From 26e156081a481d04967dc6b36b19048f8f41084c Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 25 Sep 2023 15:22:19 -0300 Subject: [PATCH 001/184] [COASTAL-1291] plugin identifier is no longer class property (#599) --- Common/Models/PumpManager.swift | 4 ++-- Loop/Managers/CGMManager.swift | 4 ++-- Loop/Managers/Service.swift | 23 +++++--------------- Loop/Managers/ServicesManager.swift | 14 +++++++++++- Loop/Managers/StatefulPluginManager.swift | 2 +- Loop/Managers/SupportManager.swift | 15 +++---------- Loop/Managers/TestingScenariosManager.swift | 2 +- Loop/View Models/ServicesViewModel.swift | 4 ++-- LoopTests/Managers/DoseEnactorTests.swift | 2 +- LoopTests/Managers/SupportManagerTests.swift | 6 ++--- 10 files changed, 34 insertions(+), 42 deletions(-) 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/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/Service.swift b/Loop/Managers/Service.swift index 1541208712..fa4a056779 100644 --- a/Loop/Managers/Service.swift +++ b/Loop/Managers/Service.swift @@ -12,21 +12,10 @@ import MockKit let staticServices: [Service.Type] = [MockService.self] -let staticServicesByIdentifier: [String: Service.Type] = staticServices.reduce(into: [:]) { (map, Type) in - map[Type.pluginIdentifier] = Type -} +let staticServicesByIdentifier: [String: Service.Type] = [ + MockService.serviceIdentifier: MockService.self +] -let availableStaticServices = staticServices.map { (Type) -> ServiceDescriptor in - return ServiceDescriptor(identifier: Type.pluginIdentifier, localizedTitle: Type.localizedTitle) -} - -func ServiceFromRawValue(_ rawValue: [String: Any]) -> Service? { - guard let serviceIdentifier = rawValue["statefulPluginIdentifier"] as? String, - let rawState = rawValue["state"] as? Service.RawStateValue, - let ServiceType = staticServicesByIdentifier[serviceIdentifier] - else { - return nil - } - - return ServiceType.init(rawState: rawState) -} +let availableStaticServices: [ServiceDescriptor] = [ + ServiceDescriptor(identifier: MockService.serviceIdentifier, localizedTitle: MockService.localizedTitle) +] diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 7e62e95333..2393ceb073 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -100,7 +100,7 @@ class ServicesManager { } private func serviceTypeFromRawValue(_ rawValue: Service.RawStateValue) -> Service.Type? { - guard let identifier = rawValue["statefulPluginIdentifier"] as? String else { + guard let identifier = rawValue["serviceIdentifier"] as? String else { return nil } @@ -400,3 +400,15 @@ extension ServicesManager: ServiceOnboardingDelegate { extension ServicesManager { var availableSupports: [SupportUI] { activeServices.compactMap { $0 as? SupportUI } } } + +// Service extension for rawValue +extension Service { + typealias RawValue = [String: Any] + + var rawValue: RawValue { + return [ + "serviceIdentifier": pluginIdentifier, + "state": rawState + ] + } +} diff --git a/Loop/Managers/StatefulPluginManager.swift b/Loop/Managers/StatefulPluginManager.swift index 22fc035b0c..13010cabf6 100644 --- a/Loop/Managers/StatefulPluginManager.swift +++ b/Loop/Managers/StatefulPluginManager.swift @@ -62,7 +62,7 @@ class StatefulPluginManager: StatefulPluggableProvider { } private func statefulPluginTypeFromRawValue(_ rawValue: StatefulPluggable.RawStateValue) -> StatefulPluggable.Type? { - guard let identifier = rawValue["statefulPluginIdentifier"] as? String else { + guard let identifier = rawValue["serviceIdentifier"] as? String else { return nil } diff --git a/Loop/Managers/SupportManager.swift b/Loop/Managers/SupportManager.swift index 58cddddf74..2111882e87 100644 --- a/Loop/Managers/SupportManager.swift +++ b/Loop/Managers/SupportManager.swift @@ -38,24 +38,17 @@ public final class SupportManager { private let alertIssuer: AlertIssuer private let deviceSupportDelegate: DeviceSupportDelegate private let pluginManager: PluginManager - private let staticSupportTypes: [SupportUI.Type] - private let staticSupportTypesByIdentifier: [String: SupportUI.Type] lazy private var cancellables = Set() init(pluginManager: PluginManager, deviceSupportDelegate: DeviceSupportDelegate, servicesManager: ServicesManager? = nil, - staticSupportTypes: [SupportUI.Type]? = nil, alertIssuer: AlertIssuer) { self.alertIssuer = alertIssuer self.deviceSupportDelegate = deviceSupportDelegate self.pluginManager = pluginManager - self.staticSupportTypes = [] - staticSupportTypesByIdentifier = self.staticSupportTypes.reduce(into: [:]) { (map, type) in - map[type.pluginIdentifier] = type - } restoreState() @@ -86,8 +79,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) } @@ -283,7 +275,7 @@ extension SupportManager { private func supportTypeFromRawValue(_ rawValue: [String: Any]) -> SupportUI.Type? { guard let supportIdentifier = rawValue["supportIdentifier"] as? String, - let supportType = pluginManager.getSupportUITypeByIdentifier(supportIdentifier) ?? staticSupportTypesByIdentifier[supportIdentifier] + let supportType = pluginManager.getSupportUITypeByIdentifier(supportIdentifier) else { return nil } @@ -331,11 +323,10 @@ fileprivate extension UserDefaults { extension SupportUI { var rawValue: RawStateValue { return [ - "supportIdentifier": Self.pluginIdentifier, + "supportIdentifier": pluginIdentifier, "state": rawState ] } - } extension Bundle { diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index b71e357433..94ee1e609a 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -126,7 +126,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) } diff --git a/Loop/View Models/ServicesViewModel.swift b/Loop/View Models/ServicesViewModel.swift index 19fb2a7d57..3021247b2c 100644 --- a/Loop/View Models/ServicesViewModel.swift +++ b/Loop/View Models/ServicesViewModel.swift @@ -54,7 +54,7 @@ public class ServicesViewModel: ObservableObject { extension ServicesViewModel { fileprivate class FakeService1: Service { static var localizedTitle: String = "Service 1" - static var pluginIdentifier: String = "FakeService1" + var pluginIdentifier: String = "FakeService1" var stateDelegate: StatefulPluggableDelegate? var serviceDelegate: ServiceDelegate? var rawState: RawStateValue = [:] @@ -65,7 +65,7 @@ extension ServicesViewModel { } fileprivate class FakeService2: Service { static var localizedTitle: String = "Service 2" - static var pluginIdentifier: String = "FakeService2" + var pluginIdentifier: String = "FakeService2" var stateDelegate: StatefulPluggableDelegate? var serviceDelegate: ServiceDelegate? var rawState: RawStateValue = [:] diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index bf722ec874..4820ecc869 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -121,7 +121,7 @@ class MockPumpManager: PumpManager { .minutes(units / deliveryUnitsPerMinute) } - static var pluginIdentifier: String = "MockPumpManager" + var pluginIdentifier: String = "MockPumpManager" var localizedTitle: String = "MockPumpManager" diff --git a/LoopTests/Managers/SupportManagerTests.swift b/LoopTests/Managers/SupportManagerTests.swift index 48fa42e4d8..8106b33005 100644 --- a/LoopTests/Managers/SupportManagerTests.swift +++ b/LoopTests/Managers/SupportManagerTests.swift @@ -34,7 +34,7 @@ class SupportManagerTests: XCTestCase { weak var delegate: SupportUIDelegate? } class MockSupport: Mixin, SupportUI { - static var pluginIdentifier: String { "SupportManagerTestsMockSupport" } + var pluginIdentifier: String { "SupportManagerTestsMockSupport" } override init() { super.init() } required init?(rawState: RawStateValue) { super.init() } var rawState: RawStateValue = [:] @@ -46,7 +46,7 @@ class SupportManagerTests: XCTestCase { } class AnotherMockSupport: Mixin, SupportUI { - static var pluginIdentifier: String { "SupportManagerTestsAnotherMockSupport" } + var pluginIdentifier: String { "SupportManagerTestsAnotherMockSupport" } override init() { super.init() } required init?(rawState: RawStateValue) { super.init() } var rawState: RawStateValue = [:] @@ -86,7 +86,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) } From cd1593413c037317bd1434668902681ab11f762d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 26 Sep 2023 11:25:06 -0500 Subject: [PATCH 002/184] Fix merge --- Common/Models/PumpManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 [] From fe1b0f917f75d526ded8aed3eb8d4d6641e2b167 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 11 Oct 2023 04:58:57 -0300 Subject: [PATCH 003/184] adding testflight configuration (#601) --- Loop.xcodeproj/project.pbxproj | 393 +++++++++++++++++++++++++++++++++ 1 file changed, 393 insertions(+) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index dcc947db00..652dc0039e 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -5499,6 +5499,388 @@ }; name = Release; }; + B4E7CF912AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 437D9BA11D7B5203007245E8 /* Loop.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APP_GROUP_IDENTIFIER = "group.$(MAIN_APP_BUNDLE_IDENTIFIER)Group"; + CLANG_ANALYZER_GCD_PERFORMANCE = YES; + CLANG_ANALYZER_LOCALIZABILITY_EMPTY_CONTEXT = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; + CLANG_ANALYZER_SECURITY_INSECUREAPI_RAND = YES; + CLANG_ANALYZER_SECURITY_INSECUREAPI_STRCPY = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_ASSIGN_ENUM = YES; + CLANG_WARN_ATOMIC_IMPLICIT_SEQ_CST = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES_ERROR; + CLANG_WARN_BOOL_CONVERSION = YES_ERROR; + CLANG_WARN_COMMA = YES_ERROR; + CLANG_WARN_CONSTANT_CONVERSION = YES_ERROR; + CLANG_WARN_CXX0X_EXTENSIONS = YES; + CLANG_WARN_DELETE_NON_VIRTUAL_DTOR = YES_ERROR; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES_ERROR; + CLANG_WARN_FLOAT_CONVERSION = YES_ERROR; + CLANG_WARN_IMPLICIT_SIGN_CONVERSION = YES_ERROR; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES_ERROR; + CLANG_WARN_MISSING_NOESCAPE = YES_ERROR; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; + CLANG_WARN_OBJC_EXPLICIT_OWNERSHIP_TYPE = YES; + CLANG_WARN_OBJC_IMPLICIT_ATOMIC_PROPERTIES = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_INTERFACE_IVARS = YES_ERROR; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES_ERROR; + CLANG_WARN_OBJC_MISSING_PROPERTY_SYNTHESIS = YES; + CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES_AGGRESSIVE; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_PRAGMA_PACK = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES_ERROR; + CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES_ERROR; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES_AGGRESSIVE; + CLANG_WARN_VEXING_PARSE = YES_ERROR; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CLANG_WARN__EXIT_TIME_DESTRUCTORS = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = YES; + GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES_ERROR; + GCC_WARN_ABOUT_MISSING_FIELD_INITIALIZERS = YES; + GCC_WARN_ABOUT_MISSING_NEWLINE = YES; + GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_FOUR_CHARACTER_CONSTANTS = YES; + GCC_WARN_HIDDEN_VIRTUAL_FUNCTIONS = YES; + GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES; + GCC_WARN_NON_VIRTUAL_DESTRUCTOR = YES; + GCC_WARN_SHADOW = YES; + GCC_WARN_SIGN_COMPARE = YES; + GCC_WARN_STRICT_SELECTOR_MATCH = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNKNOWN_PRAGMAS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_LABEL = YES; + GCC_WARN_UNUSED_PARAMETER = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LOCALIZED_STRING_MACRO_NAMES = ( + NSLocalizedString, + CFLocalizedString, + LocalizedString, + ); + MAIN_APP_BUNDLE_IDENTIFIER = "$(inherited).Loop"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + WARNING_CFLAGS = "-Wall"; + WATCHOS_DEPLOYMENT_TARGET = 7.1; + }; + name = Testflight; + }; + B4E7CF922AD00A39009B4DF2 /* Testflight */ = { + 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)"; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_FILE = Loop/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ""; + "OTHER_SWIFT_FLAGS[arch=*]" = "-DDEBUG"; + "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_RELEASE)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Testflight; + }; + B4E7CF932AD00A39009B4DF2 /* 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)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "Loop Status Extension/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_RELEASE)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Testflight; + }; + B4E7CF942AD00A39009B4DF2 /* Testflight */ = { + 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)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + FRAMEWORK_SEARCH_PATHS = ""; + IBSC_MODULE = WatchApp_Extension; + INFOPLIST_FILE = WatchApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_RELEASE)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = 4; + }; + name = Testflight; + }; + B4E7CF952AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; + CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + FRAMEWORK_SEARCH_PATHS = ""; + INFOPLIST_FILE = "WatchApp Extension/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).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 = Testflight; + }; + B4E7CF962AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CODE_SIGN_ENTITLEMENTS = "Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + GCC_DYNAMIC_NO_PIC = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWidgetExtension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WIDGET_EXTENSION_RELEASE)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Testflight; + }; + B4E7CF972AD00A39009B4DF2 /* Testflight */ = { + 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)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + ENABLE_BITCODE = NO; + 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).Loop-Intent-Extension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_INTENT_EXTENSION_RELEASE)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Testflight; + }; + B4E7CF982AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = LoopCore/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + }; + name = Testflight; + }; + B4E7CF992AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ""; + INFOPLIST_FILE = LoopCore/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_NO_PIE = NO; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; + PRODUCT_NAME = LoopCore; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + TARGETED_DEVICE_FAMILY = 4; + }; + name = Testflight; + }; + B4E7CF9A2AD00A39009B4DF2 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = LoopUI/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + }; + name = 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 = { @@ -5560,6 +5942,7 @@ isa = XCConfigurationList; buildConfigurations = ( 14B1736A28AED9EE006CCD7C /* Debug */, + B4E7CF962AD00A39009B4DF2 /* Testflight */, 14B1736B28AED9EE006CCD7C /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5569,6 +5952,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43776FB41B8022E90074EA36 /* Debug */, + B4E7CF912AD00A39009B4DF2 /* Testflight */, 43776FB51B8022E90074EA36 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5578,6 +5962,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43776FB71B8022E90074EA36 /* Debug */, + B4E7CF922AD00A39009B4DF2 /* Testflight */, 43776FB81B8022E90074EA36 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5587,6 +5972,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43A943961B926B7B0051FA24 /* Debug */, + B4E7CF952AD00A39009B4DF2 /* Testflight */, 43A943971B926B7B0051FA24 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5596,6 +5982,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43A9439A1B926B7B0051FA24 /* Debug */, + B4E7CF942AD00A39009B4DF2 /* Testflight */, 43A9439B1B926B7B0051FA24 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5605,6 +5992,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43D9002821EB209400AF44BF /* Debug */, + B4E7CF992AD00A39009B4DF2 /* Testflight */, 43D9002921EB209400AF44BF /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5614,6 +6002,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43D9FFD921EAE05D00AF44BF /* Debug */, + B4E7CF982AD00A39009B4DF2 /* Testflight */, 43D9FFDA21EAE05D00AF44BF /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5623,6 +6012,7 @@ isa = XCConfigurationList; buildConfigurations = ( 43E2D9131D20C581004DA55F /* Debug */, + B4E7CF9B2AD00A39009B4DF2 /* Testflight */, 43E2D9141D20C581004DA55F /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5632,6 +6022,7 @@ isa = XCConfigurationList; buildConfigurations = ( 4F70C1E91DE8DCA8006380B7 /* Debug */, + B4E7CF932AD00A39009B4DF2 /* Testflight */, 4F70C1EA1DE8DCA8006380B7 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5641,6 +6032,7 @@ isa = XCConfigurationList; buildConfigurations = ( 4F7528901DFE1DC600C322D6 /* Debug */, + B4E7CF9A2AD00A39009B4DF2 /* Testflight */, 4F7528911DFE1DC600C322D6 /* Release */, ); defaultConfigurationIsVisible = 0; @@ -5650,6 +6042,7 @@ isa = XCConfigurationList; buildConfigurations = ( E9B07F95253BBA6500BAD8F8 /* Debug */, + B4E7CF972AD00A39009B4DF2 /* Testflight */, E9B07F96253BBA6500BAD8F8 /* Release */, ); defaultConfigurationIsVisible = 0; From 2735876173d0d74d913e1ee3c320610c55631594 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 22 Oct 2023 13:42:25 -0500 Subject: [PATCH 004/184] LOOP-4665: Dosing Recommendations from Stateless LoopAlgorithm (#602) * Changes for functional algorithm recommendations * Remove limits from IRC * Simplify prediction input to only need those elements necessary for prediction * LoopAlgorithm recommendations compiling * LoopAlgorithm.generatePrediction parameters are extracted from LoopPredictionInput struct * Comparable implementation for ManualBolusRecommendation has moved to LoopKit --- .../StatusViewController.swift | 2 +- .../Timeline/StatusWidgetTimelimeEntry.swift | 2 +- .../StatusWidgetTimelineProvider.swift | 4 +- .../DeviceDataManager+DeviceStatus.swift | 2 +- ...osingDecisionStore+SimulatedCoreData.swift | 1 - Loop/Managers/CGMStalenessMonitor.swift | 8 +-- Loop/Managers/LoopDataManager.swift | 49 +++++--------- .../ConstantApplicationFactorStrategy.swift | 2 +- Loop/Models/LoopConstants.swift | 3 - Loop/Models/ManualBolusRecommendation.swift | 11 ---- .../StatusTableViewController.swift | 2 +- Loop/View Models/BolusEntryViewModel.swift | 4 +- Loop/Views/SimpleBolusView.swift | 2 +- LoopCore/LoopCoreConstants.swift | 3 - .../live_capture/live_capture_input.json | 65 ++++++------------- LoopTests/Managers/LoopAlgorithmTests.swift | 16 +++-- .../Managers/LoopDataManagerDosingTests.swift | 8 +-- .../ViewModels/BolusEntryViewModelTests.swift | 10 +-- .../SimpleBolusViewModelTests.swift | 2 +- .../ComplicationController.swift | 7 +- .../Controllers/ChartHUDController.swift | 2 +- .../Controllers/HUDInterfaceController.swift | 2 +- 22 files changed, 81 insertions(+), 126 deletions(-) diff --git a/Loop Status Extension/StatusViewController.swift b/Loop Status Extension/StatusViewController.swift index e3c57a98d9..16b9b64f10 100644 --- a/Loop Status Extension/StatusViewController.swift +++ b/Loop Status Extension/StatusViewController.swift @@ -291,7 +291,7 @@ class StatusViewController: UIViewController, NCWidgetProviding { lastGlucose.quantity.doubleValue(for: unit), at: lastGlucose.startDate, unit: unit, - staleGlucoseAge: LoopCoreConstants.inputDataRecencyInterval, + staleGlucoseAge: LoopAlgorithm.inputDataRecencyInterval, glucoseDisplay: context.glucoseDisplay, wasUserEntered: lastGlucose.wasUserEntered, isDisplayOnly: lastGlucose.isDisplayOnly diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift index 45271bbe14..d236427e7b 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift @@ -53,6 +53,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..5dd3af7d29 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift @@ -67,7 +67,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) } @@ -93,7 +93,7 @@ class StatusWidgetTimelineProvider: TimelineProvider { var glucose: [StoredGlucoseSample] = [] - let startDate = Date(timeIntervalSinceNow: -LoopCoreConstants.inputDataRecencyInterval) + let startDate = Date(timeIntervalSinceNow: -LoopAlgorithm.inputDataRecencyInterval) group.enter() glucoseStore.getGlucoseSamples(start: startDate) { (result) in diff --git a/Loop/Extensions/DeviceDataManager+DeviceStatus.swift b/Loop/Extensions/DeviceDataManager+DeviceStatus.swift index fbcf52b983..bc9b40f4d4 100644 --- a/Loop/Extensions/DeviceDataManager+DeviceStatus.swift +++ b/Loop/Extensions/DeviceDataManager+DeviceStatus.swift @@ -114,7 +114,7 @@ extension DeviceDataManager { var isGlucoseValueStale: Bool { guard let latestGlucoseDataDate = glucoseStore.latestGlucose?.startDate else { return true } - return Date().timeIntervalSince(latestGlucoseDataDate) > LoopCoreConstants.inputDataRecencyInterval + return Date().timeIntervalSince(latestGlucoseDataDate) > LoopAlgorithm.inputDataRecencyInterval } } diff --git a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift index 53f81c5209..94627cfdd1 100644 --- a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift @@ -168,7 +168,6 @@ fileprivate extension StoredDosingDecision { duration: .minutes(30)), bolusUnits: 1.25) let manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 0.2, - pendingInsulin: 0.75, notice: .predictedGlucoseBelowTarget(minGlucose: PredictedGlucoseValue(startDate: date.addingTimeInterval(.minutes(30)), quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 95.0)))), date: date.addingTimeInterval(-.minutes(1))) diff --git a/Loop/Managers/CGMStalenessMonitor.swift b/Loop/Managers/CGMStalenessMonitor.swift index 82cdc9267d..ad54f9d1eb 100644 --- a/Loop/Managers/CGMStalenessMonitor.swift +++ b/Loop/Managers/CGMStalenessMonitor.swift @@ -43,9 +43,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 } @@ -62,14 +62,14 @@ class CGMStalenessMonitor { } private func checkCGMStaleness() { - delegate?.getLatestCGMGlucose(since: Date(timeIntervalSinceNow: -LoopCoreConstants.inputDataRecencyInterval)) { (result) in + delegate?.getLatestCGMGlucose(since: Date(timeIntervalSinceNow: -LoopAlgorithm.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)) + self.updateCGMStalenessTimer(expiration: sample.startDate.addingTimeInterval(LoopAlgorithm.inputDataRecencyInterval + CGMStalenessMonitor.cgmStalenessTimerTolerance)) } else { self.cgmDataIsStale = true } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index b56cddd35b..142641066b 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -964,7 +964,7 @@ extension LoopDataManager { let updateGroup = DispatchGroup() let historicalGlucoseStartDate = Date(timeInterval: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval, since: now()) - let inputDataRecencyStartDate = Date(timeInterval: -LoopCoreConstants.inputDataRecencyInterval, since: now()) + let inputDataRecencyStartDate = Date(timeInterval: -LoopAlgorithm.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]? @@ -1227,7 +1227,7 @@ extension LoopDataManager { let pumpStatusDate = doseStore.lastAddedPumpData let lastGlucoseDate = glucose.startDate - guard now().timeIntervalSince(lastGlucoseDate) <= LoopCoreConstants.inputDataRecencyInterval else { + guard now().timeIntervalSince(lastGlucoseDate) <= LoopAlgorithm.inputDataRecencyInterval else { throw LoopError.glucoseTooOld(date: glucose.startDate) } @@ -1235,7 +1235,7 @@ extension LoopDataManager { throw LoopError.invalidFutureGlucose(date: lastGlucoseDate) } - guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else { + guard now().timeIntervalSince(pumpStatusDate) <= LoopAlgorithm.inputDataRecencyInterval else { throw LoopError.pumpDataTooOld(date: pumpStatusDate) } @@ -1487,15 +1487,15 @@ extension LoopDataManager { let pumpStatusDate = doseStore.lastAddedPumpData let lastGlucoseDate = glucose.startDate - guard now().timeIntervalSince(lastGlucoseDate) <= LoopCoreConstants.inputDataRecencyInterval else { + guard now().timeIntervalSince(lastGlucoseDate) <= LoopAlgorithm.inputDataRecencyInterval else { throw LoopError.glucoseTooOld(date: glucose.startDate) } - guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.inputDataRecencyInterval else { + guard lastGlucoseDate.timeIntervalSince(now()) <= LoopAlgorithm.inputDataRecencyInterval else { throw LoopError.invalidFutureGlucose(date: lastGlucoseDate) } - guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else { + guard now().timeIntervalSince(pumpStatusDate) <= LoopAlgorithm.inputDataRecencyInterval else { throw LoopError.pumpDataTooOld(date: pumpStatusDate) } @@ -1541,16 +1541,18 @@ extension LoopDataManager { let model = doseStore.insulinModelProvider.model(for: pumpInsulinType) - return predictedGlucose.recommendedManualBolus( + var recommendation = 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 + maxBolus: maxBolus ) + + // Round to pump precision + recommendation.amount = volumeRounder(recommendation.amount) + return recommendation } /// Generates a correction effect based on how large the discrepancy is between the current glucose and its model predicted value. @@ -1575,37 +1577,22 @@ extension LoopDataManager { // 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, + recencyInterval: LoopAlgorithm.inputDataRecencyInterval, retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval ) } private func computeRetrospectiveGlucoseEffect(startingAt glucose: GlucoseValue, carbEffects: [GlucoseEffect]) -> [GlucoseEffect] { - let insulinSensitivity = settings.insulinSensitivitySchedule!.quantity(at: glucose.startDate) - let basalRate = settings.basalRateSchedule!.value(at: glucose.startDate) - let correctionRange = settings.glucoseTargetRangeSchedule!.quantityRange(at: glucose.startDate) - let retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: carbStore.delta) let retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) return retrospectiveCorrection.computeEffect( startingAt: glucose, retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, - recencyInterval: LoopCoreConstants.inputDataRecencyInterval, - insulinSensitivity: insulinSensitivity, - basalRate: basalRate, - correctionRange: correctionRange, + recencyInterval: LoopAlgorithm.inputDataRecencyInterval, retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval ) } @@ -1690,17 +1677,17 @@ extension LoopDataManager { var errors = [LoopError]() - if startDate.timeIntervalSince(glucose.startDate) > LoopCoreConstants.inputDataRecencyInterval { + if startDate.timeIntervalSince(glucose.startDate) > LoopAlgorithm.inputDataRecencyInterval { errors.append(.glucoseTooOld(date: glucose.startDate)) } - if glucose.startDate.timeIntervalSince(startDate) > LoopCoreConstants.inputDataRecencyInterval { + if glucose.startDate.timeIntervalSince(startDate) > LoopAlgorithm.inputDataRecencyInterval { errors.append(.invalidFutureGlucose(date: glucose.startDate)) } let pumpStatusDate = doseStore.lastAddedPumpData - if startDate.timeIntervalSince(pumpStatusDate) > LoopCoreConstants.inputDataRecencyInterval { + if startDate.timeIntervalSince(pumpStatusDate) > LoopAlgorithm.inputDataRecencyInterval { errors.append(.pumpDataTooOld(date: pumpStatusDate)) } @@ -2176,7 +2163,7 @@ extension LoopDataManager { sensitivitySchedule: sensitivitySchedule, at: date) - dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit()), pendingInsulin: 0, notice: notice), + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit()), notice: notice), date: Date()) return dosingDecision diff --git a/Loop/Models/ConstantApplicationFactorStrategy.swift b/Loop/Models/ConstantApplicationFactorStrategy.swift index e13c40c42e..7489367cae 100644 --- a/Loop/Models/ConstantApplicationFactorStrategy.swift +++ b/Loop/Models/ConstantApplicationFactorStrategy.swift @@ -18,6 +18,6 @@ struct ConstantApplicationFactorStrategy: ApplicationFactorStrategy { settings: LoopSettings ) -> Double { // The original strategy uses a constant dosing factor. - return LoopConstants.bolusPartialApplicationFactor + return LoopAlgorithm.bolusPartialApplicationFactor } } diff --git a/Loop/Models/LoopConstants.swift b/Loop/Models/LoopConstants.swift index fb69c8275f..bd1296c12f 100644 --- a/Loop/Models/LoopConstants.swift +++ b/Loop/Models/LoopConstants.swift @@ -49,9 +49,6 @@ enum LoopConstants { static let retrospectiveCorrectionEnabled = true - // Percentage of recommended dose to apply as bolus when using automatic bolus dosing strategy - static let bolusPartialApplicationFactor = 0.4 - /// Loop completion aging category limits static let completionFreshLimit = TimeInterval(minutes: 6) static let completionAgingLimit = TimeInterval(minutes: 16) diff --git a/Loop/Models/ManualBolusRecommendation.swift b/Loop/Models/ManualBolusRecommendation.swift index c1ad01125a..d176b77cf8 100644 --- a/Loop/Models/ManualBolusRecommendation.swift +++ b/Loop/Models/ManualBolusRecommendation.swift @@ -62,14 +62,3 @@ extension BolusRecommendationNotice: Equatable { } } - -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/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 8906a75986..84bc9428c6 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -610,7 +610,7 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView.cgmStatusHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: unit), at: glucose.startDate, unit: unit, - staleGlucoseAge: LoopCoreConstants.inputDataRecencyInterval, + staleGlucoseAge: LoopAlgorithm.inputDataRecencyInterval, glucoseDisplay: self.deviceManager.glucoseDisplay(for: glucose), wasUserEntered: glucose.wasUserEntered, isDisplayOnly: glucose.isDisplayOnly) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index a86f20e0cc..08047d67c5 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -818,12 +818,12 @@ extension BolusEntryViewModel { var isGlucoseDataStale: Bool { guard let latestGlucoseDataDate = delegate?.mostRecentGlucoseDataDate else { return true } - return now().timeIntervalSince(latestGlucoseDataDate) > LoopCoreConstants.inputDataRecencyInterval + return now().timeIntervalSince(latestGlucoseDataDate) > LoopAlgorithm.inputDataRecencyInterval } var isPumpDataStale: Bool { guard let latestPumpDataDate = delegate?.mostRecentPumpDataDate else { return true } - return now().timeIntervalSince(latestPumpDataDate) > LoopCoreConstants.inputDataRecencyInterval + return now().timeIntervalSince(latestPumpDataDate) > LoopAlgorithm.inputDataRecencyInterval } var isManualGlucosePromptVisible: Bool { diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index 2a7fc3fe59..aa7546c6f9 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -392,7 +392,7 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { var decision = BolusDosingDecision(for: .simpleBolus) - decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 3, pendingInsulin: 0), + decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 3), date: Date()) return decision } diff --git a/LoopCore/LoopCoreConstants.swift b/LoopCore/LoopCoreConstants.swift index d56f2ab9b6..d33ca167bc 100644 --- a/LoopCore/LoopCoreConstants.swift +++ b/LoopCore/LoopCoreConstants.swift @@ -10,9 +10,6 @@ import Foundation import LoopKit 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) diff --git a/LoopTests/Fixtures/live_capture/live_capture_input.json b/LoopTests/Fixtures/live_capture/live_capture_input.json index f010194a63..4bd97abaa9 100644 --- a/LoopTests/Fixtures/live_capture/live_capture_input.json +++ b/LoopTests/Fixtures/live_capture/live_capture_input.json @@ -962,48 +962,25 @@ "startDate" : "2023-06-23T02:37:35Z" } ], - "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 - } - } - ] - } + "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 + } + ], + "sensitivity" : [ + { + "endDate" : "2023-06-23T05:00:00Z", + "startDate" : "2023-06-22T10:00:00Z", + "value" : 60 + } + ], } diff --git a/LoopTests/Managers/LoopAlgorithmTests.swift b/LoopTests/Managers/LoopAlgorithmTests.swift index 6c51283872..084a72a3cf 100644 --- a/LoopTests/Managers/LoopAlgorithmTests.swift +++ b/LoopTests/Managers/LoopAlgorithmTests.swift @@ -49,7 +49,7 @@ final class LoopAlgorithmTests: XCTestCase { } - func testLiveCaptureWithFunctionalAlgorithm() throws { + func testLiveCaptureWithFunctionalAlgorithm() { // This matches the "testForecastFromLiveCaptureInputData" test of LoopDataManagerDosingTests, // Using the same input data, but generating the forecast using the LoopAlgorithm.generatePrediction() // function. @@ -57,9 +57,17 @@ final class LoopAlgorithmTests: XCTestCase { 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 input = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) + + let prediction = LoopAlgorithm.generatePrediction( + glucoseHistory: input.glucoseHistory, + doses: input.doses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) let expectedPredictedGlucose = loadPredictedGlucoseFixture("live_capture_predicted_glucose") diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index a1f26a0e92..9cdb1f43cd 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -64,19 +64,19 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { // 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) + RepeatingScheduleValue(startTime: 0, value: predictionInput.basal.first!.value) ]) let insulinSensitivitySchedule = InsulinSensitivitySchedule( unit: .milligramsPerDeciliter, dailyItems: [ - RepeatingScheduleValue(startTime: 0, value: predictionInput.settings.sensitivity.first!.value.doubleValue(for: .milligramsPerDeciliter)) + 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.settings.carbRatio.first!.value) + RepeatingScheduleValue(startTime: 0.0, value: predictionInput.carbRatio.first!.value) ], timeZone: .utcTimeZone )! @@ -89,7 +89,7 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { carbRatioSchedule: carbRatioSchedule, maximumBasalRatePerHour: 10, maximumBolus: 5, - suspendThreshold: predictionInput.settings.suspendThreshold, + suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 65), automaticDosingStrategy: .automaticBolus ) diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index c373b639b1..8b65faa377 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -298,7 +298,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusNoNotice() async throws { await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321) + let recommendation = ManualBolusRecommendation(amount: 1.25) delegate.loopState.bolusRecommendationResult = recommendation await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) @@ -315,7 +315,7 @@ class BolusEntryViewModelTests: XCTestCase { 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)) + let recommendation = ManualBolusRecommendation(amount: 1.25, notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue)) delegate.loopState.bolusRecommendationResult = recommendation await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) @@ -328,7 +328,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusWithNoticeMissingSuspendThreshold() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) delegate.settings.suspendThreshold = nil - let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321, notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue)) + let recommendation = ManualBolusRecommendation(amount: 1.25, notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue)) delegate.loopState.bolusRecommendationResult = recommendation await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) @@ -340,7 +340,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusWithOtherNotice() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321, notice: BolusRecommendationNotice.currentGlucoseBelowTarget(glucose: Self.exampleGlucoseValue)) + let recommendation = ManualBolusRecommendation(amount: 1.25, notice: BolusRecommendationNotice.currentGlucoseBelowTarget(glucose: Self.exampleGlucoseValue)) delegate.loopState.bolusRecommendationResult = recommendation await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) @@ -404,7 +404,7 @@ class BolusEntryViewModelTests: XCTestCase { await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321) + let recommendation = ManualBolusRecommendation(amount: 1.25) delegate.loopState.bolusRecommendationResult = recommendation await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) diff --git a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift index 94c1fd8661..92d7de8b7e 100644 --- a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift +++ b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift @@ -319,7 +319,7 @@ extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { var decision = BolusDosingDecision(for: .simpleBolus) - decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: currentRecommendation, pendingInsulin: 0, notice: .none), + decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: currentRecommendation, notice: .none), date: date) decision.insulinOnBoard = currentIOB return decision diff --git a/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index 9f79aad280..1eae019f17 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -8,6 +8,7 @@ import ClockKit import WatchKit +import LoopKit import LoopCore import os.log @@ -88,7 +89,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, at: timelineDate, - recencyInterval: LoopCoreConstants.inputDataRecencyInterval, + recencyInterval: LoopAlgorithm.inputDataRecencyInterval, chartGenerator: self.makeChart) { switch complication.family { @@ -119,7 +120,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { var futureChangeDates: [Date] = [ // Stale glucose date: just a second after glucose expires - glucoseDate + LoopCoreConstants.inputDataRecencyInterval + 1, + glucoseDate + LoopAlgorithm.inputDataRecencyInterval + 1, ] if let loopLastRunDate = context.loopLastRunDate { @@ -135,7 +136,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { if let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, at: futureChangeDate, - recencyInterval: LoopCoreConstants.inputDataRecencyInterval, + recencyInterval: LoopAlgorithm.inputDataRecencyInterval, chartGenerator: self.makeChart) { template.tintColor = UIColor.tintColor diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift index f7aa0b0231..341eece3f6 100644 --- a/WatchApp Extension/Controllers/ChartHUDController.swift +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -162,7 +162,7 @@ final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { cell.setIsLastRow(row.isLast) cell.setContentInset(systemMinimumLayoutMargins) - let isActiveContextStale = Date().timeIntervalSince(activeContext.creationDate) > LoopCoreConstants.inputDataRecencyInterval + let isActiveContextStale = Date().timeIntervalSince(activeContext.creationDate) > LoopAlgorithm.inputDataRecencyInterval switch row { case .iob: diff --git a/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift index b23dc56680..2ff5f54fb0 100644 --- a/WatchApp Extension/Controllers/HUDInterfaceController.swift +++ b/WatchApp Extension/Controllers/HUDInterfaceController.swift @@ -79,7 +79,7 @@ class HUDInterfaceController: WKInterfaceController { eventualGlucoseLabel.setHidden(true) } - if let glucose = activeContext.glucose, let glucoseDate = activeContext.glucoseDate, let unit = activeContext.displayGlucoseUnit, glucoseDate.timeIntervalSinceNow > -LoopCoreConstants.inputDataRecencyInterval { + if let glucose = activeContext.glucose, let glucoseDate = activeContext.glucoseDate, let unit = activeContext.displayGlucoseUnit, glucoseDate.timeIntervalSinceNow > -LoopAlgorithm.inputDataRecencyInterval { let formatter = NumberFormatter.glucoseFormatter(for: unit) if let glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) { From 860516651b14a660a0440095bdf5068bdd3cc154 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 27 Oct 2023 15:32:23 -0300 Subject: [PATCH 005/184] fixing the restore of a stateful plugin (#603) --- Loop/Managers/StatefulPluginManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/StatefulPluginManager.swift b/Loop/Managers/StatefulPluginManager.swift index 13010cabf6..22fc035b0c 100644 --- a/Loop/Managers/StatefulPluginManager.swift +++ b/Loop/Managers/StatefulPluginManager.swift @@ -62,7 +62,7 @@ class StatefulPluginManager: StatefulPluggableProvider { } private func statefulPluginTypeFromRawValue(_ rawValue: StatefulPluggable.RawStateValue) -> StatefulPluggable.Type? { - guard let identifier = rawValue["serviceIdentifier"] as? String else { + guard let identifier = rawValue["statefulPluginIdentifier"] as? String else { return nil } From 99b29fdbf687105484aefbbc449ef5615452ab17 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 31 Oct 2023 10:18:56 -0700 Subject: [PATCH 006/184] [LOOP-4721] Copy size change --- Loop/Views/HowMuteAlertWorkView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/HowMuteAlertWorkView.swift b/Loop/Views/HowMuteAlertWorkView.swift index 08443a6b80..4ade045d49 100644 --- a/Loop/Views/HowMuteAlertWorkView.swift +++ b/Loop/Views/HowMuteAlertWorkView.swift @@ -54,7 +54,7 @@ struct HowMuteAlertWorkView: View { Spacer() } - .font(.footnote) + .font(.subheadline) .foregroundColor(.black.opacity(0.6)) .padding() .overlay( From 6e6c6bb1c8bc7e2d28bc310f7841e77008760bbe Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 3 Nov 2023 11:23:10 -0700 Subject: [PATCH 007/184] [LOOP-4751] Dark Mode Fix --- Loop/Views/HowMuteAlertWorkView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/HowMuteAlertWorkView.swift b/Loop/Views/HowMuteAlertWorkView.swift index 4ade045d49..22135e5f60 100644 --- a/Loop/Views/HowMuteAlertWorkView.swift +++ b/Loop/Views/HowMuteAlertWorkView.swift @@ -55,7 +55,7 @@ struct HowMuteAlertWorkView: View { Spacer() } .font(.subheadline) - .foregroundColor(.black.opacity(0.6)) + .foregroundColor(.primary.opacity(0.6)) .padding() .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) From 9ccb7dd16042b0aad6b3dc2986df77dee4169f43 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 1 Dec 2023 16:09:19 -0400 Subject: [PATCH 008/184] [PAL-172] only display pump manager provided HUD view when there is no status highlight (#607) --- LoopUI/Views/PumpStatusHUDView.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/LoopUI/Views/PumpStatusHUDView.swift b/LoopUI/Views/PumpStatusHUDView.swift index 7b6aaeb889..fbe6a0bc58 100644 --- a/LoopUI/Views/PumpStatusHUDView.swift +++ b/LoopUI/Views/PumpStatusHUDView.swift @@ -43,7 +43,7 @@ public final class PumpStatusHUDView: DeviceStatusHUDView, NibLoadable { } override public func presentStatusHighlight() { - guard !statusStackView.arrangedSubviews.contains(statusHighlightView) else { + guard !isStatusHighlightDisplayed else { return } @@ -86,7 +86,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) + } } From 62b673bb1fed817fad53bf26e997695a6907168f Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 18 Dec 2023 05:43:38 -0400 Subject: [PATCH 009/184] [PAL-236] when bolus amount exceeds max, display warning (#608) --- Loop/View Models/BolusEntryViewModel.swift | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 08047d67c5..ff0408e1a2 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -340,6 +340,16 @@ final class BolusEntryViewModel: ObservableObject { assertionFailure("Missing BolusEntryViewModelDelegate") return false } + + 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) guard enteredBolusAmount == 0 || amountToDeliver > 0 else { @@ -352,16 +362,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) From 58e6a2a96ea87cd7e336ee4e69413503612bba78 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 19 Dec 2023 08:50:04 -0600 Subject: [PATCH 010/184] LOOP-4752 Integrate stateless algorithm into Loop (#606) * Start work on new LoopDataManager using stateless algorithm * Fetching data for new ldm * LoopDosingManager * Fix method parameter names * LoopDosingManager handling automatic dosing * Consolidating presets management into TemporaryPresetsManager * Presets consolidation * Reorg back to LoopDataManager * Main status table view updates * Bolus view fetching algo input * Fix iob graph issue * Get active carb values for graph time frame * Notify on update of settings * Fix carb display and edit * Update active insulin and active carbs in bolus entry view * Restore active preset, and add note about premeal. Always confirm override deactivation. * Refactoring * Mocking for simplifying DeviceDataManagerTests * Fixing more tests * LoopDataManager tests all passing * Fixing tests * Meal detection manager tests * Update remote recommendations * TemporaryPresetsManagerTests * BolusEntryView use recommendManualBolus * Add tests for max iob * Cleanup * Cleanup * LoopSettingsTests were just overrides tests, and have been moved to TemporaryPresetsManagerTests * Get active insulin from loop display state * Update from PR feedback * Assert DeviceDataManager triggers alert upload * Remove unused method * Guard against missing glucose --- Common/Models/LoopSettingsUserInfo.swift | 86 +- Loop.xcodeproj/project.pbxproj | 288 +- Loop/AppDelegate.swift | 11 +- Loop/Extensions/BasalDeliveryState.swift | 6 +- ...aManager+BolusEntryViewModelDelegate.swift | 97 - .../DeviceDataManager+DeviceStatus.swift | 16 +- ...Manager+SimpleBolusViewModelDelegate.swift | 33 - .../SettingsStore+SimulatedCoreData.swift | 4 +- Loop/Extensions/UIDevice+Loop.swift | 4 +- Loop/Extensions/UserNotifications+Loop.swift | 39 +- Loop/Managers/AlertPermissionsChecker.swift | 2 +- Loop/Managers/Alerts/AlertManager.swift | 63 +- Loop/Managers/AnalyticsServicesManager.swift | 4 - .../CriticalEventLogExportManager.swift | 91 + Loop/Managers/DeviceDataManager.swift | 998 ++---- Loop/Managers/DoseEnactor.swift | 50 +- Loop/Managers/ExtensionDataManager.swift | 197 +- .../LocalTestingScenariosManager.swift | 79 - Loop/Managers/LoopAppManager.swift | 559 ++- .../LoopDataManager+CarbAbsorption.swift | 118 + Loop/Managers/LoopDataManager.swift | 2998 +++++------------ .../MealDetectionManager.swift | 256 +- Loop/Managers/NotificationManager.swift | 5 +- Loop/Managers/OnboardingManager.swift | 19 +- Loop/Managers/RemoteDataServicesManager.swift | 10 +- Loop/Managers/ServicesManager.swift | 29 +- Loop/Managers/SettingsManager.swift | 205 +- Loop/Managers/StatefulPluginManager.swift | 4 +- .../Store Protocols/CarbStoreProtocol.swift | 50 +- .../Store Protocols/DoseStoreProtocol.swift | 51 +- .../DosingDecisionStoreProtocol.swift | 8 +- .../GlucoseStoreProtocol.swift | 26 +- .../LatestStoredSettingsProvider.swift | 2 +- Loop/Managers/SupportManager.swift | 9 +- Loop/Managers/TemporaryPresetsManager.swift | 283 ++ Loop/Managers/TestingScenariosManager.swift | 162 +- Loop/Managers/TrustedTimeChecker.swift | 42 +- Loop/Managers/WatchDataManager.swift | 325 +- Loop/Models/ApplicationFactorStrategy.swift | 3 +- .../ConstantApplicationFactorStrategy.swift | 5 +- ...lucoseBasedApplicationFactorStrategy.swift | 4 +- Loop/Models/LoopError.swift | 15 +- Loop/Models/PredictionInputEffect.swift | 21 +- .../CarbAbsorptionViewController.swift | 175 +- .../CommandResponseViewController.swift | 23 +- .../InsulinDeliveryTableViewController.swift | 135 +- .../PredictionTableViewController.swift | 129 +- .../StatusTableViewController.swift | 652 ++-- Loop/View Models/BolusEntryViewModel.swift | 341 +- Loop/View Models/CarbEntryViewModel.swift | 29 +- .../ManualEntryDoseViewModel.swift | 214 +- Loop/View Models/SettingsViewModel.swift | 1 + Loop/View Models/SimpleBolusViewModel.swift | 213 +- Loop/View Models/VersionUpdateViewModel.swift | 1 + Loop/Views/BolusEntryView.swift | 3 + Loop/Views/ManualEntryDoseView.swift | 8 +- Loop/Views/SimpleBolusView.swift | 35 +- LoopCore/LoopSettings.swift | 142 - LoopCore/Result.swift | 12 - .../flat_and_stable_carb_effect.json | 1 - .../flat_and_stable_counteraction_effect.json | 230 -- .../flat_and_stable_insulin_effect.json | 377 --- .../flat_and_stable_momentum_effect.json | 1 - .../flat_and_stable_predicted_glucose.json | 382 --- .../high_and_falling_carb_effect.json | 1 - ...high_and_falling_counteraction_effect.json | 236 -- .../high_and_falling_insulin_effect.json | 377 --- .../high_and_falling_momentum_effect.json | 27 - .../high_and_falling_predicted_glucose.json | 382 --- .../high_and_rising_with_cob_carb_effect.json | 322 -- ..._rising_with_cob_counteraction_effect.json | 266 -- ...gh_and_rising_with_cob_insulin_effect.json | 387 --- ...h_and_rising_with_cob_momentum_effect.json | 27 - ...and_rising_with_cob_predicted_glucose.json | 392 --- .../high_and_stable_carb_effect.json | 322 -- .../high_and_stable_counteraction_effect.json | 512 --- .../high_and_stable_insulin_effect.json | 382 --- .../high_and_stable_momentum_effect.json | 27 - .../high_and_stable_predicted_glucose.json | 387 --- .../low_and_falling_carb_effect.json | 322 -- .../low_and_falling_counteraction_effect.json | 218 -- .../low_and_falling_insulin_effect.json | 382 --- .../low_and_falling_momentum_effect.json | 27 - .../low_and_falling_predicted_glucose.json | 382 --- .../low_with_low_treatment_carb_effect.json | 312 -- ...th_low_treatment_counteraction_effect.json | 230 -- ...low_with_low_treatment_insulin_effect.json | 377 --- ...ow_with_low_treatment_momentum_effect.json | 1 - ..._with_low_treatment_predicted_glucose.json | 382 --- .../Managers/Alerts/AlertManagerTests.swift | 182 +- .../Managers/DeviceDataManagerTests.swift | 200 ++ LoopTests/Managers/DoseEnactorTests.swift | 162 +- LoopTests/Managers/LoopAlgorithmTests.swift | 141 + .../Managers/LoopDataManagerDosingTests.swift | 647 ---- LoopTests/Managers/LoopDataManagerTests.swift | 422 ++- .../Managers/MealDetectionManagerTests.swift | 457 ++- LoopTests/Managers/SettingsManagerTests.swift | 35 + LoopTests/Managers/SupportManagerTests.swift | 6 +- .../TemporaryPresetsManagerTests.swift} | 53 +- LoopTests/Mock Stores/MockCarbStore.swift | 176 +- LoopTests/Mock Stores/MockDoseStore.swift | 159 +- .../Mock Stores/MockDosingDecisionStore.swift | 25 +- LoopTests/Mock Stores/MockGlucoseStore.swift | 188 +- LoopTests/Mock Stores/MockSettingsStore.swift | 2 +- LoopTests/Mocks/AlertMocks.swift | 192 ++ LoopTests/Mocks/LoopControlMock.swift | 28 + LoopTests/Mocks/MockCGMManager.swift | 63 + LoopTests/Mocks/MockDeliveryDelegate.swift | 45 + LoopTests/Mocks/MockPumpManager.swift | 141 + LoopTests/Mocks/MockSettingsProvider.swift | 49 + LoopTests/Mocks/MockTrustedTimeChecker.swift | 14 + LoopTests/Mocks/MockUploadEventListener.swift | 17 + LoopTests/Mocks/PersistenceController.swift | 16 + .../ViewModels/BolusEntryViewModelTests.swift | 440 +-- .../ManualEntryDoseViewModelTests.swift | 101 +- .../SimpleBolusViewModelTests.swift | 118 +- .../Controllers/ActionHUDController.swift | 54 +- .../OverrideSelectionController.swift | 2 +- WatchApp Extension/ExtensionDelegate.swift | 4 +- WatchApp Extension/Extensions/WCSession.swift | 4 +- .../Managers/LoopDataManager.swift | 32 +- .../CarbAndBolusFlowViewModel.swift | 4 +- 122 files changed, 6065 insertions(+), 15175 deletions(-) delete mode 100644 Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift delete mode 100644 Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift delete mode 100644 Loop/Managers/LocalTestingScenariosManager.swift create mode 100644 Loop/Managers/LoopDataManager+CarbAbsorption.swift create mode 100644 Loop/Managers/TemporaryPresetsManager.swift delete mode 100644 LoopCore/Result.swift delete mode 100644 LoopTests/Fixtures/flat_and_stable/flat_and_stable_carb_effect.json delete mode 100644 LoopTests/Fixtures/flat_and_stable/flat_and_stable_counteraction_effect.json delete mode 100644 LoopTests/Fixtures/flat_and_stable/flat_and_stable_insulin_effect.json delete mode 100644 LoopTests/Fixtures/flat_and_stable/flat_and_stable_momentum_effect.json delete mode 100644 LoopTests/Fixtures/flat_and_stable/flat_and_stable_predicted_glucose.json delete mode 100644 LoopTests/Fixtures/high_and_falling/high_and_falling_carb_effect.json delete mode 100644 LoopTests/Fixtures/high_and_falling/high_and_falling_counteraction_effect.json delete mode 100644 LoopTests/Fixtures/high_and_falling/high_and_falling_insulin_effect.json delete mode 100644 LoopTests/Fixtures/high_and_falling/high_and_falling_momentum_effect.json delete mode 100644 LoopTests/Fixtures/high_and_falling/high_and_falling_predicted_glucose.json delete mode 100644 LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_carb_effect.json delete mode 100644 LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_counteraction_effect.json delete mode 100644 LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_insulin_effect.json delete mode 100644 LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_momentum_effect.json delete mode 100644 LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_predicted_glucose.json delete mode 100644 LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json delete mode 100644 LoopTests/Fixtures/high_and_stable/high_and_stable_counteraction_effect.json delete mode 100644 LoopTests/Fixtures/high_and_stable/high_and_stable_insulin_effect.json delete mode 100644 LoopTests/Fixtures/high_and_stable/high_and_stable_momentum_effect.json delete mode 100644 LoopTests/Fixtures/high_and_stable/high_and_stable_predicted_glucose.json delete mode 100644 LoopTests/Fixtures/low_and_falling/low_and_falling_carb_effect.json delete mode 100644 LoopTests/Fixtures/low_and_falling/low_and_falling_counteraction_effect.json delete mode 100644 LoopTests/Fixtures/low_and_falling/low_and_falling_insulin_effect.json delete mode 100644 LoopTests/Fixtures/low_and_falling/low_and_falling_momentum_effect.json delete mode 100644 LoopTests/Fixtures/low_and_falling/low_and_falling_predicted_glucose.json delete mode 100644 LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_carb_effect.json delete mode 100644 LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_counteraction_effect.json delete mode 100644 LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_insulin_effect.json delete mode 100644 LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_momentum_effect.json delete mode 100644 LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_predicted_glucose.json create mode 100644 LoopTests/Managers/DeviceDataManagerTests.swift delete mode 100644 LoopTests/Managers/LoopDataManagerDosingTests.swift create mode 100644 LoopTests/Managers/SettingsManagerTests.swift rename LoopTests/{LoopSettingsTests.swift => Managers/TemporaryPresetsManagerTests.swift} (64%) create mode 100644 LoopTests/Mocks/AlertMocks.swift create mode 100644 LoopTests/Mocks/LoopControlMock.swift create mode 100644 LoopTests/Mocks/MockCGMManager.swift create mode 100644 LoopTests/Mocks/MockDeliveryDelegate.swift create mode 100644 LoopTests/Mocks/MockPumpManager.swift create mode 100644 LoopTests/Mocks/MockSettingsProvider.swift create mode 100644 LoopTests/Mocks/MockTrustedTimeChecker.swift create mode 100644 LoopTests/Mocks/MockUploadEventListener.swift create mode 100644 LoopTests/Mocks/PersistenceController.swift diff --git a/Common/Models/LoopSettingsUserInfo.swift b/Common/Models/LoopSettingsUserInfo.swift index a6123825d8..bf95b076b4 100644 --- a/Common/Models/LoopSettingsUserInfo.swift +++ b/Common/Models/LoopSettingsUserInfo.swift @@ -6,10 +6,67 @@ // import LoopCore +import LoopKit +struct LoopSettingsUserInfo: Equatable { + var loopSettings: LoopSettings + var scheduleOverride: TemporaryScheduleOverride? + var preMealOverride: TemporaryScheduleOverride? + + public mutating func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { + preMealOverride = makePreMealOverride(beginningAt: date, for: duration) + } + + private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let preMealTargetRange = loopSettings.preMealTargetRange else { + return nil + } + return TemporaryScheduleOverride( + context: .preMeal, + settings: TemporaryScheduleOverrideSettings(targetRange: preMealTargetRange), + startDate: date, + duration: .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + public mutating func clearOverride(matching context: TemporaryScheduleOverride.Context? = nil) { + if context == .preMeal { + preMealOverride = nil + return + } + + guard let scheduleOverride = scheduleOverride else { return } + + if let context = context { + if scheduleOverride.context == context { + self.scheduleOverride = nil + } + } else { + self.scheduleOverride = nil + } + } + + public func nonPreMealOverrideEnabled(at date: Date = Date()) -> Bool { + return scheduleOverride?.isActive(at: date) == true + } + + public mutating func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let legacyWorkoutTargetRange = loopSettings.legacyWorkoutTargetRange else { + return nil + } + + return TemporaryScheduleOverride( + context: .legacyWorkout, + settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), + startDate: date, + duration: duration.isInfinite ? .indefinite : .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } -struct LoopSettingsUserInfo { - let settings: LoopSettings } @@ -23,19 +80,36 @@ extension LoopSettingsUserInfo: RawRepresentable { guard rawValue["v"] as? Int == LoopSettingsUserInfo.version, rawValue["name"] as? String == LoopSettingsUserInfo.name, let settingsRaw = rawValue["s"] as? LoopSettings.RawValue, - let settings = LoopSettings(rawValue: settingsRaw) + let loopSettings = LoopSettings(rawValue: settingsRaw) else { return nil } - self.settings = settings + self.loopSettings = loopSettings + + if let rawScheduleOverride = rawValue["o"] as? TemporaryScheduleOverride.RawValue { + self.scheduleOverride = TemporaryScheduleOverride(rawValue: rawScheduleOverride) + } else { + self.scheduleOverride = nil + } + + if let rawPreMealOverride = rawValue["p"] as? TemporaryScheduleOverride.RawValue { + self.preMealOverride = TemporaryScheduleOverride(rawValue: rawPreMealOverride) + } else { + self.preMealOverride = nil + } } var rawValue: RawValue { - return [ + var raw: RawValue = [ "v": LoopSettingsUserInfo.version, "name": LoopSettingsUserInfo.name, - "s": settings.rawValue + "s": loopSettings.rawValue ] + + raw["o"] = scheduleOverride?.rawValue + raw["p"] = preMealOverride?.rawValue + + return raw } } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 652dc0039e..ab382ca1de 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -59,7 +59,6 @@ 1D80313D24746274002810DF /* AlertStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D80313C24746274002810DF /* AlertStoreTests.swift */; }; 1D82E6A025377C6B009131FB /* TrustedTimeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */; }; 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8D55BB252274650044DBB6 /* BolusEntryViewModelTests.swift */; }; - 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9650C72523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift */; }; 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA649A6244126CD00F61E75 /* UserNotificationAlertScheduler.swift */; }; 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA649A8244126DA00F61E75 /* InAppModalAlertScheduler.swift */; }; 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA7A84124476EAD008257F0 /* AlertManagerTests.swift */; }; @@ -93,8 +92,6 @@ 4344628220A7A37F00C4BE6F /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628120A7A37E00C4BE6F /* CoreBluetooth.framework */; }; 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4344629120A7C19800C4BE6F /* ButtonGroup.swift */; }; 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; - 4345E3F421F036FC009E00E5 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D848AF1E7DCBE100DADCBC /* Result.swift */; }; - 4345E3F521F036FC009E00E5 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D848AF1E7DCBE100DADCBC /* Result.swift */; }; 4345E3FB21F04911009E00E5 /* UIColor+HIG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */; }; 4345E3FC21F04911009E00E5 /* UIColor+HIG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */; }; 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */; }; @@ -271,7 +268,6 @@ 895788B3242E69A2002CB114 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788AB242E69A2002CB114 /* ActionButton.swift */; }; 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */; }; 8968B1122408B3520074BB48 /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8968B1112408B3520074BB48 /* UIFont.swift */; }; - 8968B114240C55F10074BB48 /* LoopSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8968B113240C55F10074BB48 /* LoopSettingsTests.swift */; }; 897A5A9624C2175B00C4E71D /* BolusEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */; }; 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */; }; 898ECA60218ABD17001E9D35 /* GlucoseChartScaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */; }; @@ -293,7 +289,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 */; }; @@ -415,6 +410,7 @@ C11BD0552523CFED00236B08 /* SimpleBolusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */; }; C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; C1201E2D23ECDF3D002DA84A /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; + C129BF4A2B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */; }; 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 */; }; C13DA2B024F6C7690098BB29 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13DA2AF24F6C7690098BB29 /* UIViewController.swift */; }; @@ -443,7 +439,13 @@ C17824A51E1AD4D100D9D25C /* ManualBolusRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */; }; C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; }; C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; }; - C18913B52524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18913B42524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift */; }; + C188599B2AF15E1B0010F21F /* DeviceDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */; }; + C188599E2AF15FAB0010F21F /* AlertMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599D2AF15FAB0010F21F /* AlertMocks.swift */; }; + C18859A02AF1612B0010F21F /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599F2AF1612B0010F21F /* PersistenceController.swift */; }; + C18859A22AF165130010F21F /* MockPumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859A12AF165130010F21F /* MockPumpManager.swift */; }; + C18859A42AF165330010F21F /* MockCGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859A32AF165330010F21F /* MockCGMManager.swift */; }; + C18859A82AF292D90010F21F /* MockTrustedTimeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859A72AF292D90010F21F /* MockTrustedTimeChecker.swift */; }; + C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18859AB2AF29BE50010F21F /* TemporaryPresetsManager.swift */; }; C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19008FD25225D3900721625 /* SimpleBolusCalculator.swift */; }; C1900900252271BB00721625 /* SimpleBolusCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */; }; C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */; }; @@ -463,6 +465,7 @@ C19F48742560ABFB003632D7 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; C1AD4200256D61E500164DDD /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AD41FF256D61E500164DDD /* Comparable.swift */; }; C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */; }; + C1B80D632AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B80D622AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift */; }; C1C660D1252E4DD5009B5C32 /* LoopConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C660D0252E4DD5009B5C32 /* LoopConstants.swift */; }; C1C73F0D1DE3D0270022FC89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1C73F0F1DE3D0270022FC89 /* InfoPlist.strings */; }; C1CCF1122858FA900035389C /* LoopCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */; }; @@ -472,6 +475,11 @@ C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */; }; C1D476B42A8ED179002C1C87 /* LoopAlgorithmTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */; }; C1D6EEA02A06C7270047DE5C /* MKRingProgressView in Frameworks */ = {isa = PBXBuildFile; productRef = C1D6EE9F2A06C7270047DE5C /* MKRingProgressView */; }; + C1DA434F2B164C6C00CBD33F /* MockSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA434E2B164C6C00CBD33F /* MockSettingsProvider.swift */; }; + C1DA43532B19310A00CBD33F /* LoopControlMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43522B19310A00CBD33F /* LoopControlMock.swift */; }; + C1DA43552B193BCB00CBD33F /* MockUploadEventListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43542B193BCB00CBD33F /* MockUploadEventListener.swift */; }; + C1DA43572B1A70BE00CBD33F /* SettingsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */; }; + C1DA43592B1A784900CBD33F /* MockDeliveryDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43582B1A784900CBD33F /* MockDeliveryDelegate.swift */; }; C1DE5D23251BFC4D00439E49 /* SimpleBolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */; }; C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1E2773D224177C000354103 /* ClockKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */; }; @@ -493,46 +501,15 @@ 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 */; }; @@ -776,7 +753,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 = ""; }; @@ -903,7 +879,6 @@ 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeaderValuesTableViewCell.swift; sourceTree = ""; }; 43D533BB1CFD1DD7009E3085 /* WatchApp Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = "WatchApp Extension.entitlements"; sourceTree = ""; }; - 43D848AF1E7DCBE100DADCBC /* Result.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; 43D9002A21EB209400AF44BF /* LoopCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 43D9002C21EB225D00AF44BF /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/HealthKit.framework; sourceTree = DEVELOPER_DIR; }; 43D9F81721EC51CC000578CD /* DateEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateEntry.swift; sourceTree = ""; }; @@ -1203,7 +1178,6 @@ 895788AB242E69A2002CB114 /* ActionButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideSelectionViewController.swift; sourceTree = ""; }; 8968B1112408B3520074BB48 /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; - 8968B113240C55F10074BB48 /* LoopSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSettingsTests.swift; sourceTree = ""; }; 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryView.swift; sourceTree = ""; }; 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryViewModel.swift; sourceTree = ""; }; 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseChartScaler.swift; sourceTree = ""; }; @@ -1226,7 +1200,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 = ""; }; @@ -1415,6 +1388,7 @@ C122DEFE29BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/ckcomplication.strings; sourceTree = ""; }; C122DEFF29BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; C122DF0029BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; + C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryPresetsManagerTests.swift; sourceTree = ""; }; C12BCCF929BBFA480066A158 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; C12CB9AC23106A3C00F84978 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Intents.strings; sourceTree = ""; }; C12CB9AE23106A5C00F84978 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Intents.strings; sourceTree = ""; }; @@ -1459,10 +1433,16 @@ C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManualBolusRecommendation.swift; sourceTree = ""; }; C1814B85225E507C008D2D8E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; C186B73F298309A700F83024 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDataManagerTests.swift; sourceTree = ""; }; + C188599D2AF15FAB0010F21F /* AlertMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMocks.swift; sourceTree = ""; }; + C188599F2AF1612B0010F21F /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; + C18859A12AF165130010F21F /* MockPumpManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPumpManager.swift; sourceTree = ""; }; + C18859A32AF165330010F21F /* MockCGMManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCGMManager.swift; sourceTree = ""; }; + C18859A72AF292D90010F21F /* MockTrustedTimeChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTrustedTimeChecker.swift; sourceTree = ""; }; + C18859AB2AF29BE50010F21F /* TemporaryPresetsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryPresetsManager.swift; sourceTree = ""; }; C18886E629830A5E004C982D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; C18886E729830A5E004C982D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; C18886E829830A5E004C982D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/ckcomplication.strings; sourceTree = ""; }; - C18913B42524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+SimpleBolusViewModelDelegate.swift"; sourceTree = ""; }; C18A491222FCC22800FDA733 /* build-derived-assets.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "build-derived-assets.sh"; sourceTree = ""; }; C18A491322FCC22900FDA733 /* make_scenario.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = make_scenario.py; sourceTree = ""; }; C18A491522FCC22900FDA733 /* copy-plugins.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "copy-plugins.sh"; sourceTree = ""; }; @@ -1508,6 +1488,7 @@ C1B2679B2995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; C1B2679C2995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/ckcomplication.strings; sourceTree = ""; }; C1B2679D2995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; + C1B80D622AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopDataManager+CarbAbsorption.swift"; sourceTree = ""; }; C1BCB5AF298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1BCB5B0298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1BCB5B1298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; @@ -1546,6 +1527,11 @@ C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalDeliveryState.swift; sourceTree = ""; }; C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAlgorithmTests.swift; sourceTree = ""; }; C1D70F7A2A914F71009FE129 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; + C1DA434E2B164C6C00CBD33F /* MockSettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSettingsProvider.swift; sourceTree = ""; }; + C1DA43522B19310A00CBD33F /* LoopControlMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopControlMock.swift; sourceTree = ""; }; + C1DA43542B193BCB00CBD33F /* MockUploadEventListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUploadEventListener.swift; sourceTree = ""; }; + C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerTests.swift; sourceTree = ""; }; + C1DA43582B1A784900CBD33F /* MockDeliveryDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDeliveryDelegate.swift; sourceTree = ""; }; C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistedProperty.swift; sourceTree = ""; }; C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SimpleBolusView.swift; sourceTree = ""; }; C1E2773D224177C000354103 /* ClockKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ClockKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/ClockKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -1610,44 +1596,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 = ""; }; @@ -1861,13 +1816,15 @@ 1DA7A84024476E98008257F0 /* Alerts */, C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */, A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */, + C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */, C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, + C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.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 = ""; @@ -2164,7 +2121,6 @@ C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, 431E73471FF95A900069B5F7 /* PersistenceController.swift */, - 43D848AF1E7DCBE100DADCBC /* Result.swift */, 43D9FFD121EAE05D00AF44BF /* LoopCore.h */, 43D9FFD221EAE05D00AF44BF /* Info.plist */, 4B60626A287E286000BF8BBB /* Localizable.strings */, @@ -2188,10 +2144,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 */, @@ -2283,40 +2237,41 @@ children = ( B42D124228D371C400E43D22 /* AlertMuter.swift */, 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */, + 1DA6499D2441266400F61E75 /* Alerts */, 439897361CD2F80600223065 /* AnalyticsServicesManager.swift */, + C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */, 439BED291E76093C00B0AED5 /* CGMManager.swift */, C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */, A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */, + 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */, C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */, 43DBF0521C93EC8200B3C386 /* DeviceDataManager.swift */, C16B983D26B4893300256B05 /* DoseEnactor.swift */, - 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */, + 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */, A9C62D862331703000535612 /* LoggingServicesManager.swift */, A9D5C5B525DC6C6A00534873 /* LoopAppManager.swift */, 43A567681C94880B00334FAC /* LoopDataManager.swift */, + E9B355232935906B0076AB04 /* Missed Meal Detection */, 43C094491CACCC73001F6403 /* NotificationManager.swift */, A97F250725E056D500F0EE19 /* OnboardingManager.swift */, 432E73CA1D24B3D6009AD15D /* RemoteDataServicesManager.swift */, - B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */, + 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, A9C62D852331703000535612 /* Service.swift */, A9C62D872331703000535612 /* ServicesManager.swift */, C1F7822527CC056900C0919A /* SettingsManager.swift */, + A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, E9BB27AA23B85C3500FB4987 /* SleepStore.swift */, B470F5832AB22B5100049695 /* StatefulPluggable.swift */, + B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */, 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */, + E95D37FF24EADE68005E2F50 /* Store Protocols */, 1D63DEA426E950D400F46FA5 /* SupportManager.swift */, - 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */, + C18859AB2AF29BE50010F21F /* TemporaryPresetsManager.swift */, 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */, 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */, 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */, - 1DA6499D2441266400F61E75 /* Alerts */, - E95D37FF24EADE68005E2F50 /* Store Protocols */, - E9B355232935906B0076AB04 /* Missed Meal Detection */, - C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, - A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, - 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, - 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */, + C1B80D622AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift */, ); path = Managers; sourceTree = ""; @@ -2324,17 +2279,17 @@ 43F78D2C1C8FC58F002152D1 /* LoopTests */ = { isa = PBXGroup; children = ( + A9DF02CA24F72B9E00B7C988 /* CriticalEventLogTests.swift */, + A96DAC292838EF8A00D94E38 /* DiagnosticLogTests.swift */, E9C58A7624DB510500487A17 /* Fixtures */, + 43E2D90F1D20C581004DA55F /* Info.plist */, B4CAD8772549D2330057946B /* LoopCore */, + A9DAE7CF2332D77F006AE942 /* LoopTests.swift */, 1DA7A83F24476E8C008257F0 /* Managers */, + E93E86AC24DDE02C00FF40C8 /* Mock Stores */, + C188599C2AF15F9A0010F21F /* Mocks */, A9E6DFED246A0460005B1A1C /* Models */, B4BC56362518DE8800373647 /* ViewModels */, - 43E2D90F1D20C581004DA55F /* Info.plist */, - A9DF02CA24F72B9E00B7C988 /* CriticalEventLogTests.swift */, - A96DAC292838EF8A00D94E38 /* DiagnosticLogTests.swift */, - A9DAE7CF2332D77F006AE942 /* LoopTests.swift */, - 8968B113240C55F10074BB48 /* LoopSettingsTests.swift */, - E93E86AC24DDE02C00FF40C8 /* Mock Stores */, ); path = LoopTests; sourceTree = ""; @@ -2774,6 +2729,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 = ( @@ -2787,54 +2758,6 @@ path = Scripts; sourceTree = ""; }; - E90909CB24E34A7A00F963D2 /* high_and_rising_with_cob */ = { - isa = PBXGroup; - children = ( - E90909CF24E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json */, - E90909D024E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json */, - E90909CD24E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json */, - E90909CC24E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json */, - E90909CE24E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json */, - ); - path = high_and_rising_with_cob; - sourceTree = ""; - }; - E90909D624E34EC200F963D2 /* low_and_falling */ = { - isa = PBXGroup; - children = ( - E90909D824E34F1500F963D2 /* low_and_falling_carb_effect.json */, - E90909D924E34F1500F963D2 /* low_and_falling_counteraction_effect.json */, - E90909DA24E34F1600F963D2 /* low_and_falling_insulin_effect.json */, - E90909DB24E34F1600F963D2 /* low_and_falling_momentum_effect.json */, - E90909D724E34F1500F963D2 /* low_and_falling_predicted_glucose.json */, - ); - path = low_and_falling; - sourceTree = ""; - }; - E90909E124E352C300F963D2 /* low_with_low_treatment */ = { - isa = PBXGroup; - children = ( - E90909E224E3530200F963D2 /* low_with_low_treatment_carb_effect.json */, - E90909E624E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json */, - E90909E324E3530200F963D2 /* low_with_low_treatment_insulin_effect.json */, - E90909E524E3530200F963D2 /* low_with_low_treatment_momentum_effect.json */, - E90909E424E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json */, - ); - path = low_with_low_treatment; - sourceTree = ""; - }; - E90909EC24E35B3400F963D2 /* high_and_falling */ = { - isa = PBXGroup; - children = ( - E90909F524E35B7C00F963D2 /* high_and_falling_momentum_effect.json */, - E90909F024E35B4C00F963D2 /* high_and_falling_carb_effect.json */, - E90909EF24E35B4C00F963D2 /* high_and_falling_counteraction_effect.json */, - E90909F124E35B4C00F963D2 /* high_and_falling_insulin_effect.json */, - E90909ED24E35B4000F963D2 /* high_and_falling_predicted_glucose.json */, - ); - path = high_and_falling; - sourceTree = ""; - }; E93E86AC24DDE02C00FF40C8 /* Mock Stores */ = { isa = PBXGroup; children = ( @@ -2848,30 +2771,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 = ( @@ -2924,12 +2823,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 */, @@ -3413,7 +3306,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 */, @@ -3421,44 +3313,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 */, C16FC0B02A99392F0025E239 /* live_capture_input.json in Resources */, - E93E86BE24E1FDC400FF40C8 /* flat_and_stable_carb_effect.json in Resources */, - E90909E824E3530200F963D2 /* low_with_low_treatment_insulin_effect.json in Resources */, E9B3553B293706CB0076AB04 /* noisy_cgm_counteraction_effect.json in Resources */, E9B3553C293706CB0076AB04 /* realistic_report_counteraction_effect.json in Resources */, E93E865824DB75BE00FF40C8 /* predicted_glucose_very_negative.json in Resources */, - E93E86BC24E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json in Resources */, - E90909EB24E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json in Resources */, - E90909F424E35B4D00F963D2 /* high_and_falling_insulin_effect.json in Resources */, E9B35539293706CB0076AB04 /* dynamic_autofill_counteraction_effect.json in Resources */, - E90909F624E35B7C00F963D2 /* high_and_falling_momentum_effect.json in Resources */, - E93E86BA24E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json in Resources */, - E90909E724E3530200F963D2 /* low_with_low_treatment_carb_effect.json in Resources */, - E90909EA24E3530200F963D2 /* low_with_low_treatment_momentum_effect.json in Resources */, - E90909D324E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json in Resources */, - E90909D524E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json in Resources */, E9C58A7D24DB529A00487A17 /* basal_profile.json in Resources */, - E93E86CD24E2E02200FF40C8 /* high_and_stable_counteraction_effect.json in Resources */, - E93E86C324E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json in Resources */, E9C58A7E24DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json in Resources */, - E90909F324E35B4D00F963D2 /* high_and_falling_carb_effect.json in Resources */, - E93E86CA24E2E02200FF40C8 /* high_and_stable_insulin_effect.json in Resources */, - E93E86BB24E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json in Resources */, - E93E86CB24E2E02200FF40C8 /* high_and_stable_carb_effect.json in Resources */, E9C58A7C24DB529A00487A17 /* momentum_effect_bouncing.json in Resources */, - E90909E024E34F1600F963D2 /* low_and_falling_momentum_effect.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3683,6 +3546,7 @@ C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, + C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, @@ -3714,6 +3578,7 @@ 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 */, @@ -3724,7 +3589,6 @@ A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */, A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */, 1DB1065124467E18005542BD /* AlertManager.swift in Sources */, - 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */, 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */, 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */, 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */, @@ -3804,7 +3668,6 @@ 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */, 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */, C174233C259BEB0F00399C9D /* ManualEntryDoseViewModel.swift in Sources */, - 89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */, 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */, C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */, @@ -3834,7 +3697,6 @@ 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 */, @@ -3942,7 +3804,6 @@ E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */, C1D0B6312986D4D90098D215 /* LocalizedString.swift in Sources */, 43C05CB821EBEA54006FB252 /* HKUnit.swift in Sources */, - 4345E3F421F036FC009E00E5 /* Result.swift in Sources */, C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */, 43D9002021EB209400AF44BF /* NSTimeInterval.swift in Sources */, C16575762539FEF3004AE16E /* LoopCoreConstants.swift in Sources */, @@ -3963,7 +3824,6 @@ E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */, C1D0B6302986D4D90098D215 /* LocalizedString.swift in Sources */, 43C05CB921EBEA54006FB252 /* HKUnit.swift in Sources */, - 4345E3F521F036FC009E00E5 /* Result.swift in Sources */, C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */, 43D9FFFB21EAF3D300AF44BF /* NSTimeInterval.swift in Sources */, C16575752539FD60004AE16E /* LoopCoreConstants.swift in Sources */, @@ -3991,33 +3851,43 @@ 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 */, + C1DA43552B193BCB00CBD33F /* MockUploadEventListener.swift in Sources */, B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.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 */, 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 */, + C1DA43592B1A784900CBD33F /* MockDeliveryDelegate.swift in Sources */, + C18859A02AF1612B0010F21F /* PersistenceController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Loop/AppDelegate.swift b/Loop/AppDelegate.swift index 5da6ce9cb6..a707eb1a61 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -22,9 +22,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, WindowProvider { setenv("CFNETWORK_DIAGNOSTICS", "3", 1) - loopAppManager.initialize(windowProvider: self, launchOptions: launchOptions) - loopAppManager.launch() - return loopAppManager.isLaunchComplete + // Avoid doing full initialization when running tests + if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { + loopAppManager.initialize(windowProvider: self, launchOptions: launchOptions) + loopAppManager.launch() + return loopAppManager.isLaunchComplete + } else { + return true + } } // MARK: - UIApplicationDelegate - Life Cycle diff --git a/Loop/Extensions/BasalDeliveryState.swift b/Loop/Extensions/BasalDeliveryState.swift index 7aef479ccf..6b53f06e2b 100644 --- a/Loop/Extensions/BasalDeliveryState.swift +++ b/Loop/Extensions/BasalDeliveryState.swift @@ -10,7 +10,7 @@ import LoopKit import LoopCore 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 +20,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 +30,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/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 bc9b40f4d4..5f08105b2e 100644 --- a/Loop/Extensions/DeviceDataManager+DeviceStatus.swift +++ b/Loop/Extensions/DeviceDataManager+DeviceStatus.swift @@ -41,16 +41,16 @@ extension DeviceDataManager { } else if pumpManager == nil { return DeviceDataManager.addPumpStatusHighlight } else { - return pumpManager?.pumpStatusHighlight + return (pumpManager as? PumpManagerUI)?.pumpStatusHighlight } } var pumpStatusBadge: DeviceStatusBadge? { - return pumpManager?.pumpStatusBadge + return (pumpManager as? PumpManagerUI)?.pumpStatusBadge } var pumpLifecycleProgress: DeviceLifecycleProgress? { - return pumpManager?.pumpLifecycleProgress + return (pumpManager as? PumpManagerUI)?.pumpLifecycleProgress } static var resumeOnboardingStatusHighlight: ResumeOnboardingStatusHighlight { @@ -104,18 +104,12 @@ extension DeviceDataManager { let action = pumpManagerHUDProvider.didTapOnHUDView(view, allowDebugFeatures: FeatureFlags.allowDebugFeatures) { return action - } else if let pumpManager = pumpManager { + } else if let pumpManager = pumpManager as? PumpManagerUI { return .presentViewController(pumpManager.settingsViewController(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures, allowedInsulinTypes: allowedInsulinTypes)) } else { return .setupNewPump } - } - - var isGlucoseValueStale: Bool { - guard let latestGlucoseDataDate = glucoseStore.latestGlucose?.startDate else { return true } - - return Date().timeIntervalSince(latestGlucoseDataDate) > LoopAlgorithm.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/SettingsStore+SimulatedCoreData.swift b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift index 80c990bb38..5fbcd152f6 100644 --- a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift +++ b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift @@ -154,9 +154,7 @@ fileprivate extension StoredSettings { glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, preMealTargetRange: DoubleRange(minValue: 80.0, maxValue: 90.0).quantityRange(for: .milligramsPerDeciliter), workoutTargetRange: DoubleRange(minValue: 150.0, maxValue: 160.0).quantityRange(for: .milligramsPerDeciliter), - overridePresets: nil, - scheduleOverride: nil, - preMealOverride: preMealOverride, + overridePresets: [], maximumBasalRatePerHour: 3.5, maximumBolus: 10.0, suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 75.0), diff --git a/Loop/Extensions/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/UserNotifications+Loop.swift b/Loop/Extensions/UserNotifications+Loop.swift index cd1959c907..dd5eec862d 100644 --- a/Loop/Extensions/UserNotifications+Loop.swift +++ b/Loop/Extensions/UserNotifications+Loop.swift @@ -9,26 +9,25 @@ import UserNotifications extension UNUserNotificationCenter { - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { - getNotificationSettings() { notificationSettings in - let report: [String] = [ - "## NotificationSettings", - "", - "* authorizationStatus: \(String(describing: notificationSettings.authorizationStatus))", - "* soundSetting: \(String(describing: notificationSettings.soundSetting))", - "* badgeSetting: \(String(describing: notificationSettings.badgeSetting))", - "* alertSetting: \(String(describing: notificationSettings.alertSetting))", - "* notificationCenterSetting: \(String(describing: notificationSettings.notificationCenterSetting))", - "* lockScreenSetting: \(String(describing: notificationSettings.lockScreenSetting))", - "* carPlaySetting: \(String(describing: notificationSettings.carPlaySetting))", - "* alertStyle: \(String(describing: notificationSettings.alertStyle))", - "* showPreviewsSetting: \(String(describing: notificationSettings.showPreviewsSetting))", - "* criticalAlertSetting: \(String(describing: notificationSettings.criticalAlertSetting))", - "* providesAppNotificationSettings: \(String(describing: notificationSettings.providesAppNotificationSettings))", - "* announcementSetting: \(String(describing: notificationSettings.announcementSetting))", - ] - completion(report.joined(separator: "\n")) - } + func generateDiagnosticReport() async -> String { + let notificationSettings = await notificationSettings() + let report: [String] = [ + "## NotificationSettings", + "", + "* authorizationStatus: \(String(describing: notificationSettings.authorizationStatus))", + "* soundSetting: \(String(describing: notificationSettings.soundSetting))", + "* badgeSetting: \(String(describing: notificationSettings.badgeSetting))", + "* alertSetting: \(String(describing: notificationSettings.alertSetting))", + "* notificationCenterSetting: \(String(describing: notificationSettings.notificationCenterSetting))", + "* lockScreenSetting: \(String(describing: notificationSettings.lockScreenSetting))", + "* carPlaySetting: \(String(describing: notificationSettings.carPlaySetting))", + "* alertStyle: \(String(describing: notificationSettings.alertStyle))", + "* showPreviewsSetting: \(String(describing: notificationSettings.showPreviewsSetting))", + "* criticalAlertSetting: \(String(describing: notificationSettings.criticalAlertSetting))", + "* providesAppNotificationSettings: \(String(describing: notificationSettings.providesAppNotificationSettings))", + "* announcementSetting: \(String(describing: notificationSettings.announcementSetting))", + ] + return report.joined(separator: "\n") } } diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index bae4512e6a..12c88c5e71 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -34,7 +34,7 @@ public class AlertPermissionsChecker: ObservableObject { init() { // Check on loop complete, but only while in the background. - NotificationCenter.default.publisher(for: .LoopCompleted) + NotificationCenter.default.publisher(for: .LoopCycleCompleted) .receive(on: RunLoop.main) .sink { [weak self] _ in guard let self = self else { return } diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 50b99666e2..010a00074a 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -24,6 +24,7 @@ public enum AlertUserNotificationUserInfoKey: String { /// - managing the different responders that might acknowledge the alert /// - serializing alerts to storage /// - etc. +@MainActor public final class AlertManager { private static let soundsDirectoryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).last!.appendingPathComponent("Sounds") @@ -88,10 +89,12 @@ public final class AlertManager { bluetoothProvider.addBluetoothObserver(self, queue: .main) - NotificationCenter.default.publisher(for: .LoopCompleted) + NotificationCenter.default.publisher(for: .LoopCycleCompleted) .sink { [weak self] publisher in if let loopDataManager = publisher.object as? LoopDataManager { - self?.loopDidComplete(loopDataManager.lastLoopCompleted) + Task { @MainActor in + self?.loopDidComplete(loopDataManager.lastLoopCompleted) + } } } .store(in: &cancellables) @@ -404,10 +407,12 @@ extension AlertManager: AlertIssuer { extension AlertManager { + nonisolated public static func soundURL(for alert: Alert) -> URL? { return soundURL(managerIdentifier: alert.identifier.managerIdentifier, sound: alert.sound) } + nonisolated private static func soundURL(managerIdentifier: String, sound: Alert.Sound?) -> URL? { guard let soundFileName = sound?.filename else { return nil } @@ -494,31 +499,35 @@ extension AlertManager { // MARK: Alert storage access extension AlertManager { - func getStoredEntries(startDate: Date, completion: @escaping (_ report: String) -> Void) { - alertStore.executeQuery(since: startDate, limit: 100) { result in - switch result { - case .failure(let error): - completion("Error: \(error)") - case .success(_, let objects): - let encoder = JSONEncoder() - let report = "## Alerts\n" + objects.map { object in - return """ - **\(object.title ?? "??")** - - * identifier: \(object.identifier.value) - * issued: \(object.issuedDate) - * acknowledged: \(object.acknowledgedDate?.description ?? "n/a") - * retracted: \(object.retractedDate?.description ?? "n/a") - * trigger: \(object.trigger) - * interruptionLevel: \(object.interruptionLevel) - * foregroundContent: \((try? encoder.encodeToStringIfPresent(object.foregroundContent)) ?? "n/a") - * backgroundContent: \((try? encoder.encodeToStringIfPresent(object.backgroundContent)) ?? "n/a") - * sound: \((try? encoder.encodeToStringIfPresent(object.sound)) ?? "n/a") - * metadata: \((try? encoder.encodeToStringIfPresent(object.metadata)) ?? "n/a") - - """ - }.joined(separator: "\n") - completion(report) + func generateDiagnosticReport() async -> String { + await withCheckedContinuation { continuation in + let startDate = Date() - .days(3.5) // Report the last 3 and half days of alerts + let header = "## Alerts\n" + alertStore.executeQuery(since: startDate, limit: 100) { result in + switch result { + case .failure: + continuation.resume(returning: header) + case .success(_, let objects): + let encoder = JSONEncoder() + let report = header + objects.map { object in + return """ + **\(object.title ?? "??")** + + * identifier: \(object.identifier.value) + * issued: \(object.issuedDate) + * acknowledged: \(object.acknowledgedDate?.description ?? "n/a") + * retracted: \(object.retractedDate?.description ?? "n/a") + * trigger: \(object.trigger) + * interruptionLevel: \(object.interruptionLevel) + * foregroundContent: \((try? encoder.encodeToStringIfPresent(object.foregroundContent)) ?? "n/a") + * backgroundContent: \((try? encoder.encodeToStringIfPresent(object.backgroundContent)) ?? "n/a") + * sound: \((try? encoder.encodeToStringIfPresent(object.sound)) ?? "n/a") + * metadata: \((try? encoder.encodeToStringIfPresent(object.metadata)) ?? "n/a") + + """ + }.joined(separator: "\n") + continuation.resume(returning: report) + } } } } diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 808a34c81a..4e80ba7bd5 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -143,10 +143,6 @@ final class AnalyticsServicesManager { logEvent("Therapy schedule time zone change") } - if newValue.scheduleOverride != oldValue.scheduleOverride { - logEvent("Temporary schedule override change") - } - if newValue.glucoseTargetRangeSchedule != oldValue.glucoseTargetRangeSchedule { logEvent("Glucose target range change") } diff --git a/Loop/Managers/CriticalEventLogExportManager.swift b/Loop/Managers/CriticalEventLogExportManager.swift index 6b8f699e5c..546c7986fe 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 @@ -197,6 +199,16 @@ public class CriticalEventLogExportManager { calendar.timeZone = TimeZone(identifier: "UTC")! return calendar }() + + // MARK: - Background Tasks + + func registerBackgroundTasks() { + if Self.registerCriticalEventLogHistoricalExportBackgroundTask({ self.handleCriticalEventLogHistoricalExportBackgroundTask($0) }) { + log.debug("Critical event log export background task registered") + } else { + log.error("Critical event log export background task not registered") + } + } } // MARK: - CriticalEventLogBaseExporter @@ -551,3 +563,82 @@ fileprivate extension FileManager { return temporaryDirectory.appendingPathComponent(UUID().uuidString) } } + +// MARK: - Critical Event Log Export + +extension CriticalEventLogExportManager { + 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 = 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.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 + } +} + +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/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index b6cd35d3a6..234dc4eed4 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -6,7 +6,6 @@ // Copyright © 2015 Nathan Racklyeft. All rights reserved. // -import BackgroundTasks import HealthKit import LoopKit import LoopKitUI @@ -15,10 +14,28 @@ import LoopTestingKit import UserNotifications import Combine +protocol LoopControl { + var lastLoopCompleted: Date? { get } + func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async + 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 +47,9 @@ final class DeviceDataManager { private let launchDate = Date() /// The last error recorded by a device manager - /// Should be accessed only on the main queue private(set) var lastError: (date: Date, error: Error)? - private var deviceLog: PersistentDeviceLog + var deviceLog: PersistentDeviceLog // MARK: - App-level responsibilities @@ -84,17 +100,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 +127,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 +158,13 @@ final class DeviceDataManager { var doseEnactor = DoseEnactor() // MARK: Stores - let healthStore: HKHealthStore - - let carbStore: CarbStore - - let doseStore: DoseStore - - let glucoseStore: GlucoseStore - - let cgmEventStore: CgmEventStore - + private let healthStore: HKHealthStore + private let carbStore: CarbStore + private let doseStore: DoseStore + private let glucoseStore: GlucoseStore private let cacheStore: PersistenceController + private let cgmEventStore: CgmEventStore - let dosingDecisionStore: DosingDecisionStore - /// All the HealthKit types to be read by stores private var readTypes: Set { var readTypes: Set = [] @@ -207,51 +209,48 @@ final class DeviceDataManager { sleepDataAuthorizationRequired } - private(set) var statefulPluginManager: StatefulPluginManager! - // MARK: Services - private(set) var servicesManager: ServicesManager! + private var analyticsServicesManager: AnalyticsServicesManager + private var uploadEventListener: UploadEventListener + private var activeServicesProvider: ActiveServicesProvider - var analyticsServicesManager: AnalyticsServicesManager + // MARK: Misc Managers - var settingsManager: SettingsManager - - var remoteDataServicesManager: RemoteDataServicesManager { return servicesManager.remoteDataServicesManager } - - 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, 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) - { + displayGlucosePreference: DisplayGlucosePreference, + displayGlucoseUnitBroadcaster: DisplayGlucoseUnitBroadcaster + ) { let fileManager = FileManager.default let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! @@ -267,190 +266,41 @@ final class DeviceDataManager { self.pluginManager = pluginManager self.alertManager = alertManager + self.settingsManager = settingsManager + self.healthStore = healthStore + self.carbStore = carbStore + self.doseStore = doseStore + self.glucoseStore = glucoseStore + self.cgmEventStore = cgmEventStore + self.loopControl = loopControl + self.analyticsServicesManager = analyticsServicesManager self.bluetoothProvider = bluetoothProvider self.alertPresenter = alertPresenter - - self.healthStore = HKHealthStore() + self.automaticDosingStatus = automaticDosingStatus self.cacheStore = cacheStore - self.settingsManager = settingsManager + self.crashRecoveryManager = crashRecoveryManager + self.activeStatefulPluginsProvider = activeStatefulPluginsProvider + self.uploadEventListener = uploadEventListener + self.activeServicesProvider = activeServicesProvider + self.displayGlucosePreference = displayGlucosePreference + self.displayGlucoseUnitBroadcaster = displayGlucoseUnitBroadcaster - let absorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes - let sensitivitySchedule = settingsManager.latestSettings.insulinSensitivitySchedule - - let carbHealthStore = HealthKitSampleStore( - healthStore: healthStore, - observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitCarbSamplesFromOtherApps, // At some point we should let the user decide which apps they would like to import from. - type: HealthKitSampleStore.carbType, - observationStart: Date().addingTimeInterval(-absorptionTimes.slow * 2) - ) - - self.carbStore = CarbStore( - healthKitSampleStore: carbHealthStore, - cacheStore: cacheStore, - cacheLength: localCacheDuration, - defaultAbsorptionTimes: absorptionTimes, - carbRatioSchedule: settingsManager.latestSettings.carbRatioSchedule, - insulinSensitivitySchedule: sensitivitySchedule, - overrideHistory: overrideHistory, - carbAbsorptionModel: FeatureFlags.nonlinearCarbModelEnabled ? .nonlinear : .linear, - provenanceIdentifier: HKSource.default().bundleIdentifier - ) - - let insulinModelProvider: InsulinModelProvider - if FeatureFlags.adultChildInsulinModelSelectionEnabled { - insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: settingsManager.latestSettings.defaultRapidActingModel?.presetForRapidActingInsulin) - } else { - insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) - } - - self.analyticsServicesManager = analyticsServicesManager - - 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) - - self.trustedTimeChecker = trustedTimeChecker - - crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) - alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) - - if let pumpManagerRawValue = rawPumpManager ?? UserDefaults.appGroup?.legacyPumpManagerRawValue { - pumpManager = pumpManagerFromRawValue(pumpManagerRawValue) - // Update lastPumpEventsReconciliation on DoseStore - if let lastSync = pumpManager?.lastSync { - doseStore.addPumpEvents([], lastReconciliation: lastSync) { _ in } - } - if let status = pumpManager?.status { - updatePumpIsAllowingAutomation(status: status) - } - } else { - pumpManager = nil - } - - if let cgmManagerRawValue = rawCGMManager ?? UserDefaults.appGroup?.legacyCGMManagerRawValue { - cgmManager = cgmManagerFromRawValue(cgmManagerRawValue) - - // Handle case of PumpManager providing CGM - if cgmManager == nil && pumpManagerTypeFromRawValue(cgmManagerRawValue) != nil { - 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 } @@ -460,17 +310,28 @@ final class DeviceDataManager { .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 + 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 } + } + if let status = pumpManager?.status { + updatePumpIsAllowingAutomation(status: status) } + } else { + pumpManager = nil + } - Task { @MainActor in - if let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) { - self.displayGlucosePreference.unitDidChange(to: unit) - self.notifyObserversOfDisplayGlucoseUnitChange(to: unit) - } + if let cgmManagerRawValue = rawCGMManager ?? UserDefaults.appGroup?.legacyCGMManagerRawValue { + cgmManager = cgmManagerFromRawValue(cgmManagerRawValue) + + // Handle case of PumpManager providing CGM + if cgmManager == nil && pumpManagerTypeFromRawValue(cgmManagerRawValue) != nil { + cgmManager = pumpManager as? CGMManager } } } @@ -521,7 +382,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 +409,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 +418,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 } - pumpManager.ensureCurrentPumpData() { (lastSync) in - self.loopManager.loop() + 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 + } + + guard let scheduledBasalRate = settingsManager.settings.basalRateSchedule?.value(at: tempBasal.startDate), + tempBasal.unitsPerHour > scheduledBasalRate else + { + return } + + // Cancel active high temp basal + 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 +518,7 @@ final class DeviceDataManager { public func cgmManagerTypeByIdentifier(_ identifier: String) -> CGMManagerUI.Type? { return pluginManager.getCGMManagerTypeByIdentifier(identifier) ?? staticCGMManagersByIdentifier[identifier] as? CGMManagerUI.Type } - + public func setupCGMManagerFromPumpManager(withIdentifier identifier: String) -> CGMManager? { guard identifier == pumpManager?.pluginIdentifier, let cgmManager = pumpManager as? CGMManager else { return nil @@ -674,9 +549,7 @@ final class DeviceDataManager { func checkDeliveryUncertaintyState() { if let pumpManager = pumpManager, pumpManager.status.deliveryIsUncertain { - DispatchQueue.main.async { - self.deliveryUncertaintyAlertManager?.showAlert() - } + self.deliveryUncertaintyAlertManager?.showAlert() } } @@ -700,14 +573,49 @@ final class DeviceDataManager { self.getHealthStoreAuthorization(completion) } } + + private func refreshCGM() async { + guard let cgmManager = cgmManager else { + return + } + + let result = await cgmManager.fetchNewDataIfNeeded() + + if case .newData = result { + self.analyticsServicesManager.didFetchNewCGMData() + } + + await self.processCGMReadingResult(cgmManager, readingResult: result) + + let lastLoopCompleted = self.loopControl.lastLoopCompleted + + if lastLoopCompleted == nil || lastLoopCompleted!.timeIntervalSinceNow < -.minutes(4.2) { + self.log.default("Triggering Loop from refreshCGM()") + await self.checkPumpDataAndLoop() + } + } + + func refreshDeviceData() async { + await refreshCGM() + + guard let pumpManager = self.pumpManager, pumpManager.isOnboarded else { + return + } + + await pumpManager.ensureCurrentPumpData() + } + + var isGlucoseValueStale: Bool { + guard let latestGlucoseDataDate = glucoseStore.latestGlucose?.startDate else { return true } + + return Date().timeIntervalSince(latestGlucoseDataDate) > LoopAlgorithm.inputDataRecencyInterval + } } private extension DeviceDataManager { func setupCGM() { - dispatchPrecondition(condition: .onQueue(.main)) - cgmManager?.cgmManagerDelegate = self - cgmManager?.delegateQueue = queue + cgmManager?.delegateQueue = DispatchQueue.main reportPluginInitializationComplete() glucoseStore.managedDataInterval = cgmManager?.managedDataInterval @@ -725,7 +633,7 @@ private extension DeviceDataManager { } if let cgmManagerUI = cgmManager as? CGMManagerUI { - addDisplayGlucoseUnitObserver(cgmManagerUI) + displayGlucoseUnitBroadcaster?.addDisplayGlucoseUnitObserver(cgmManagerUI) } } @@ -733,17 +641,17 @@ private extension DeviceDataManager { dispatchPrecondition(condition: .onQueue(.main)) pumpManager?.pumpManagerDelegate = self - pumpManager?.delegateQueue = queue + pumpManager?.delegateQueue = DispatchQueue.main reportPluginInitializationComplete() doseStore.device = pumpManager?.status.device - pumpManagerHUDProvider = pumpManager?.hudProvider(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowedInsulinTypes: allowedInsulinTypes) + pumpManagerHUDProvider = (pumpManager as? PumpManagerUI)?.hudProvider(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowedInsulinTypes: allowedInsulinTypes) // Proliferate PumpModel preferences to DoseStore if let pumpRecordsBasalProfileStartEvents = pumpManager?.pumpRecordsBasalProfileStartEvents { doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents } - if let pumpManager = pumpManager { + if let pumpManager = pumpManager as? PumpManagerUI { alertManager?.addAlertResponder(managerIdentifier: pumpManager.pluginIdentifier, alertResponder: pumpManager) alertManager?.addAlertSoundVendor(managerIdentifier: pumpManager.pluginIdentifier, @@ -767,11 +675,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) } @@ -784,9 +692,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) } @@ -816,13 +724,12 @@ extension DeviceDataManager { // MARK: - Client API extension DeviceDataManager { - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { + func enactBolus(units: Double, activationType: BolusActivationType) async throws { guard let pumpManager = pumpManager else { - completion(LoopError.configurationError(.pumpManager)) - return + throw LoopError.configurationError(.pumpManager) } - self.loopManager.addRequestedBolus(DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true)) { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in pumpManager.enactBolus(units: units, activationType: activationType) { (error) in if let error = error { self.log.error("%{public}@", String(describing: error)) @@ -836,33 +743,14 @@ extension DeviceDataManager { NotificationManager.sendBolusFailureNotification(for: error, units: units, at: Date(), activationType: activationType) } } - - self.loopManager.bolusRequestFailed(error) { - completion(error) - } + continuation.resume(throwing: error) } else { - self.loopManager.bolusConfirmed() { - completion(nil) - } + continuation.resume() } } - // Trigger forecast/recommendation update for remote clients - self.loopManager.updateRemoteRecommendation() } } - func enactBolus(units: Double, activationType: BolusActivationType) async throws { - return try await withCheckedThrowingContinuation { continuation in - enactBolus(units: units, activationType: activationType) { error in - if let error = error { - continuation.resume(throwing: error) - return - } - continuation.resume() - } - } - } - var pumpManagerStatus: PumpManagerStatus? { return pumpManager?.status } @@ -952,6 +840,7 @@ extension DeviceDataManager: PersistedAlertStore { precondition(alertManager != nil) alertManager.doesIssuedAlertExist(identifier: identifier, completion: completion) } + func lookupAllUnretracted(managerIdentifier: String, completion: @escaping (Swift.Result<[PersistedAlert], Error>) -> Void) { precondition(alertManager != nil) alertManager.lookupAllUnretracted(managerIdentifier: managerIdentifier, completion: completion) @@ -970,34 +859,33 @@ extension DeviceDataManager: PersistedAlertStore { // MARK: - CGMManagerDelegate extension DeviceDataManager: CGMManagerDelegate { + nonisolated func cgmManagerWantsDeletion(_ manager: CGMManager) { - dispatchPrecondition(condition: .onQueue(queue)) - - log.default("CGM manager with identifier '%{public}@' wants deletion", manager.pluginIdentifier) - DispatchQueue.main.async { + self.log.default("CGM manager with identifier '%{public}@' wants deletion", manager.pluginIdentifier) if let cgmManagerUI = self.cgmManager as? CGMManagerUI { - self.removeDisplayGlucoseUnitObserver(cgmManagerUI) + self.displayGlucoseUnitBroadcaster?.removeDisplayGlucoseUnitObserver(cgmManagerUI) } self.cgmManager = nil - self.displayGlucoseUnitObservers.cleanupDeallocatedElements() self.settingsManager.storeSettings() } } + nonisolated func cgmManager(_ manager: CGMManager, hasNew readingResult: CGMReadingResult) { - dispatchPrecondition(condition: .onQueue(queue)) - log.default("CGMManager:%{public}@ did update with %{public}@", String(describing: type(of: manager)), String(describing: readingResult)) - processCGMReadingResult(manager, readingResult: readingResult) { + Task { @MainActor in + log.default("CGMManager:%{public}@ did update with %{public}@", String(describing: type(of: manager)), String(describing: readingResult)) + await processCGMReadingResult(manager, readingResult: readingResult) let now = Date() if case .newData = readingResult, now.timeIntervalSince(self.lastCGMLoopTrigger) > .minutes(4.2) { self.log.default("Triggering loop from new CGM data at %{public}@", String(describing: now)) self.lastCGMLoopTrigger = now - self.checkPumpDataAndLoop() + await self.checkPumpDataAndLoop() } } } + nonisolated func cgmManager(_ manager: LoopKit.CGMManager, hasNew events: [PersistedCgmEvent]) { Task { do { @@ -1009,12 +897,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 } @@ -1023,6 +911,7 @@ extension DeviceDataManager: CGMManagerDelegate { return UUID().uuidString } + nonisolated func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) { DispatchQueue.main.async { if self.cgmHasValidSensorSession != status.hasValidSensorSession { @@ -1036,32 +925,37 @@ extension DeviceDataManager: CGMManagerDelegate { extension DeviceDataManager: CGMManagerOnboardingDelegate { func cgmManagerOnboarding(didCreateCGMManager cgmManager: CGMManagerUI) { - log.default("CGM manager with identifier '%{public}@' created", cgmManager.pluginIdentifier) - self.cgmManager = cgmManager + Task { @MainActor in + log.default("CGM manager with identifier '%{public}@' created", cgmManager.pluginIdentifier) + self.cgmManager = cgmManager + } } func cgmManagerOnboarding(didOnboardCGMManager cgmManager: CGMManagerUI) { precondition(cgmManager.isOnboarded) log.default("CGM manager with identifier '%{public}@' onboarded", cgmManager.pluginIdentifier) - DispatchQueue.main.async { - self.refreshDeviceData() - self.settingsManager.storeSettings() + Task { @MainActor in + await refreshDeviceData() + settingsManager.storeSettings() } } } // MARK: - PumpManagerDelegate extension DeviceDataManager: PumpManagerDelegate { + + var detectedSystemTimeOffset: TimeInterval { UserDefaults.standard.detectedSystemTimeOffset ?? 0 } + func pumpManager(_ pumpManager: PumpManager, didAdjustPumpClockBy adjustment: TimeInterval) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did adjust pump clock by %fs", String(describing: type(of: pumpManager)), adjustment) analyticsServicesManager.pumpTimeDidDrift(adjustment) } func pumpManagerDidUpdateState(_ pumpManager: PumpManager) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did update state", String(describing: type(of: pumpManager))) rawPumpManager = pumpManager.rawValue @@ -1073,47 +967,14 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManagerBLEHeartbeatDidFire(_ pumpManager: PumpManager) { - dispatchPrecondition(condition: .onQueue(queue)) - log.default("PumpManager:%{public}@ did fire heartbeat", String(describing: type(of: pumpManager))) - refreshCGM() - } - - private func refreshCGM(_ completion: (() -> Void)? = nil) { - guard let cgmManager = cgmManager else { - completion?() - return - } - - cgmManager.fetchNewDataIfNeeded { (result) in - if case .newData = result { - self.analyticsServicesManager.didFetchNewCGMData() - } - - self.queue.async { - self.processCGMReadingResult(cgmManager, readingResult: result) { - if self.loopManager.lastLoopCompleted == nil || self.loopManager.lastLoopCompleted!.timeIntervalSinceNow < -.minutes(4.2) { - self.log.default("Triggering Loop from refreshCGM()") - self.checkPumpDataAndLoop() - } - completion?() - } - } - } - } - - func refreshDeviceData() { - refreshCGM() { - self.queue.async { - guard let pumpManager = self.pumpManager, pumpManager.isOnboarded else { - return - } - pumpManager.ensureCurrentPumpData(completion: nil) - } + Task { @MainActor in + log.default("PumpManager:%{public}@ did fire heartbeat", String(describing: type(of: pumpManager))) + await refreshCGM() } } func pumpManagerMustProvideBLEHeartbeat(_ pumpManager: PumpManager) -> Bool { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) return pumpManagerMustProvideBLEHeartbeat } @@ -1126,7 +987,7 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did update status: %{public}@", String(describing: type(of: pumpManager)), String(describing: status)) doseStore.device = status.device @@ -1137,19 +998,11 @@ extension DeviceDataManager: PumpManagerDelegate { analyticsServicesManager.pumpBatteryWasReplaced() } - if status.basalDeliveryState != oldStatus.basalDeliveryState { - loopManager.basalDeliveryState = status.basalDeliveryState - } - updatePumpIsAllowingAutomation(status: status) // Update the pump-schedule based settings - loopManager.setScheduleTimeZone(status.timeZone) - - if status.insulinType != oldStatus.insulinType { - loopManager.pumpInsulinType = status.insulinType - } - + settingsManager.setScheduleTimeZone(status.timeZone) + if status.deliveryIsUncertain != oldStatus.deliveryIsUncertain { DispatchQueue.main.async { if status.deliveryIsUncertain { @@ -1173,26 +1026,23 @@ extension DeviceDataManager: PumpManagerDelegate { } func pumpManagerWillDeactivate(_ pumpManager: PumpManager) { - dispatchPrecondition(condition: .onQueue(queue)) - + dispatchPrecondition(condition: .onQueue(.main)) log.default("Pump manager with identifier '%{public}@' will deactivate", pumpManager.pluginIdentifier) - DispatchQueue.main.async { - self.pumpManager = nil - self.deliveryUncertaintyAlertManager = nil - self.settingsManager.storeSettings() - } + self.pumpManager = nil + deliveryUncertaintyAlertManager = nil + settingsManager.storeSettings() } func pumpManager(_ pumpManager: PumpManager, didUpdatePumpRecordsBasalProfileStartEvents pumpRecordsBasalProfileStartEvents: Bool) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did update pumpRecordsBasalProfileStartEvents to %{public}@", String(describing: type(of: pumpManager)), String(describing: pumpRecordsBasalProfileStartEvents)) doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents } func pumpManager(_ pumpManager: PumpManager, didError error: PumpManagerError) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.error("PumpManager:%{public}@ did error: %{public}@", String(describing: type(of: pumpManager)), String(describing: error)) setLastError(error: error) @@ -1205,7 +1055,7 @@ extension DeviceDataManager: PumpManagerDelegate { replacePendingEvents: Bool, completion: @escaping (_ error: Error?) -> Void) { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ hasNewPumpEvents (lastReconciliation = %{public}@)", String(describing: type(of: pumpManager)), String(describing: lastReconciliation)) doseStore.addPumpEvents(events, lastReconciliation: lastReconciliation, replacePendingEvents: replacePendingEvents) { (error) in @@ -1221,23 +1071,57 @@ extension DeviceDataManager: PumpManagerDelegate { } } - func pumpManager(_ pumpManager: PumpManager, didReadReservoirValue units: Double, at date: Date, completion: @escaping (_ result: Swift.Result<(newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool), Error>) -> Void) { - dispatchPrecondition(condition: .onQueue(queue)) - log.default("PumpManager:%{public}@ did read reservoir value", String(describing: type(of: pumpManager))) + func pumpManager( + _ pumpManager: PumpManager, + didReadReservoirValue units: Double, + at date: Date, + completion: @escaping (_ result: Swift.Result<(newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool), Error>) -> Void + ) { + Task { @MainActor in + dispatchPrecondition(condition: .onQueue(.main)) + log.default("PumpManager:%{public}@ did read reservoir value", String(describing: type(of: pumpManager))) - loopManager.addReservoirValue(units, at: date) { (result) in - switch result { - case .failure(let error): + do { + let (newValue, lastValue, areStoredValuesContinuous) = try await 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))) } } } + /// 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) async throws -> (newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool) { + try await withCheckedThrowingContinuation { continuation in + doseStore.addReservoirValue(units, at: date) { (newValue, previousValue, areStoredValuesContinuous, error) in + if let error = error { + continuation.resume(throwing: error) + } else if let newValue = newValue { + continuation.resume(returning: ( + newValue: newValue, + lastValue: previousValue, + areStoredValuesContinuous: areStoredValuesContinuous + )) + } else { + assertionFailure() + } + } + } + } + + func startDateToFilterNewPumpEvents(for manager: PumpManager) -> Date { - dispatchPrecondition(condition: .onQueue(queue)) + dispatchPrecondition(condition: .onQueue(.main)) return doseStore.pumpEventQueryAfterDate } @@ -1255,12 +1139,12 @@ extension DeviceDataManager: PumpManagerOnboardingDelegate { } func pumpManagerOnboarding(didOnboardPumpManager pumpManager: PumpManagerUI) { - precondition(pumpManager.isOnboarded) - log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.pluginIdentifier) + Task { @MainActor in + precondition(pumpManager.isOnboarded) + log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.pluginIdentifier) - DispatchQueue.main.async { - self.refreshDeviceData() - self.settingsManager.storeSettings() + await refreshDeviceData() + settingsManager.storeSettings() } } @@ -1272,14 +1156,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) {} @@ -1288,35 +1172,35 @@ extension DeviceDataManager: CarbStoreDelegate { // MARK: - DoseStoreDelegate extension DeviceDataManager: DoseStoreDelegate { 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) } } @@ -1375,55 +1259,10 @@ extension DeviceDataManager { } } -// MARK: - LoopDataManagerDelegate -extension DeviceDataManager: LoopDataManagerDelegate { - func roundBasalRate(unitsPerHour: Double) -> Double { - guard let pumpManager = pumpManager else { - return unitsPerHour - } - - return pumpManager.roundToSupportedBasalRate(unitsPerHour: unitsPerHour) +extension DeviceDataManager: BolusDurationEstimator { + func estimateBolusDuration(bolusUnits: Double) -> TimeInterval? { + pumpManager?.estimatedDuration(toBolus: bolusUnits) } - - func roundBolusVolume(units: Double) -> Double { - guard let pumpManager = pumpManager else { - return units - } - - let rounded = pumpManager.roundToSupportedBolusVolume(units: units) - self.log.default("Rounded %{public}@ to %{public}@", String(describing: units), String(describing: rounded)) - - return rounded - } - - func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration units: Double) -> TimeInterval? { - pumpManager?.estimatedDuration(toBolus: units) - } - - func loopDataManager( - _ manager: LoopDataManager, - didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), - completion: @escaping (LoopError?) -> Void - ) { - guard let pumpManager = pumpManager else { - completion(LoopError.configurationError(.pumpManager)) - return - } - - guard !pumpManager.status.deliveryIsUncertain else { - completion(LoopError.connectionError) - return - } - - log.default("LoopManager did recommend dose: %{public}@", String(describing: automaticDose.recommendation)) - - crashRecoveryManager.dosingStarted(dose: automaticDose.recommendation) - doseEnactor.enact(recommendation: automaticDose.recommendation, with: pumpManager) { pumpManagerError in - completion(pumpManagerError.map { .pumpManagerError($0) }) - self.crashRecoveryManager.dosingFinished() - } - } - } extension Notification.Name { @@ -1432,151 +1271,6 @@ 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 { } @@ -1621,22 +1315,25 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { pumpManager?.syncBasalRateSchedule(items: items, completion: completion) } - func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Swift.Result) -> Void) { - // 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)) + func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits + { + do { + // FIRST we need to check to make sure if we have to cancel temp basal first + 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 + await self.loopControl.cancelActiveTempBasal(for: .maximumBasalRateChanged) } + return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits + } catch { + throw CancelTempBasalFailedError(reason: error) } } - - 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 @@ -1660,90 +1357,83 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { } } -extension DeviceDataManager { - func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { - let queue = DispatchQueue.main - displayGlucoseUnitObservers.insert(observer, queue: queue) - queue.async { - observer.unitDidChange(to: self.displayGlucosePreference.unit) - } +extension DeviceDataManager: DeviceSupportDelegate { + var availableSupports: [SupportUI] { [cgmManager, pumpManager].compactMap { $0 as? SupportUI } } + + func generateDiagnosticReport() async -> String { + let report = [ + "", + "## DeviceDataManager", + "* launchDate: \(self.launchDate)", + "* lastError: \(String(describing: self.lastError))", + "", + "cacheStore: \(String(reflecting: self.cacheStore))", + "", + self.cgmManager != nil ? String(reflecting: self.cgmManager!) : "cgmManager: nil", + "", + self.pumpManager != nil ? String(reflecting: self.pumpManager!) : "pumpManager: nil", + "", + await deviceLog.generateDiagnosticReport() + ] + return report.joined(separator: "\n") } +} - func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { - displayGlucoseUnitObservers.removeElement(observer) +extension DeviceDataManager: DeliveryDelegate { + var isPumpConfigured: Bool { + return pumpManager != nil } - func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { - self.displayGlucoseUnitObservers.forEach { - $0.unitDidChange(to: displayGlucoseUnit) + func roundBasalRate(unitsPerHour: Double) -> Double { + guard let pumpManager = pumpManager else { + return unitsPerHour } - } -} -extension DeviceDataManager: DeviceSupportDelegate { - var availableSupports: [SupportUI] { [cgmManager, pumpManager].compactMap { $0 as? SupportUI } } + return pumpManager.roundToSupportedBasalRate(unitsPerHour: unitsPerHour) + } - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { - self.loopManager.generateDiagnosticReport { (loopReport) in + func roundBolusVolume(units: Double) -> Double { + guard let pumpManager = pumpManager else { + return units + } - let logDurationHours = 84.0 + return pumpManager.roundToSupportedBolusVolume(units: units) + } - self.alertManager.getStoredEntries(startDate: Date() - .hours(logDurationHours)) { (alertReport) in - self.deviceLog.getLogEntries(startDate: Date() - .hours(logDurationHours)) { (result) in - let deviceLogReport: String - switch result { - case .failure(let error): - deviceLogReport = "Error fetching entries: \(error)" - case .success(let entries): - deviceLogReport = entries.map { "* \($0.timestamp) \($0.managerIdentifier) \($0.deviceIdentifier ?? "") \($0.type) \($0.message)" }.joined(separator: "\n") - } + var pumpInsulinType: LoopKit.InsulinType? { + return pumpManager?.status.insulinType + } + + var isSuspended: Bool { + return pumpManager?.status.basalDeliveryState?.isSuspended ?? false + } + + func enact(_ recommendation: LoopKit.AutomaticDoseRecommendation) async throws { + guard let pumpManager = pumpManager else { + throw LoopError.configurationError(.pumpManager) + } - let report = [ - "## Build Details", - "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", - "* profileExpiration: \(BuildDetails.default.profileExpirationString)", - "* gitRevision: \(BuildDetails.default.gitRevision ?? "N/A")", - "* gitBranch: \(BuildDetails.default.gitBranch ?? "N/A")", - "* workspaceGitRevision: \(BuildDetails.default.workspaceGitRevision ?? "N/A")", - "* workspaceGitBranch: \(BuildDetails.default.workspaceGitBranch ?? "N/A")", - "* sourceRoot: \(BuildDetails.default.sourceRoot ?? "N/A")", - "* buildDateString: \(BuildDetails.default.buildDateString ?? "N/A")", - "* xcodeVersion: \(BuildDetails.default.xcodeVersion ?? "N/A")", - "", - "## FeatureFlags", - "\(FeatureFlags)", - "", - alertReport, - "", - "## DeviceDataManager", - "* launchDate: \(self.launchDate)", - "* lastError: \(String(describing: self.lastError))", - "", - "cacheStore: \(String(reflecting: self.cacheStore))", - "", - self.cgmManager != nil ? String(reflecting: self.cgmManager!) : "cgmManager: nil", - "", - self.pumpManager != nil ? String(reflecting: self.pumpManager!) : "pumpManager: nil", - "", - "## Device Communication Log", - deviceLogReport, - "", - String(reflecting: self.watchManager!), - "", - String(reflecting: self.statusExtensionManager!), - "", - loopReport, - ].joined(separator: "\n") - - completion(report) - } - } + guard !pumpManager.status.deliveryIsUncertain else { + throw LoopError.connectionError } + + log.default("Enacting dose: %{public}@", String(describing: recommendation)) + + crashRecoveryManager.dosingStarted(dose: recommendation) + defer { self.crashRecoveryManager.dosingFinished() } + + try await doseEnactor.enact(recommendation: recommendation, with: pumpManager) + } + + var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { + return pumpManager?.status.basalDeliveryState } } extension DeviceDataManager: DeviceStatusProvider {} -extension DeviceDataManager { - var detectedSystemTimeOffset: TimeInterval { trustedTimeChecker.detectedSystemTimeOffset } +extension DeviceDataManager: BolusStateProvider { + var bolusState: LoopKit.PumpManagerStatus.BolusState? { + return pumpManager?.status.bolusState + } } diff --git a/Loop/Managers/DoseEnactor.swift b/Loop/Managers/DoseEnactor.swift index 55c782c96c..fc533d6219 100644 --- a/Loop/Managers/DoseEnactor.swift +++ b/Loop/Managers/DoseEnactor.swift @@ -1,4 +1,4 @@ - // +// // DoseEnactor.swift // Loop // @@ -15,47 +15,17 @@ class DoseEnactor { private let log = DiagnosticLog(category: "DoseEnactor") - func enact(recommendation: AutomaticDoseRecommendation, with pumpManager: PumpManager, completion: @escaping (PumpManagerError?) -> Void) { - - dosingQueue.async { - let doseDispatchGroup = DispatchGroup() - - var tempBasalError: PumpManagerError? = nil - var bolusError: PumpManagerError? = nil - - if let basalAdjustment = recommendation.basalAdjustment { - self.log.default("Enacting recommend basal change") + func enact(recommendation: AutomaticDoseRecommendation, with pumpManager: PumpManager) async throws { - doseDispatchGroup.enter() - pumpManager.enactTempBasal(unitsPerHour: basalAdjustment.unitsPerHour, for: basalAdjustment.duration, completion: { error in - if let error = error { - tempBasalError = error - } - doseDispatchGroup.leave() - }) - } - - doseDispatchGroup.wait() + if let basalAdjustment = recommendation.basalAdjustment { + self.log.default("Enacting recommended basal change") + try await pumpManager.enactTempBasal(unitsPerHour: basalAdjustment.unitsPerHour, for: basalAdjustment.duration) + } - guard tempBasalError == nil else { - completion(tempBasalError) - return - } - - if let bolusUnits = recommendation.bolusUnits, bolusUnits > 0 { - self.log.default("Enacting recommended bolus dose") - doseDispatchGroup.enter() - pumpManager.enactBolus(units: bolusUnits, activationType: .automatic) { (error) in - if let error = error { - bolusError = error - } else { - self.log.default("PumpManager successfully issued bolus command") - } - doseDispatchGroup.leave() - } - } - doseDispatchGroup.wait() - completion(bolusError) + if let bolusUnits = recommendation.bolusUnits, bolusUnits > 0 { + self.log.default("Enacting recommended bolus dose") + try await pumpManager.enactBolus(units: bolusUnits, activationType: .automatic) } } } + diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift index 9261dcfc43..09d7170237 100644 --- a/Loop/Managers/ExtensionDataManager.swift +++ b/Loop/Managers/ExtensionDataManager.swift @@ -10,18 +10,27 @@ import HealthKit import UIKit import LoopKit - +@MainActor final class ExtensionDataManager { unowned let deviceManager: DeviceDataManager + unowned let loopDataManager: LoopDataManager + unowned let settingsManager: SettingsManager + unowned let temporaryPresetsManager: TemporaryPresetsManager private let automaticDosingStatus: AutomaticDosingStatus init(deviceDataManager: DeviceDataManager, - automaticDosingStatus: AutomaticDosingStatus) - { + loopDataManager: LoopDataManager, + automaticDosingStatus: AutomaticDosingStatus, + settingsManager: SettingsManager, + temporaryPresetsManager: TemporaryPresetsManager + ) { self.deviceManager = deviceDataManager + self.loopDataManager = loopDataManager + self.settingsManager = settingsManager + self.temporaryPresetsManager = temporaryPresetsManager self.automaticDosingStatus = automaticDosingStatus - NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .LoopDataUpdated, object: deviceDataManager.loopManager) + NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .LoopDataUpdated, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .PumpManagerChanged, object: nil) // Wait until LoopDataManager has had a chance to initialize itself @@ -61,114 +70,112 @@ final class ExtensionDataManager { } private func update() { - createStatusContext(glucoseUnit: deviceManager.preferredGlucoseUnit) { (context) in - if let context = context { + Task { @MainActor in + if let context = await createStatusContext(glucoseUnit: deviceManager.displayGlucosePreference.unit) { ExtensionDataManager.context = context } - } - - createIntentsContext { (info) in - if let info = info, ExtensionDataManager.intentExtensionInfo?.overridePresetNames != info.overridePresetNames { + + if let info = createIntentsContext(), ExtensionDataManager.intentExtensionInfo?.overridePresetNames != info.overridePresetNames { ExtensionDataManager.intentExtensionInfo = info } } } - private func createIntentsContext(_ completion: @escaping (_ context: IntentExtensionInfo?) -> Void) { - let presets = deviceManager.loopManager.settings.overridePresets + private func createIntentsContext() -> IntentExtensionInfo? { + let presets = settingsManager.settings.overridePresets let info = IntentExtensionInfo(overridePresetNames: presets.map { $0.name }) - completion(info) + return info } - private func createStatusContext(glucoseUnit: HKUnit, _ completionHandler: @escaping (_ context: StatusExtensionContext?) -> Void) { + private func createStatusContext(glucoseUnit: HKUnit) async -> StatusExtensionContext? { let basalDeliveryState = deviceManager.pumpManager?.status.basalDeliveryState - deviceManager.loopManager.getLoopState { (manager, state) in - let dataManager = self.deviceManager - var context = StatusExtensionContext() - - context.createdAt = Date() - - #if IOS_SIMULATOR - // If we're in the simulator, there's a higher likelihood that we don't have - // a fully configured app. Inject some baseline debug data to let us test the - // experience. This data will be overwritten by actual data below, if available. - context.batteryPercentage = 0.25 - context.netBasal = NetBasalContext( - rate: 2.1, - percentage: 0.6, - start: - Date(timeIntervalSinceNow: -250), - end: Date(timeIntervalSinceNow: .minutes(30)) - ) - context.predictedGlucose = PredictedGlucoseContext( - values: (1...36).map { 89.123 + Double($0 * 5) }, // 3 hours of linear data - unit: HKUnit.milligramsPerDeciliter, - startDate: Date(), - interval: TimeInterval(minutes: 5)) - - let lastLoopCompleted = Date(timeIntervalSinceNow: -TimeInterval(minutes: 0)) - #else - let lastLoopCompleted = manager.lastLoopCompleted - #endif - - context.lastLoopCompleted = lastLoopCompleted - - context.isClosedLoop = self.automaticDosingStatus.automaticDosingEnabled - - context.preMealPresetAllowed = self.automaticDosingStatus.automaticDosingEnabled && manager.settings.preMealTargetRange != nil - context.preMealPresetActive = manager.settings.preMealTargetEnabled() - context.customPresetActive = manager.settings.nonPreMealOverrideEnabled() - - // Drop the first element in predictedGlucose because it is the currentGlucose - // and will have a different interval to the next element - if let predictedGlucose = state.predictedGlucoseIncludingPendingInsulin?.dropFirst(), - predictedGlucose.count > 1 { - let first = predictedGlucose[predictedGlucose.startIndex] - let second = predictedGlucose[predictedGlucose.startIndex.advanced(by: 1)] - context.predictedGlucose = PredictedGlucoseContext( - values: predictedGlucose.map { $0.quantity.doubleValue(for: glucoseUnit) }, - unit: glucoseUnit, - startDate: first.startDate, - interval: second.startDate.timeIntervalSince(first.startDate)) - } - - if let basalDeliveryState = basalDeliveryState, - let basalSchedule = manager.basalRateScheduleApplyingOverrideHistory, - let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, settings: manager.settings) - { - context.netBasal = NetBasalContext(rate: netBasal.rate, percentage: netBasal.percent, start: netBasal.start, end: netBasal.end) - } + let state = loopDataManager.algorithmState + + let dataManager = self.deviceManager + var context = StatusExtensionContext() + + context.createdAt = Date() + + #if IOS_SIMULATOR + // If we're in the simulator, there's a higher likelihood that we don't have + // a fully configured app. Inject some baseline debug data to let us test the + // experience. This data will be overwritten by actual data below, if available. + context.batteryPercentage = 0.25 + context.netBasal = NetBasalContext( + rate: 2.1, + percentage: 0.6, + start: + Date(timeIntervalSinceNow: -250), + end: Date(timeIntervalSinceNow: .minutes(30)) + ) + context.predictedGlucose = PredictedGlucoseContext( + values: (1...36).map { 89.123 + Double($0 * 5) }, // 3 hours of linear data + unit: HKUnit.milligramsPerDeciliter, + startDate: Date(), + interval: TimeInterval(minutes: 5)) + + let lastLoopCompleted = Date(timeIntervalSinceNow: -TimeInterval(minutes: 0)) + #else + let lastLoopCompleted = loopDataManager.lastLoopCompleted + #endif + + context.lastLoopCompleted = lastLoopCompleted + + context.isClosedLoop = self.automaticDosingStatus.automaticDosingEnabled + + context.preMealPresetAllowed = self.automaticDosingStatus.automaticDosingEnabled && self.settingsManager.settings.preMealTargetRange != nil + context.preMealPresetActive = self.temporaryPresetsManager.preMealTargetEnabled() + context.customPresetActive = self.temporaryPresetsManager.nonPreMealOverrideEnabled() + + // Drop the first element in predictedGlucose because it is the currentGlucose + // and will have a different interval to the next element + if let predictedGlucose = state.output?.predictedGlucose.dropFirst(), + predictedGlucose.count > 1 { + let first = predictedGlucose[predictedGlucose.startIndex] + let second = predictedGlucose[predictedGlucose.startIndex.advanced(by: 1)] + context.predictedGlucose = PredictedGlucoseContext( + values: predictedGlucose.map { $0.quantity.doubleValue(for: glucoseUnit) }, + unit: glucoseUnit, + startDate: first.startDate, + interval: second.startDate.timeIntervalSince(first.startDate)) + } - context.batteryPercentage = dataManager.pumpManager?.status.pumpBatteryChargeRemaining - context.reservoirCapacity = dataManager.pumpManager?.pumpReservoirCapacity - - if let glucoseDisplay = dataManager.glucoseDisplay(for: dataManager.glucoseStore.latestGlucose) { - context.glucoseDisplay = GlucoseDisplayableContext( - isStateValid: glucoseDisplay.isStateValid, - stateDescription: glucoseDisplay.stateDescription, - trendType: glucoseDisplay.trendType, - trendRate: glucoseDisplay.trendRate, - isLocal: glucoseDisplay.isLocal, - glucoseRangeCategory: glucoseDisplay.glucoseRangeCategory - ) - } - - if let pumpManagerHUDProvider = dataManager.pumpManagerHUDProvider { - context.pumpManagerHUDViewContext = PumpManagerHUDViewContext(pumpManagerHUDViewRawValue: PumpManagerHUDViewRawValueFromHUDProvider(pumpManagerHUDProvider)) - } - - context.pumpStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.pumpStatusHighlight) - context.pumpLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.pumpLifecycleProgress) + if let basalDeliveryState = basalDeliveryState, + let basalSchedule = self.temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory, + let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, maximumBasalRatePerHour: self.settingsManager.settings.maximumBasalRatePerHour) + { + context.netBasal = NetBasalContext(rate: netBasal.rate, percentage: netBasal.percent, start: netBasal.start, end: netBasal.end) + } - context.cgmStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.cgmStatusHighlight) - context.cgmLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.cgmLifecycleProgress) + context.batteryPercentage = dataManager.pumpManager?.status.pumpBatteryChargeRemaining + context.reservoirCapacity = dataManager.pumpManager?.pumpReservoirCapacity + + if let glucoseDisplay = dataManager.glucoseDisplay(for: loopDataManager.latestGlucose) { + context.glucoseDisplay = GlucoseDisplayableContext( + isStateValid: glucoseDisplay.isStateValid, + stateDescription: glucoseDisplay.stateDescription, + trendType: glucoseDisplay.trendType, + trendRate: glucoseDisplay.trendRate, + isLocal: glucoseDisplay.isLocal, + glucoseRangeCategory: glucoseDisplay.glucoseRangeCategory + ) + } - context.carbsOnBoard = state.carbsOnBoard?.quantity.doubleValue(for: .gram()) - - completionHandler(context) + if let pumpManagerHUDProvider = dataManager.pumpManagerHUDProvider { + context.pumpManagerHUDViewContext = PumpManagerHUDViewContext(pumpManagerHUDViewRawValue: PumpManagerHUDViewRawValueFromHUDProvider(pumpManagerHUDProvider)) } + + context.pumpStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.pumpStatusHighlight) + context.pumpLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.pumpLifecycleProgress) + + context.cgmStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.cgmStatusHighlight) + context.cgmLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.cgmLifecycleProgress) + + context.carbsOnBoard = state.activeCarbs?.value + + return context } } diff --git a/Loop/Managers/LocalTestingScenariosManager.swift b/Loop/Managers/LocalTestingScenariosManager.swift deleted file mode 100644 index bd1e7e087a..0000000000 --- a/Loop/Managers/LocalTestingScenariosManager.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// LocalTestingScenariosManager.swift -// Loop -// -// Created by Michael Pangburn on 4/22/19. -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import Foundation -import LoopKit -import LoopTestingKit -import OSLog - -final class LocalTestingScenariosManager: TestingScenariosManagerRequirements, DirectoryObserver { - - unowned let deviceManager: DeviceDataManager - unowned let supportManager: SupportManager - - let log = DiagnosticLog(category: "LocalTestingScenariosManager") - - private let fileManager = FileManager.default - private let scenariosSource: URL - private var directoryObservationToken: DirectoryObservationToken? - - private(set) var scenarioURLs: [URL] = [] - var activeScenarioURL: URL? - var activeScenario: TestingScenario? - - weak var delegate: TestingScenariosManagerDelegate? { - didSet { - delegate?.testingScenariosManager(self, didUpdateScenarioURLs: scenarioURLs) - } - } - - var pluginManager: PluginManager { - deviceManager.pluginManager - } - - init(deviceManager: DeviceDataManager, supportManager: SupportManager) { - guard FeatureFlags.scenariosEnabled else { - fatalError("\(#function) should be invoked only when scenarios are enabled") - } - - self.deviceManager = deviceManager - self.supportManager = supportManager - self.scenariosSource = Bundle.main.bundleURL.appendingPathComponent("Scenarios") - - log.debug("Loading testing scenarios from %{public}@", scenariosSource.path) - if !fileManager.fileExists(atPath: scenariosSource.path) { - do { - try fileManager.createDirectory(at: scenariosSource, withIntermediateDirectories: false) - } catch { - log.error("%{public}@", String(describing: error)) - } - } - - directoryObservationToken = observeDirectory(at: scenariosSource) { [weak self] in - self?.reloadScenarioURLs() - } - reloadScenarioURLs() - } - - func fetchScenario(from url: URL, completion: (Result) -> Void) { - let result = Result(catching: { try TestingScenario(source: url) }) - completion(result) - } - - private func reloadScenarioURLs() { - do { - let scenarioURLs = try fileManager.contentsOfDirectory(at: scenariosSource, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "json" } - self.scenarioURLs = scenarioURLs - delegate?.testingScenariosManager(self, didUpdateScenarioURLs: scenarioURLs) - log.debug("Reloaded scenario URLs") - } catch { - log.error("%{public}@", String(describing: error)) - } - } -} diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index b8e23d0bba..2b026a384a 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -14,6 +14,8 @@ import LoopKitUI import MockKit import HealthKit import WidgetKit +import LoopCore + #if targetEnvironment(simulator) enum SimulatorError: Error { @@ -55,6 +57,7 @@ protocol WindowProvider: AnyObject { var window: UIWindow? { get } } +@MainActor class LoopAppManager: NSObject { private enum State: Int { case initialize @@ -74,6 +77,11 @@ class LoopAppManager: NSObject { private var bluetoothStateManager: BluetoothStateManager! private var alertManager: AlertManager! private var trustedTimeChecker: TrustedTimeChecker! + private var healthStore: HKHealthStore! + private var carbStore: CarbStore! + private var doseStore: DoseStore! + private var glucoseStore: GlucoseStore! + private var dosingDecisionStore: DosingDecisionStore! private var deviceDataManager: DeviceDataManager! private var onboardingManager: OnboardingManager! private var alertPermissionsChecker: AlertPermissionsChecker! @@ -84,8 +92,22 @@ class LoopAppManager: NSObject { private(set) var testingScenariosManager: TestingScenariosManager? private var resetLoopManager: ResetLoopManager! private var deeplinkManager: DeeplinkManager! - - private var overrideHistory = UserDefaults.appGroup?.overrideHistory ?? TemporaryScheduleOverrideHistory.init() + private var temporaryPresetsManager: TemporaryPresetsManager! + private var loopDataManager: LoopDataManager! + private var mealDetectionManager: MealDetectionManager! + private var statusExtensionManager: ExtensionDataManager! + private var watchManager: WatchDataManager! + private var crashRecoveryManager: CrashRecoveryManager! + private var cgmEventStore: CgmEventStore! + private var servicesManager: ServicesManager! + private var remoteDataServicesManager: RemoteDataServicesManager! + private var statefulPluginManager: StatefulPluginManager! + private var criticalEventLogExportManager: CriticalEventLogExportManager! + + // HealthStorePreferredGlucoseUnitDidChange will be notified once the user completes the health access form. Set to .milligramsPerDeciliter until then + public private(set) var displayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + + private var displayGlucoseUnitObservers = WeakSynchronizedSet() private var state: State = .initialize @@ -107,43 +129,33 @@ class LoopAppManager: NSObject { INPreferences.requestSiriAuthorization { _ in } } - registerBackgroundTasks() - - if FeatureFlags.remoteCommandsEnabled { - DispatchQueue.main.async { -#if targetEnvironment(simulator) - self.remoteNotificationRegistrationDidFinish(.failure(SimulatorError.remoteNotificationsNotAvailable)) -#else - UIApplication.shared.registerForRemoteNotifications() -#endif - } - } self.state = state.next } func launch() { - dispatchPrecondition(condition: .onQueue(.main)) precondition(isLaunchPending) - resumeLaunch() + Task { + await resumeLaunch() + } } var isLaunchPending: Bool { state == .checkProtectedDataAvailable } var isLaunchComplete: Bool { state == .launchComplete } - private func resumeLaunch() { + private func resumeLaunch() async { if state == .checkProtectedDataAvailable { checkProtectedDataAvailable() } if state == .launchManagers { - launchManagers() + await launchManagers() } if state == .launchOnboarding { launchOnboarding() } if state == .launchHomeScreen { - launchHomeScreen() + await launchHomeScreen() } askUserToConfirmLoopReset() @@ -161,7 +173,7 @@ class LoopAppManager: NSObject { self.state = state.next } - private func launchManagers() { + private func launchManagers() async { dispatchPrecondition(condition: .onQueue(.main)) precondition(state == .launchManagers) @@ -187,48 +199,247 @@ class LoopAppManager: NSObject { alertPermissionsChecker = AlertPermissionsChecker() alertPermissionsChecker.delegate = alertManager - trustedTimeChecker = TrustedTimeChecker(alertManager: alertManager) + trustedTimeChecker = LoopTrustedTimeChecker(alertManager: alertManager) + + settingsManager = SettingsManager( + cacheStore: cacheStore, + expireAfter: localCacheDuration, + alertMuter: alertManager.alertMuter, + analyticsServicesManager: analyticsServicesManager + ) + + // Once settings manager is initialized, we can register for remote notifications + if FeatureFlags.remoteCommandsEnabled { + DispatchQueue.main.async { +#if targetEnvironment(simulator) + self.remoteNotificationRegistrationDidFinish(.failure(SimulatorError.remoteNotificationsNotAvailable)) +#else + UIApplication.shared.registerForRemoteNotifications() +#endif + } + } + + 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) + ) + + let absorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes + + temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsManager) + temporaryPresetsManager.overrideHistory.delegate = self + + temporaryPresetsManager.addTemporaryPresetObserver(alertManager) + temporaryPresetsManager.addTemporaryPresetObserver(analyticsServicesManager) + + self.carbStore = CarbStore( + healthKitSampleStore: carbHealthStore, + cacheStore: cacheStore, + cacheLength: localCacheDuration, + defaultAbsorptionTimes: absorptionTimes, + carbAbsorptionModel: FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + ) + + let insulinHealthStore = HealthKitSampleStore( + healthStore: healthStore, + observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitDoseSamplesFromOtherApps, + type: HealthKitSampleStore.insulinQuantityType, + observationStart: Date().addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) + ) + + let insulinModelProvider: InsulinModelProvider + + if FeatureFlags.adultChildInsulinModelSelectionEnabled { + insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: settingsManager.settings.defaultRapidActingModel?.presetForRapidActingInsulin) + } else { + insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) + } + + self.doseStore = DoseStore( + healthKitSampleStore: insulinHealthStore, + cacheStore: cacheStore, + cacheLength: localCacheDuration, + insulinModelProvider: insulinModelProvider, + longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, + basalProfile: settingsManager.settings.basalRateSchedule, + lastPumpEventsReconciliation: nil // PumpManager is nil at this point. Will update this via addPumpEvents below + ) - settingsManager = SettingsManager(cacheStore: cacheStore, - expireAfter: localCacheDuration, - alertMuter: alertManager.alertMuter) + 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 + ) + + dosingDecisionStore = DosingDecisionStore(store: cacheStore, expireAfter: localCacheDuration) + + + NotificationCenter.default.addObserver(forName: .HealthStorePreferredGlucoseUnitDidChange, object: healthStore, queue: nil) { [weak self] _ in + guard let self else { + return + } + + Task { @MainActor in + if let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) { + self.displayGlucosePreference.unitDidChange(to: unit) + self.notifyObserversOfDisplayGlucoseUnitChange(to: unit) + } + } + } + + let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + + loopDataManager = LoopDataManager( + lastLoopCompleted: ExtensionDataManager.context?.lastLoopCompleted, + temporaryPresetsManager: temporaryPresetsManager, + settingsProvider: settingsManager, + doseStore: doseStore, + glucoseStore: glucoseStore, + carbStore: carbStore, + dosingDecisionStore: dosingDecisionStore, + automaticDosingStatus: automaticDosingStatus, + trustedTimeOffset: { self.trustedTimeChecker.detectedSystemTimeOffset }, + analyticsServicesManager: analyticsServicesManager, + carbAbsorptionModel: carbModel + ) + + cacheStore.delegate = loopDataManager + + crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) + + Task { @MainActor in + alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) + } + + cgmEventStore = CgmEventStore(cacheStore: cacheStore, cacheLength: localCacheDuration) + + + remoteDataServicesManager = RemoteDataServicesManager( + alertStore: alertManager.alertStore, + carbStore: carbStore, + doseStore: doseStore, + dosingDecisionStore: dosingDecisionStore, + glucoseStore: glucoseStore, + cgmEventStore: cgmEventStore, + settingsStore: settingsManager.settingsStore, + overrideHistory: temporaryPresetsManager.overrideHistory, + insulinDeliveryStore: doseStore.insulinDeliveryStore + ) + + settingsManager.remoteDataServicesManager = remoteDataServicesManager + + 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, alertManager: alertManager, settingsManager: settingsManager, - loggingServicesManager: loggingServicesManager, + healthStore: healthStore, + carbStore: carbStore, + doseStore: doseStore, + glucoseStore: glucoseStore, + cgmEventStore: cgmEventStore, + uploadEventListener: remoteDataServicesManager, + crashRecoveryManager: crashRecoveryManager, + loopControl: loopDataManager, analyticsServicesManager: analyticsServicesManager, + activeServicesProvider: servicesManager, + activeStatefulPluginsProvider: statefulPluginManager, bluetoothProvider: bluetoothStateManager, alertPresenter: self, automaticDosingStatus: automaticDosingStatus, cacheStore: cacheStore, localCacheDuration: localCacheDuration, - overrideHistory: overrideHistory, - trustedTimeChecker: trustedTimeChecker + displayGlucosePreference: displayGlucosePreference, + displayGlucoseUnitBroadcaster: self ) - settingsManager.deviceStatusProvider = deviceDataManager - settingsManager.displayGlucosePreference = deviceDataManager.displayGlucosePreference + + dosingDecisionStore.delegate = deviceDataManager + remoteDataServicesManager.delegate = deviceDataManager - overrideHistory.delegate = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, + directory: FileManager.default.exportsDirectoryURL, + historicalDuration: localCacheDuration) + + criticalEventLogExportManager.registerBackgroundTasks() + + + statusExtensionManager = ExtensionDataManager( + deviceDataManager: deviceDataManager, + loopDataManager: loopDataManager, + automaticDosingStatus: automaticDosingStatus, + settingsManager: settingsManager, + temporaryPresetsManager: temporaryPresetsManager + ) + + watchManager = WatchDataManager( + deviceManager: deviceDataManager, + settingsManager: settingsManager, + loopDataManager: loopDataManager, + carbStore: carbStore, + glucoseStore: glucoseStore, + analyticsServicesManager: analyticsServicesManager, + temporaryPresetsManager: temporaryPresetsManager, + healthStore: healthStore + ) + + self.mealDetectionManager = MealDetectionManager( + algorithmStateProvider: loopDataManager, + settingsProvider: temporaryPresetsManager, + bolusStateProvider: deviceDataManager + ) + + loopDataManager.deliveryDelegate = deviceDataManager + + deviceDataManager.instantiateDeviceManagers() + + settingsManager.deviceStatusProvider = deviceDataManager + settingsManager.displayGlucosePreference = displayGlucosePreference SharedLogging.instance = loggingServicesManager - scheduleBackgroundTasks() + criticalEventLogExportManager.scheduleCriticalEventLogHistoricalExportBackgroundTask() + supportManager = SupportManager(pluginManager: pluginManager, deviceSupportDelegate: deviceDataManager, - servicesManager: deviceDataManager.servicesManager, + servicesManager: servicesManager, alertIssuer: alertManager) setWhitelistedDevices() onboardingManager = OnboardingManager(pluginManager: pluginManager, bluetoothProvider: bluetoothStateManager, - deviceDataManager: deviceDataManager, - statefulPluginManager: deviceDataManager.statefulPluginManager, - servicesManager: deviceDataManager.servicesManager, - loopDataManager: deviceDataManager.loopManager, + deviceDataManager: deviceDataManager, + settingsManager: settingsManager, + statefulPluginManager: statefulPluginManager, + servicesManager: servicesManager, + loopDataManager: loopDataManager, supportManager: supportManager, windowProvider: windowProvider, userDefaults: UserDefaults.appGroup!) @@ -252,23 +463,46 @@ class LoopAppManager: NSObject { } analyticsServicesManager.identify("Dosing Strategy", value: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue) - let serviceNames = deviceDataManager.servicesManager.activeServices.map { $0.pluginIdentifier } + let serviceNames = servicesManager.activeServices.map { $0.pluginIdentifier } analyticsServicesManager.identify("Services", array: serviceNames) if FeatureFlags.scenariosEnabled { - testingScenariosManager = LocalTestingScenariosManager(deviceManager: deviceDataManager, supportManager: supportManager) + testingScenariosManager = TestingScenariosManager( + deviceManager: deviceDataManager, + supportManager: supportManager, + pluginManager: pluginManager, + carbStore: carbStore, + settingsManager: settingsManager + ) } analyticsServicesManager.application(didFinishLaunchingWithOptions: launchOptions) - automaticDosingStatus.$isAutomaticDosingAllowed - .combineLatest(deviceDataManager.loopManager.$dosingEnabled) + .combineLatest(settingsManager.$dosingEnabled) .map { $0 && $1 } .assign(to: \.automaticDosingStatus.automaticDosingEnabled, on: self) .store(in: &cancellables) + state = state.next + + await loopDataManager.updateDisplayState() + + NotificationCenter.default.publisher(for: .LoopCycleCompleted) + .sink { [weak self] _ in + Task { + await self?.loopCycleDidComplete() + } + } + .store(in: &cancellables) + } + + private func loopCycleDidComplete() async { + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + self.widgetLog.default("Refreshing widget. Reason: Loop completed") + WidgetCenter.shared.reloadAllTimelines() + } } private func launchOnboarding() { @@ -278,12 +512,14 @@ class LoopAppManager: NSObject { onboardingManager.launch { DispatchQueue.main.async { self.state = self.state.next - self.resumeLaunch() + Task { + await self.resumeLaunch() + } } } } - private func launchHomeScreen() { + private func launchHomeScreen() async { dispatchPrecondition(condition: .onQueue(.main)) precondition(state == .launchHomeScreen) @@ -296,6 +532,16 @@ class LoopAppManager: NSObject { statusTableViewController.onboardingManager = onboardingManager statusTableViewController.supportManager = supportManager statusTableViewController.testingScenariosManager = testingScenariosManager + statusTableViewController.settingsManager = settingsManager + statusTableViewController.temporaryPresetsManager = temporaryPresetsManager + statusTableViewController.loopManager = loopDataManager + statusTableViewController.diagnosticReportGenerator = self + statusTableViewController.simulatedData = self + statusTableViewController.analyticsServicesManager = analyticsServicesManager + statusTableViewController.servicesManager = servicesManager + statusTableViewController.carbStore = carbStore + statusTableViewController.doseStore = doseStore + statusTableViewController.criticalEventLogExportManager = criticalEventLogExportManager bluetoothStateManager.addBluetoothObserver(statusTableViewController) var rootNavigationController = rootViewController as? RootNavigationController @@ -306,7 +552,7 @@ class LoopAppManager: NSObject { rootNavigationController?.setViewControllers([statusTableViewController], animated: true) - deviceDataManager.refreshDeviceData() + await deviceDataManager.refreshDeviceData() handleRemoteNotificationFromLaunchOptions() @@ -325,7 +571,7 @@ class LoopAppManager: NSObject { } settingsManager?.didBecomeActive() deviceDataManager?.didBecomeActive() - alertManager.inferDeliveredLoopNotRunningNotifications() + alertManager?.inferDeliveredLoopNotRunningNotifications() widgetLog.default("Refreshing widget. Reason: App didBecomeActive") WidgetCenter.shared.reloadAllTimelines() @@ -333,7 +579,7 @@ class LoopAppManager: NSObject { // MARK: - Remote Notification - func remoteNotificationRegistrationDidFinish(_ result: Result) { + func remoteNotificationRegistrationDidFinish(_ result: Swift.Result) { if case .success(let token) = result { log.default("DeviceToken: %{public}@", token.hexadecimalString) } @@ -349,7 +595,7 @@ class LoopAppManager: NSObject { guard let notification = notification else { return false } - deviceDataManager?.servicesManager.handleRemoteNotification(notification) + servicesManager.handleRemoteNotification(notification) return true } @@ -395,20 +641,6 @@ class LoopAppManager: NSObject { } } - // MARK: - Background Tasks - - private func registerBackgroundTasks() { - if DeviceDataManager.registerCriticalEventLogHistoricalExportBackgroundTask({ self.deviceDataManager?.handleCriticalEventLogHistoricalExportBackgroundTask($0) }) { - log.debug("Critical event log export background task registered") - } else { - log.error("Critical event log export background task not registered") - } - } - - private func scheduleBackgroundTasks() { - deviceDataManager?.scheduleCriticalEventLogHistoricalExportBackgroundTask() - } - // MARK: - Private private func setWhitelistedDevices() { @@ -509,6 +741,33 @@ extension LoopAppManager: AlertPresenter { } } +protocol DisplayGlucoseUnitBroadcaster: AnyObject { + func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) + func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) +} + +extension LoopAppManager: DisplayGlucoseUnitBroadcaster { + func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { + let queue = DispatchQueue.main + displayGlucoseUnitObservers.insert(observer, queue: queue) + queue.async { + observer.unitDidChange(to: self.displayGlucosePreference.unit) + } + } + + func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { + displayGlucoseUnitObservers.removeElement(observer) + displayGlucoseUnitObservers.cleanupDeallocatedElements() + } + + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { + self.displayGlucoseUnitObservers.forEach { + $0.unitDidChange(to: displayGlucoseUnit) + } + } +} + // MARK: - DeviceOrientationController extension LoopAppManager: DeviceOrientationController { @@ -548,14 +807,12 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { let activationType = BolusActivationType(rawValue: activationTypeRawValue), startDate.timeIntervalSinceNow >= TimeInterval(minutes: -5) { - deviceDataManager?.analyticsServicesManager.didRetryBolus() + analyticsServicesManager.didRetryBolus() - deviceDataManager?.enactBolus(units: units, activationType: activationType) { (_) in - DispatchQueue.main.async { - completionHandler() - } + Task { @MainActor in + try? await deviceDataManager?.enactBolus(units: units, activationType: activationType) + completionHandler() } - return } case NotificationManager.Action.acknowledgeAlert.rawValue: let userInfo = response.notification.request.content.userInfo @@ -600,8 +857,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { extension LoopAppManager: TemporaryScheduleOverrideHistoryDelegate { func temporaryScheduleOverrideHistoryDidUpdate(_ history: TemporaryScheduleOverrideHistory) { UserDefaults.appGroup?.overrideHistory = history - - deviceDataManager.remoteDataServicesManager.triggerUpload(for: .overrides) + remoteDataServicesManager.triggerUpload(for: .overrides) } } @@ -643,3 +899,172 @@ extension LoopAppManager: ResetLoopManagerDelegate { alertManager.presentCouldNotResetLoopAlert(error: error) } } + +// MARK: - ServicesManagerDosingDelegate + +extension LoopAppManager: ServicesManagerDosingDelegate { + func deliverBolus(amountInUnits: Double) async throws { + try await deviceDataManager.enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) + } +} + +protocol DiagnosticReportGenerator: AnyObject { + func generateDiagnosticReport() async -> String +} + + +extension LoopAppManager: DiagnosticReportGenerator { + /// Generates a diagnostic report about the current state + /// + /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue. + /// + /// - parameter completion: A closure called once the report has been generated. The closure takes a single argument of the report string. + func generateDiagnosticReport() async -> String { + + let entries: [String] = [ + "## Build Details", + "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", + "* profileExpiration: \(BuildDetails.default.profileExpirationString)", + "* gitRevision: \(BuildDetails.default.gitRevision ?? "N/A")", + "* gitBranch: \(BuildDetails.default.gitBranch ?? "N/A")", + "* workspaceGitRevision: \(BuildDetails.default.workspaceGitRevision ?? "N/A")", + "* workspaceGitBranch: \(BuildDetails.default.workspaceGitBranch ?? "N/A")", + "* sourceRoot: \(BuildDetails.default.sourceRoot ?? "N/A")", + "* buildDateString: \(BuildDetails.default.buildDateString ?? "N/A")", + "* xcodeVersion: \(BuildDetails.default.xcodeVersion ?? "N/A")", + "", + "## FeatureFlags", + "\(FeatureFlags)", + "", + await alertManager.generateDiagnosticReport(), + await deviceDataManager.generateDiagnosticReport(), + "", + String(reflecting: self.watchManager), + "", + String(reflecting: self.statusExtensionManager), + "", + await loopDataManager.generateDiagnosticReport(), + "", + await self.glucoseStore.generateDiagnosticReport(), + "", + await self.carbStore.generateDiagnosticReport(), + "", + await self.carbStore.generateDiagnosticReport(), + "", + await self.mealDetectionManager.generateDiagnosticReport(), + "", + await UNUserNotificationCenter.current().generateDiagnosticReport(), + "", + UIDevice.current.generateDiagnosticReport(), + "" + ] + return entries.joined(separator: "\n") + } +} + + +// MARK: SimulatedData + +protocol SimulatedData { + func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) + func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) +} + +extension LoopAppManager: SimulatedData { + func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + settingsManager.settingsStore.generateSimulatedHistoricalSettingsObjects() { error in + guard error == nil else { + completion(error) + return + } + Task { @MainActor in + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + self.glucoseStore.generateSimulatedHistoricalGlucoseObjects() { error in + guard error == nil else { + completion(error) + return + } + self.carbStore.generateSimulatedHistoricalCarbObjects() { error in + guard error == nil else { + completion(error) + return + } + self.dosingDecisionStore.generateSimulatedHistoricalDosingDecisionObjects() { error in + guard error == nil else { + completion(error) + return + } + self.doseStore.generateSimulatedHistoricalPumpEvents() { error in + guard error == nil else { + completion(error) + return + } + self.deviceDataManager.deviceLog.generateSimulatedHistoricalDeviceLogEntries() { error in + guard error == nil else { + completion(error) + return + } + self.alertManager.alertStore.generateSimulatedHistoricalStoredAlerts(completion: completion) + } + } + } + } + } + } + } + } + + func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + alertManager.alertStore.purgeHistoricalStoredAlerts() { error in + guard error == nil else { + completion(error) + return + } + self.deviceDataManager.deviceLog.purgeHistoricalDeviceLogEntries() { error in + guard error == nil else { + completion(error) + return + } + Task { @MainActor in + self.doseStore.purgeHistoricalPumpEvents() { error in + guard error == nil else { + completion(error) + return + } + self.dosingDecisionStore.purgeHistoricalDosingDecisionObjects() { error in + guard error == nil else { + completion(error) + return + } + self.carbStore.purgeHistoricalCarbObjects() { error in + guard error == nil else { + completion(error) + return + } + self.glucoseStore.purgeHistoricalGlucoseObjects() { 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..2d3053f08d --- /dev/null +++ b/Loop/Managers/LoopDataManager+CarbAbsorption.swift @@ -0,0 +1,118 @@ +// +// LoopDataManager+CarbAbsorption.swift +// Loop +// +// Created by Pete Schwamb on 11/6/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import HealthKit + +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.getDoses( + 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) + + var overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: end) + + guard !sensitivity.isEmpty else { + throw LoopError.configurationError(.insulinSensitivitySchedule) + } + + let sensitivityWithOverrides = overrides.apply(over: sensitivity) { (quantity, override) in + let value = quantity.doubleValue(for: .milligramsPerDeciliter) + return HKQuantity( + unit: .milligramsPerDeciliter, + doubleValue: value / override.settings.effectiveInsulinNeedsScaleFactor + ) + } + + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) + } + let basalWithOverrides = overrides.apply(over: basal) { (value, override) in + value * override.settings.effectiveInsulinNeedsScaleFactor + } + + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) + } + let carbRatioWithOverrides = overrides.apply(over: carbRatio) { (value, override) in + value * override.settings.effectiveInsulinNeedsScaleFactor + } + + let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + + // Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal + let annotatedDoses = doses.annotated(with: basal) + + let insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) + + let insulinEffects = annotatedDoses.glucoseEffects( + insulinModelProvider: insulinModelProvider, + insulinSensitivityHistory: sensitivity, + 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: carbRatio, + insulinSensitivity: sensitivity + ) + + let carbEffects = carbStatus.dynamicGlucoseEffects( + from: end, + to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), + carbRatios: carbRatio, + insulinSensitivities: sensitivity, + absorptionModel: carbModel.model + ) + + return CarbAbsorptionReview( + carbEntries: carbEntries, + carbStatuses: carbStatus, + effectsVelocities: insulinCounteractionEffects, + carbEffects: carbEffects + ) + } +} diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 142641066b..697007d76d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -10,150 +10,158 @@ import Foundation import Combine import HealthKit import LoopKit +import LoopKitUI import LoopCore import WidgetKit -protocol PresetActivationObserver: AnyObject { - func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) - func presetDeactivated(context: TemporaryScheduleOverride.Context) +struct AlgorithmDisplayState { + var input: LoopAlgorithmInput? + var output: LoopAlgorithmOutput? + + var activeInsulin: InsulinValue? { + guard let input, let value = output?.activeInsulin else { + return nil + } + return InsulinValue(startDate: input.predictionStart, value: value) + } + + var activeCarbs: CarbValue? { + guard let input, let value = output?.activeCarbs else { + return nil + } + return CarbValue(startDate: input.predictionStart, value: value) + } + + var asTuple: (algoInput: LoopAlgorithmInput?, algoOutput: LoopAlgorithmOutput?) { + return (algoInput: input, algoOutput: output) + } +} + +protocol DeliveryDelegate: AnyObject { + var isSuspended: Bool { get } + var pumpInsulinType: InsulinType? { get } + var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { get } + var isPumpConfigured: Bool { get } + + func enact(_ recommendation: AutomaticDoseRecommendation) async throws + func enactBolus(units: Double, activationType: BolusActivationType) async throws + func roundBasalRate(unitsPerHour: Double) -> Double + func roundBolusVolume(units: Double) -> Double +} + +protocol DosingManagerDelegate { + func didMakeDosingDecision(_ decision: StoredDosingDecision) +} + +enum LoopUpdateContext: Int { + case insulin + case carbs + case glucose + case preferences + case forecast } +@MainActor final class LoopDataManager { - enum LoopUpdateContext: Int { - case insulin - case carbs - case glucose - case preferences - case loopFinished - } + nonisolated static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext" - let loopLock = UnfairLock() + // Represents the current state of the loop algorithm for display + var displayState = AlgorithmDisplayState() - static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext" + // Display state convenience accessors + var predictedGlucose: [PredictedGlucoseValue]? { + displayState.output?.predictedGlucose + } - private let carbStore: CarbStoreProtocol - - private let mealDetectionManager: MealDetectionManager + var tempBasalRecommendation: TempBasalRecommendation? { + displayState.output?.recommendation?.automatic?.basalAdjustment + } + + var automaticBolusRecommendation: Double? { + displayState.output?.recommendation?.automatic?.bolusUnits + } - private let doseStore: DoseStoreProtocol + var automaticRecommendation: AutomaticDoseRecommendation? { + displayState.output?.recommendation?.automatic + } - let dosingDecisionStore: DosingDecisionStoreProtocol + private(set) var lastLoopCompleted: Date? - private let glucoseStore: GlucoseStoreProtocol + var deliveryDelegate: DeliveryDelegate? - let latestStoredSettingsProvider: LatestStoredSettingsProvider + let analyticsServicesManager: AnalyticsServicesManager? + let carbStore: CarbStoreProtocol + let doseStore: DoseStoreProtocol + let temporaryPresetsManager: TemporaryPresetsManager + let settingsProvider: SettingsProvider + let dosingDecisionStore: DosingDecisionStoreProtocol + let glucoseStore: GlucoseStoreProtocol - weak var delegate: LoopDataManagerDelegate? + let logger = DiagnosticLog(category: "LoopDataManager") - private let logger = DiagnosticLog(category: "LoopDataManager") private let widgetLog = DiagnosticLog(category: "LoopWidgets") - private let analyticsServicesManager: AnalyticsServicesManager - - private let trustedTimeOffset: () -> TimeInterval + private let trustedTimeOffset: () async -> TimeInterval private let now: () -> Date private let automaticDosingStatus: AutomaticDosingStatus - lazy private var cancellables = Set() - // References to registered notification center observers private var notificationObservers: [Any] = [] - - private var overrideIntentObserver: NSKeyValueObservation? = nil - var presetActivationObservers: [PresetActivationObserver] = [] - - private var timeBasedDoseApplicationFactor: Double = 1.0 + var activeInsulin: InsulinValue? { + displayState.activeInsulin + } + var activeCarbs: CarbValue? { + displayState.activeCarbs + } - private var insulinOnBoard: InsulinValue? + var latestGlucose: GlucoseSampleValue? { + displayState.input?.glucoseHistory.last + } - deinit { - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) - } + var lastReservoirValue: ReservoirValue? { + doseStore.lastReservoirValue } + var carbAbsorptionModel: CarbAbsorptionModel + + private var lastManualBolusRecommendation: ManualBolusRecommendation? + + var usePositiveMomentumAndRCForManualBoluses: Bool + + lazy private var cancellables = Set() + init( lastLoopCompleted: Date?, - basalDeliveryState: PumpManagerStatus.BasalDeliveryState?, - settings: LoopSettings, - overrideHistory: TemporaryScheduleOverrideHistory, - analyticsServicesManager: AnalyticsServicesManager, - localCacheDuration: TimeInterval = .days(1), + temporaryPresetsManager: TemporaryPresetsManager, + settingsProvider: SettingsProvider, doseStore: DoseStoreProtocol, glucoseStore: GlucoseStoreProtocol, carbStore: CarbStoreProtocol, dosingDecisionStore: DosingDecisionStoreProtocol, - latestStoredSettingsProvider: LatestStoredSettingsProvider, now: @escaping () -> Date = { Date() }, - pumpInsulinType: InsulinType?, automaticDosingStatus: AutomaticDosingStatus, - trustedTimeOffset: @escaping () -> TimeInterval + trustedTimeOffset: @escaping () async -> TimeInterval, + analyticsServicesManager: AnalyticsServicesManager?, + carbAbsorptionModel: CarbAbsorptionModel, + usePositiveMomentumAndRCForManualBoluses: Bool = true ) { - self.analyticsServicesManager = analyticsServicesManager - self.lockedLastLoopCompleted = Locked(lastLoopCompleted) - self.lockedBasalDeliveryState = Locked(basalDeliveryState) - self.lockedSettings = Locked(settings) - self.dosingEnabled = settings.dosingEnabled - - self.overrideHistory = overrideHistory - let absorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes - - self.overrideHistory.relevantTimeWindow = absorptionTimes.slow * 2 - - self.carbStore = carbStore + self.lastLoopCompleted = lastLoopCompleted + self.temporaryPresetsManager = temporaryPresetsManager + self.settingsProvider = settingsProvider self.doseStore = doseStore self.glucoseStore = glucoseStore - + self.carbStore = carbStore self.dosingDecisionStore = dosingDecisionStore - self.now = now - - self.latestStoredSettingsProvider = latestStoredSettingsProvider - self.mealDetectionManager = MealDetectionManager( - carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory, - insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory, - maximumBolus: settings.maximumBolus - ) - - self.lockedPumpInsulinType = Locked(pumpInsulinType) - self.automaticDosingStatus = automaticDosingStatus - self.trustedTimeOffset = trustedTimeOffset - - overrideIntentObserver = UserDefaults.appGroup?.observe(\.intentExtensionOverrideToSet, options: [.new], changeHandler: {[weak self] (defaults, change) in - guard let name = change.newValue??.lowercased(), let appGroup = UserDefaults.appGroup else { - return - } - - guard let preset = self?.settings.overridePresets.first(where: {$0.name.lowercased() == name}) else { - self?.logger.error("Override Intent: Unable to find override named '%s'", String(describing: name)) - return - } - - self?.logger.default("Override Intent: setting override named '%s'", String(describing: name)) - self?.mutateSettings { settings in - if let oldPreset = settings.scheduleOverride { - if let observers = self?.presetActivationObservers { - for observer in observers { - observer.presetDeactivated(context: oldPreset.context) - } - } - } - settings.scheduleOverride = preset.createOverride(enactTrigger: .remote("Siri")) - if let observers = self?.presetActivationObservers { - for observer in observers { - observer.presetActivated(context: .preset(preset), duration: preset.duration) - } - } - } - // Remove the override from UserDefaults so we don't set it multiple times - appGroup.intentExtensionOverrideToSet = nil - }) + self.analyticsServicesManager = analyticsServicesManager + self.carbAbsorptionModel = carbAbsorptionModel + self.usePositiveMomentumAndRCForManualBoluses = usePositiveMomentumAndRCForManualBoluses // Required for device settings in stored dosing decisions UIDevice.current.isBatteryMonitoringEnabled = true @@ -165,13 +173,9 @@ final class LoopDataManager { object: self.carbStore, queue: nil ) { (note) -> Void in - self.dataAccessQueue.async { + Task { @MainActor in self.logger.default("Received notification of carb entries changing") - - self.carbEffect = nil - self.carbsOnBoard = nil - self.recentCarbEntries = nil - self.remoteRecommendationNeedsUpdating = true + await self.updateDisplayState() self.notify(forChange: .carbs) } }, @@ -180,12 +184,9 @@ final class LoopDataManager { object: self.glucoseStore, queue: nil ) { (note) in - self.dataAccessQueue.async { + Task { @MainActor in self.logger.default("Received notification of glucose samples changing") - - self.glucoseMomentumEffect = nil - self.remoteRecommendationNeedsUpdating = true - + await self.updateDisplayState() self.notify(forChange: .glucose) } }, @@ -194,12 +195,9 @@ final class LoopDataManager { object: self.doseStore, queue: OperationQueue.main ) { (note) in - self.dataAccessQueue.async { + Task { @MainActor in self.logger.default("Received notification of dosing changing") - - self.clearCachedInsulinEffects() - self.remoteRecommendationNeedsUpdating = true - + await self.updateDisplayState() self.notify(forChange: .insulin) } } @@ -208,318 +206,405 @@ final class LoopDataManager { // Turn off preMeal when going into closed loop off mode // Cancel any active temp basal when going into closed loop off mode // The dispatch is necessary in case this is coming from a didSet already on the settings struct. - self.automaticDosingStatus.$automaticDosingEnabled + automaticDosingStatus.$automaticDosingEnabled .removeDuplicates() .dropFirst() - .receive(on: DispatchQueue.main) - .sink { if !$0 { - self.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) + .sink { + if !$0 { + self.temporaryPresetsManager.clearOverride(matching: .preMeal) + Task { + await self.cancelActiveTempBasal(for: .automaticDosingDisabled) + } + } else { + Task { + await self.updateDisplayState() + } } - self.cancelActiveTempBasal(for: .automaticDosingDisabled) - } } + } .store(in: &cancellables) + + } - /// Loop-related settings + // MARK: - Calculation state + + fileprivate let dataAccessQueue: DispatchQueue = DispatchQueue(label: "com.loudnate.Naterade.LoopDataManager.dataAccessQueue", qos: .utility) + - private var lockedSettings: Locked + // MARK: - Background task management + + 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 fetchData(for baseTime: Date = Date(), disablingPreMeal: Bool = false) async throws -> LoopAlgorithmInput { + // Need to fetch doses back as far as t - (DIA + DCA) for Dynamic carbs + let dosesInputHistory = CarbMath.maximumAbsorptionTimeInterval + InsulinMath.defaultInsulinActivityDuration + + var dosesStart = baseTime.addingTimeInterval(-dosesInputHistory) + let doses = try await doseStore.getDoses( + start: dosesStart, + end: baseTime + ) + + dosesStart = doses.map { $0.startDate }.min() ?? dosesStart + + let basal = try await settingsProvider.getBasalHistory(startDate: dosesStart, endDate: baseTime) + + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) } - var invalidateCachedEffects = false + let forecastEndTime = baseTime.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(.minutes(GlucoseMath.defaultDelta)) - dosingEnabled = newValue.dosingEnabled + let carbsStart = baseTime.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) - if newValue.preMealOverride != oldValue.preMealOverride { - // The prediction isn't actually invalid, but a target range change requires recomputing recommended doses - predictedGlucose = nil + // Include future carbs in query, but filter out ones entered after basetime. The filtering is only applicable when running in a retrospective situation. + let carbEntries = try await carbStore.getCarbEntries( + start: carbsStart, + end: forecastEndTime + ).filter { + $0.userCreatedDate ?? $0.startDate < baseTime } - if newValue.scheduleOverride != oldValue.scheduleOverride { - overrideHistory.recordOverride(settings.scheduleOverride) + let carbRatio = try await settingsProvider.getCarbRatioHistory( + startDate: carbsStart, + endDate: forecastEndTime + ) - if let oldPreset = oldValue.scheduleOverride { - for observer in self.presetActivationObservers { - observer.presetDeactivated(context: oldPreset.context) - } + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) + } - } - if let newPreset = newValue.scheduleOverride { - for observer in self.presetActivationObservers { - observer.presetActivated(context: newPreset.context, duration: newPreset.duration) - } - } + let glucose = try await glucoseStore.getGlucoseSamples(start: carbsStart, end: baseTime) + + let sensitivityStart = min(carbsStart, dosesStart) + + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: sensitivityStart, endDate: forecastEndTime) - // Invalidate cached effects affected by the override - invalidateCachedEffects = true - - // Update the affected schedules - mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory - mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory + let target = try await settingsProvider.getTargetRangeHistory(startDate: baseTime, endDate: forecastEndTime) + + let dosingLimits = try await settingsProvider.getDosingLimits(at: baseTime) + + guard let maxBolus = dosingLimits.maxBolus else { + throw LoopError.configurationError(.maximumBolus) } - if newValue.insulinSensitivitySchedule != oldValue.insulinSensitivitySchedule { - carbStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule - doseStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule - mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory - invalidateCachedEffects = true - analyticsServicesManager.didChangeInsulinSensitivitySchedule() + guard let maxBasalRate = dosingLimits.maxBasalRate else { + throw LoopError.configurationError(.maximumBasalRatePerHour) } - if newValue.basalRateSchedule != oldValue.basalRateSchedule { - doseStore.basalProfile = newValue.basalRateSchedule + var overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: forecastEndTime) - if let newValue = newValue.basalRateSchedule, let oldValue = oldValue.basalRateSchedule, newValue.items != oldValue.items { - analyticsServicesManager.didChangeBasalRateSchedule() - } + // Bug (https://tidepool.atlassian.net/browse/LOOP-4759) pre-meal is not recorded in override history + // So currently we handle automatic forecast by manually adding it in, and when meal bolusing, we do not do this. + // Eventually, when pre-meal is stored in override history, during meal bolusing we should scan for it and adjust the end time + if !disablingPreMeal, let preMeal = temporaryPresetsManager.preMealOverride { + overrides.append(preMeal) + overrides.sort { $0.startDate < $1.startDate } } - if newValue.carbRatioSchedule != oldValue.carbRatioSchedule { - carbStore.carbRatioSchedule = newValue.carbRatioSchedule - mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory - invalidateCachedEffects = true - analyticsServicesManager.didChangeCarbRatioSchedule() + guard !sensitivity.isEmpty else { + throw LoopError.configurationError(.insulinSensitivitySchedule) } - 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 sensitivityWithOverrides = overrides.apply(over: sensitivity) { (quantity, override) in + let value = quantity.doubleValue(for: .milligramsPerDeciliter) + return HKQuantity( + unit: .milligramsPerDeciliter, + doubleValue: value / override.settings.effectiveInsulinNeedsScaleFactor + ) } - if newValue.maximumBolus != oldValue.maximumBolus { - mealDetectionManager.maximumBolus = newValue.maximumBolus + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) + } + let basalWithOverrides = overrides.apply(over: basal) { (value, override) in + value * override.settings.effectiveInsulinNeedsScaleFactor } - if invalidateCachedEffects { - dataAccessQueue.async { - // Invalidate cached effects based on this schedule - self.carbEffect = nil - self.carbsOnBoard = nil - self.clearCachedInsulinEffects() - } + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) + } + let carbRatioWithOverrides = overrides.apply(over: carbRatio) { (value, override) in + value * override.settings.effectiveInsulinNeedsScaleFactor } - notify(forChange: .preferences) - analyticsServicesManager.didChangeLoopSettings(from: oldValue, to: newValue) - } + guard !target.isEmpty else { + throw LoopError.configurationError(.glucoseTargetRangeSchedule) + } + let targetWithOverrides = overrides.apply(over: target) { (range, override) in + override.settings.targetRange ?? range + } - @Published private(set) var dosingEnabled: Bool + // Create dosing strategy based on user setting + let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled + ? GlucoseBasedApplicationFactorStrategy() + : ConstantApplicationFactorStrategy() - let overrideHistory: TemporaryScheduleOverrideHistory + let correctionRange = target.closestPrior(to: baseTime)?.value - // MARK: - Calculation state + let effectiveBolusApplicationFactor: Double? - fileprivate let dataAccessQueue: DispatchQueue = DispatchQueue(label: "com.loudnate.Naterade.LoopDataManager.dataAccessQueue", qos: .utility) + if let latestGlucose = glucose.last { + effectiveBolusApplicationFactor = applicationFactorStrategy.calculateDosingFactor( + for: latestGlucose.quantity, + correctionRange: correctionRange! + ) + } else { + effectiveBolusApplicationFactor = nil + } + + return LoopAlgorithmInput( + predictionStart: baseTime, + glucoseHistory: glucose, + doses: doses, + carbEntries: carbEntries, + basal: basalWithOverrides, + sensitivity: sensitivityWithOverrides, + carbRatio: carbRatioWithOverrides, + target: targetWithOverrides, + suspendThreshold: dosingLimits.suspendThreshold, + maxBolus: maxBolus, + maxBasalRate: maxBasalRate, + useIntegralRetrospectiveCorrection: UserDefaults.standard.integralRetrospectiveCorrectionEnabled, + carbAbsorptionModel: carbAbsorptionModel, + recommendationInsulinType: deliveryDelegate?.pumpInsulinType ?? .novolog, + recommendationType: .manualBolus, + automaticBolusApplicationFactor: effectiveBolusApplicationFactor + ) + } - private var carbEffect: [GlucoseEffect]? { - didSet { - predictedGlucose = nil + func loopingReEnabled() async { + await updateDisplayState() + self.notify(forChange: .forecast) + } - // Carb data may be back-dated, so re-calculate the retrospective glucose. - retrospectiveGlucoseDiscrepancies = nil + func updateDisplayState() async { + var newState = AlgorithmDisplayState() + do { + var input = try await fetchData(for: now()) + input.recommendationType = .manualBolus + newState.input = input + newState.output = LoopAlgorithm.run(input: input) + } catch { + let loopError = error as? LoopError ?? .unknownError(error) + logger.error("Error updating Loop state: %{public}@", String(describing: loopError)) } + displayState = newState + await updateRemoteRecommendation() } - private var insulinEffect: [GlucoseEffect]? + /// Cancel the active temp basal if it was automatically issued + func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async { + guard case .tempBasal(let dose) = deliveryDelegate?.basalDeliveryState, (dose.automatic ?? true) else { return } - private var insulinEffectIncludingPendingInsulin: [GlucoseEffect]? { - didSet { - predictedGlucoseIncludingPendingInsulin = nil - } - } + logger.default("Cancelling active temp basal for reason: %{public}@", String(describing: reason)) - private var glucoseMomentumEffect: [GlucoseEffect]? { - didSet { - predictedGlucose = nil - } - } + let recommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - private var retrospectiveGlucoseEffect: [GlucoseEffect] = [] { - didSet { - predictedGlucose = nil + var dosingDecision = StoredDosingDecision(reason: reason.rawValue) + dosingDecision.settings = StoredDosingDecision.Settings(settingsProvider.settings) + dosingDecision.automaticDoseRecommendation = recommendation + + do { + try await deliveryDelegate?.enact(recommendation) + } catch { + dosingDecision.appendError(error as? LoopError ?? .unknownError(error)) } + + await dosingDecisionStore.storeDosingDecision(dosingDecision) } - /// When combining retrospective glucose discrepancies, extend the window slightly as a buffer. - private let retrospectiveCorrectionGroupingIntervalMultiplier = 1.01 + func loop() async { + let loopBaseTime = now() - private var retrospectiveGlucoseDiscrepancies: [GlucoseEffect]? { - didSet { - retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies?.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) - } - } + var dosingDecision = StoredDosingDecision( + date: loopBaseTime, + reason: "loop" + ) - private var retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]? - - private var suspendInsulinDeliveryEffect: [GlucoseEffect] = [] + do { + guard let deliveryDelegate else { + preconditionFailure("Unable to dose without dosing delegate.") + } - fileprivate var predictedGlucose: [PredictedGlucoseValue]? { - didSet { - recommendedAutomaticDose = nil - predictedGlucoseIncludingPendingInsulin = nil - } - } + logger.debug("Running Loop at %{public}@", String(describing: loopBaseTime)) + NotificationCenter.default.post(name: .LoopRunning, object: self) - fileprivate var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? + var input = try await fetchData(for: loopBaseTime) - private var recentCarbEntries: [StoredCarbEntry]? + let startDate = input.predictionStart - fileprivate var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? + let dosingStrategy = settingsProvider.settings.automaticDosingStrategy + input.recommendationType = dosingStrategy.recommendationType - fileprivate var carbsOnBoard: CarbValue? + guard let latestGlucose = input.glucoseHistory.last else { + throw LoopError.missingDataError(.glucose) + } - 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 - } - set { - lockedPumpInsulinType.value = newValue - } - } - private let lockedPumpInsulinType: Locked + guard startDate.timeIntervalSince(latestGlucose.startDate) <= LoopAlgorithm.inputDataRecencyInterval else { + throw LoopError.glucoseTooOld(date: latestGlucose.startDate) + } - fileprivate var lastRequestedBolus: DoseEntry? + guard latestGlucose.startDate.timeIntervalSince(startDate) <= LoopAlgorithm.inputDataRecencyInterval else { + throw LoopError.invalidFutureGlucose(date: latestGlucose.startDate) + } - /// 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 + var output = LoopAlgorithm.run(input: input) - fileprivate var lastLoopError: LoopError? + switch output.recommendationResult { + case .success(let recommendation): + // Round delivery amounts to pump supported amounts, + // And determine if a change in dosing should be made. - /// A timeline of average velocity of glucose change counteracting predicted insulin effects - fileprivate var insulinCounteractionEffects: [GlucoseEffectVelocity] = [] { - didSet { - carbEffect = nil - carbsOnBoard = nil - } - } + let algoRecommendation = recommendation.automatic! + logger.default("Algorithm recommendation: %{public}@", String(describing: algoRecommendation)) - // Confined to dataAccessQueue - private var lastIntegralRetrospectiveCorrectionEnabled: Bool? - private var cachedRetrospectiveCorrection: RetrospectiveCorrection? + var recommendationToEnact = algoRecommendation + // Round bolus recommendation based on pump bolus precision + if let bolus = algoRecommendation.bolusUnits, bolus > 0 { + recommendationToEnact.bolusUnits = deliveryDelegate.roundBolusVolume(units: bolus) + } - 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) + if var basal = algoRecommendation.basalAdjustment { + basal.unitsPerHour = deliveryDelegate.roundBasalRate(unitsPerHour: basal.unitsPerHour) + + let lastTempBasal = input.doses.first { $0.type == .tempBasal && $0.startDate < input.predictionStart && $0.endDate > input.predictionStart } + let scheduledBasalRate = input.basal.closestPrior(to: loopBaseTime)!.value + let activeOverride = temporaryPresetsManager.overrideHistory.activeOverride(at: loopBaseTime) + + let basalAdjustment = basal.ifNecessary( + at: loopBaseTime, + neutralBasalRate: scheduledBasalRate, + lastTempBasal: lastTempBasal, + continuationInterval: .minutes(11), + neutralBasalRateMatchesPump: activeOverride == nil + ) + + recommendationToEnact.basalAdjustment = basalAdjustment + } + output.recommendationResult = .success(.init(automatic: recommendationToEnact)) + + if recommendationToEnact != algoRecommendation { + logger.default("Recommendation changed to: %{public}@", String(describing: recommendationToEnact)) + } + + dosingDecision.updateFrom(input: input, output: output) + + if self.automaticDosingStatus.automaticDosingEnabled { + if deliveryDelegate.isSuspended { + throw LoopError.pumpSuspended + } + + if recommendationToEnact.hasDosingChange { + logger.default("Enacting: %{public}@", String(describing: recommendationToEnact)) + try await deliveryDelegate.enact(recommendationToEnact) + } + + logger.default("loop() completed successfully.") + lastLoopCompleted = Date() + let duration = lastLoopCompleted!.timeIntervalSince(loopBaseTime) + + analyticsServicesManager?.loopDidSucceed(duration) + } else { + self.logger.default("Not adjusting dosing during open loop.") + } + + await dosingDecisionStore.storeDosingDecision(dosingDecision) + NotificationCenter.default.post(name: .LoopCycleCompleted, object: self) + + case .failure(let error): + throw error } + } catch { + logger.error("loop() did error: %{public}@", String(describing: error)) + let loopError = error as? LoopError ?? .unknownError(error) + dosingDecision.appendError(loopError) + await dosingDecisionStore.storeDosingDecision(dosingDecision) + analyticsServicesManager?.loopDidError(error: loopError) } - - return cachedRetrospectiveCorrection! - } - - func clearCachedInsulinEffects() { - insulinEffect = nil - insulinEffectIncludingPendingInsulin = nil - predictedGlucose = nil + logger.default("Loop ended") } - // MARK: - Background task management + func recommendManualBolus( + manualGlucoseSample: NewGlucoseSample? = nil, + potentialCarbEntry: NewCarbEntry? = nil, + originalCarbEntry: StoredCarbEntry? = nil + ) async throws -> ManualBolusRecommendation? { - private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + var input = try await self.fetchData(for: now(), disablingPreMeal: potentialCarbEntry != nil) + .addingGlucoseSample(sample: manualGlucoseSample) + .removingCarbEntry(carbEntry: originalCarbEntry) + .addingCarbEntry(carbEntry: potentialCarbEntry) - private func startBackgroundTask() { - endBackgroundTask() - backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "PersistenceController save") { - self.endBackgroundTask() - } - } + input.includePositiveVelocityAndRC = usePositiveMomentumAndRCForManualBoluses + input.recommendationType = .manualBolus - private func endBackgroundTask() { - if backgroundTask != .invalid { - UIApplication.shared.endBackgroundTask(backgroundTask) - backgroundTask = .invalid + let output = LoopAlgorithm.run(input: input) + + switch output.recommendationResult { + case .success(let prediction): + return prediction.manual + case .failure(let error): + throw error } } - private func loopDidComplete(date: Date, dosingDecision: StoredDosingDecision, duration: TimeInterval) { - logger.default("Loop completed successfully.") - lastLoopCompleted = date - analyticsServicesManager.loopDidSucceed(duration) - dosingDecisionStore.storeDosingDecision(dosingDecision) {} - - NotificationCenter.default.post(name: .LoopCompleted, object: self) + var iobValues: [InsulinValue] { + dosesRelativeToBasal.insulinOnBoard() } - 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) {} + var dosesRelativeToBasal: [DoseEntry] { + displayState.output?.dosesRelativeToBasal ?? [] } - // This is primarily for remote clients displaying a bolus recommendation and forecast - // Should be called after any significant change to forecast input data. - + func updateRemoteRecommendation() async { + if lastManualBolusRecommendation == nil { + lastManualBolusRecommendation = displayState.output?.recommendation?.manual + } - var remoteRecommendationNeedsUpdating: Bool = false + guard lastManualBolusRecommendation != displayState.output?.recommendation?.manual else { + // no change + return + } - func updateRemoteRecommendation() { - dataAccessQueue.async { - if self.remoteRecommendationNeedsUpdating { - var (dosingDecision, updateError) = self.update(for: .updateRemoteRecommendation) + lastManualBolusRecommendation = displayState.output?.recommendation?.manual - if let error = updateError { - self.logger.error("Error updating manual bolus recommendation: %{public}@", String(describing: error)) + if let output = displayState.output { + var dosingDecision = StoredDosingDecision(date: Date(), reason: "updateRemoteRecommendation") + dosingDecision.predictedGlucose = output.predictedGlucose + dosingDecision.insulinOnBoard = displayState.activeInsulin + dosingDecision.carbsOnBoard = displayState.activeCarbs + switch output.recommendationResult { + case .success(let recommendation): + dosingDecision.automaticDoseRecommendation = recommendation.automatic + if let recommendationDate = displayState.input?.predictionStart, let manualRec = recommendation.manual { + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: manualRec, date: recommendationDate) + } + case .failure(let error): + if let loopError = error as? LoopError { + dosingDecision.errors.append(loopError.issue) } else { - 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)) - } + dosingDecision.errors.append(.init(id: "error", details: ["description": error.localizedDescription])) } - self.remoteRecommendationNeedsUpdating = false } + + dosingDecision.controllerStatus = UIDevice.current.controllerStatus + self.logger.debug("Manual bolus rec = %{public}@", String(describing: dosingDecision.manualBolusRecommendation)) + await self.dosingDecisionStore.storeDosingDecision(dosingDecision) } } } @@ -535,37 +620,6 @@ extension LoopDataManager: PersistenceControllerDelegate { } } -// MARK: - Preferences -extension LoopDataManager { - - /// The basal rate schedule, applying recent overrides relative to the current moment in time. - var basalRateScheduleApplyingOverrideHistory: BasalRateSchedule? { - return doseStore.basalProfileApplyingOverrideHistory - } - - /// The carb ratio schedule, applying recent overrides relative to the current moment in time. - var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { - return carbStore.carbRatioScheduleApplyingOverrideHistory - } - - /// The insulin sensitivity schedule, applying recent overrides relative to the current moment in time. - var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { - return carbStore.insulinSensitivityScheduleApplyingOverrideHistory - } - - /// 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 - } - } -} - // MARK: - Intake extension LoopDataManager { @@ -575,1572 +629,143 @@ extension LoopDataManager { /// - 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 } - } - - completion?(.success(samples)) - case .failure(let error): - completion?(.failure(error)) - } - } - } - } - - /// 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) + func addGlucose(_ samples: [NewGlucoseSample]) async throws -> [StoredGlucoseSample] { + return try await glucoseStore.addGlucoseSamples(samples) } - 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 } - - dataAccessQueue.async { - self.cancelActiveTempBasal(for: reason, completion: nil) - } - } - - private func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason, completion: ((Error?) -> Void)?) { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - let recommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - recommendedAutomaticDose = (recommendation: recommendation, date: now()) - - 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)) - } - } - } - - 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) - } - } - - - /// 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))) - } - - completion(.success(( - newValue: newValue, - lastValue: previousValue, - areStoredValuesContinuous: areStoredValuesContinuous - ))) - } - } 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) - - if error == nil, self.automaticDosingStatus.automaticDosingEnabled == true { - error = self.enactRecommendedAutomaticDose() - } else { - self.logger.default("Not adjusting dosing during open loop.") - } - - self.finishLoop(startDate: startDate, dosingDecision: dosingDecision, error: error) - } - } - - private func finishLoop(startDate: Date, dosingDecision: StoredDosingDecision, error: LoopError? = nil) { - let date = now() - let duration = date.timeIntervalSince(startDate) - - if let error = error { - loopDidError(date: date, error: error, dosingDecision: dosingDecision, duration: duration) - } else { - loopDidComplete(date: date, dosingDecision: dosingDecision, duration: duration) - } - - logger.default("Loop ended") - notify(forChange: .loopFinished) - - if FeatureFlags.missedMealNotifications { - let samplesStart = now().addingTimeInterval(-MissedMealSettings.maxRecency) - carbStore.getGlucoseEffects(start: samplesStart, end: now(), effectVelocities: insulinCounteractionEffects) {[weak self] result in - guard - let self = self, - case .success((_, let carbEffects)) = result - else { - if case .failure(let error) = result { - self?.logger.error("Failed to fetch glucose effects to check for missed meal: %{public}@", String(describing: error)) - } - return - } - - glucoseStore.getGlucoseSamples(start: samplesStart, end: now()) {[weak self] result in - guard - let self = self, - case .success(let glucoseSamples) = result - else { - if case .failure(let error) = result { - self?.logger.error("Failed to fetch glucose samples to check for missed meal: %{public}@", String(describing: error)) - } - return - } - - self.mealDetectionManager.generateMissedMealNotificationIfNeeded( - glucoseSamples: glucoseSamples, - insulinCounteractionEffects: self.insulinCounteractionEffects, - carbEffects: carbEffects, - pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits, - bolusDurationEstimator: { [unowned self] bolusAmount in - return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount) - } - ) - } - } - } - - // 5 second delay to allow stores to cache data before it is read by widget - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - self.widgetLog.default("Refreshing widget. Reason: Loop completed") - WidgetCenter.shared.reloadAllTimelines() - } - - updateRemoteRecommendation() - } - - fileprivate enum UpdateReason: String { - case loop - case getLoopState - case updateRemoteRecommendation - } - - fileprivate func update(for reason: UpdateReason) -> (StoredDosingDecision, LoopError?) { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - var dosingDecision = StoredDosingDecision(reason: reason.rawValue) - let latestSettings = latestStoredSettingsProvider.latestSettings - dosingDecision.settings = StoredDosingDecision.Settings(latestSettings) - dosingDecision.scheduleOverride = latestSettings.scheduleOverride - dosingDecision.controllerStatus = UIDevice.current.controllerStatus - dosingDecision.pumpManagerStatus = delegate?.pumpManagerStatus - if let pumpStatusHighlight = delegate?.pumpStatusHighlight { - dosingDecision.pumpStatusHighlight = StoredDosingDecision.StoredDeviceHighlight( - localizedMessage: pumpStatusHighlight.localizedMessage, - imageName: pumpStatusHighlight.imageName, - state: pumpStatusHighlight.state) - } - dosingDecision.cgmManagerStatus = delegate?.cgmManagerStatus - dosingDecision.lastReservoirValue = StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue) - - let warnings = Locked<[LoopWarning]>([]) - - let updateGroup = DispatchGroup() - - let historicalGlucoseStartDate = Date(timeInterval: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval, since: now()) - let inputDataRecencyStartDate = Date(timeInterval: -LoopAlgorithm.inputDataRecencyInterval, since: now()) - - // Fetch glucose effects as far back as we want to make retroactive analysis and historical glucose for dosing decision - var historicalGlucose: [HistoricalGlucoseValue]? - var latestGlucoseDate: Date? - updateGroup.enter() - glucoseStore.getGlucoseSamples(start: min(historicalGlucoseStartDate, inputDataRecencyStartDate), end: nil) { (result) in - switch result { - case .failure(let error): - self.logger.error("Failure getting glucose samples: %{public}@", String(describing: error)) - latestGlucoseDate = nil - warnings.append(.fetchDataWarning(.glucoseSamples(error: error))) - case .success(let samples): - historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } - latestGlucoseDate = samples.last?.startDate - } - updateGroup.leave() - } - _ = updateGroup.wait(timeout: .distantFuture) - - guard let lastGlucoseDate = latestGlucoseDate else { - dosingDecision.appendWarnings(warnings.value) - dosingDecision.appendError(.missingDataError(.glucose)) - return (dosingDecision, .missingDataError(.glucose)) - } - - let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) - - let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) - let nextCounteractionEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate - let insulinEffectStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-5)) - - if glucoseMomentumEffect == nil { - updateGroup.enter() - glucoseStore.getRecentMomentumEffect(for: now()) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("Failure getting recent momentum effect: %{public}@", String(describing: error)) - self.glucoseMomentumEffect = nil - warnings.append(.fetchDataWarning(.glucoseMomentumEffect(error: error))) - case .success(let effects): - self.glucoseMomentumEffect = effects - } - updateGroup.leave() - } - } - - if insulinEffect == nil || insulinEffect?.first?.startDate ?? .distantFuture > insulinEffectStartDate { - self.logger.debug("Recomputing insulin effects") - updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: now()) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("Could not fetch insulin effects: %{public}@", error.localizedDescription) - self.insulinEffect = nil - warnings.append(.fetchDataWarning(.insulinEffect(error: error))) - case .success(let effects): - self.insulinEffect = effects - } - - updateGroup.leave() - } - } - - if insulinEffectIncludingPendingInsulin == nil { - updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("Could not fetch insulin effects including pending insulin: %{public}@", error.localizedDescription) - self.insulinEffectIncludingPendingInsulin = nil - warnings.append(.fetchDataWarning(.insulinEffectIncludingPendingInsulin(error: error))) - case .success(let effects): - self.insulinEffectIncludingPendingInsulin = effects - } - - updateGroup.leave() - } - } - - _ = updateGroup.wait(timeout: .distantFuture) - - if nextCounteractionEffectDate < lastGlucoseDate, let insulinEffect = insulinEffect { - updateGroup.enter() - self.logger.debug("Fetching counteraction effects after %{public}@", String(describing: nextCounteractionEffectDate)) - glucoseStore.getCounteractionEffects(start: nextCounteractionEffectDate, end: nil, to: insulinEffect) { (result) in - switch result { - case .failure(let error): - self.logger.error("Failure getting counteraction effects: %{public}@", String(describing: error)) - warnings.append(.fetchDataWarning(.insulinCounteractionEffect(error: error))) - case .success(let velocities): - self.insulinCounteractionEffects.append(contentsOf: velocities) - } - self.insulinCounteractionEffects = self.insulinCounteractionEffects.filterDateRange(earliestEffectDate, nil) - - updateGroup.leave() - } - - _ = updateGroup.wait(timeout: .distantFuture) - } - - if carbEffect == nil { - updateGroup.enter() - carbStore.getGlucoseEffects( - start: retrospectiveStart, end: nil, - effectVelocities: insulinCounteractionEffects - ) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("%{public}@", String(describing: error)) - self.carbEffect = nil - self.recentCarbEntries = nil - warnings.append(.fetchDataWarning(.carbEffect(error: error))) - case .success(let (entries, effects)): - self.carbEffect = effects - self.recentCarbEntries = entries - } - - updateGroup.leave() - } - } - - if carbsOnBoard == nil { - updateGroup.enter() - carbStore.carbsOnBoard(at: now(), effectVelocities: insulinCounteractionEffects) { (result) in - switch result { - case .failure(let error): - switch error { - case .noData: - // when there is no data, carbs on board is set to 0 - self.carbsOnBoard = CarbValue(startDate: Date(), value: 0) - default: - self.carbsOnBoard = nil - warnings.append(.fetchDataWarning(.carbsOnBoard(error: error))) - } - case .success(let value): - self.carbsOnBoard = value - } - updateGroup.leave() - } - } - updateGroup.enter() - doseStore.insulinOnBoard(at: now()) { result in - switch result { - case .failure(let error): - warnings.append(.fetchDataWarning(.insulinOnBoard(error: error))) - case .success(let insulinValue): - self.insulinOnBoard = insulinValue - } - updateGroup.leave() - } - - _ = updateGroup.wait(timeout: .distantFuture) - - if retrospectiveGlucoseDiscrepancies == nil { - do { - try updateRetrospectiveGlucoseEffect() - } catch let error { - logger.error("%{public}@", String(describing: error)) - warnings.append(.fetchDataWarning(.retrospectiveGlucoseEffect(error: error))) - } - } - - do { - try updateSuspendInsulinDeliveryEffect() - } catch let error { - logger.error("%{public}@", String(describing: error)) - } - - dosingDecision.appendWarnings(warnings.value) - - dosingDecision.date = now() - dosingDecision.historicalGlucose = historicalGlucose - dosingDecision.carbsOnBoard = carbsOnBoard - dosingDecision.insulinOnBoard = self.insulinOnBoard - dosingDecision.glucoseTargetRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule() - - // These will be updated by updatePredictedGlucoseAndRecommendedDose, if possible - dosingDecision.predictedGlucose = predictedGlucose - dosingDecision.automaticDoseRecommendation = recommendedAutomaticDose?.recommendation - - // If the glucose prediction hasn't changed, then nothing has changed, so just use pre-existing recommendations - guard predictedGlucose == nil else { - - // If we still have a bolus in progress, then warn (unlikely, but possible if device comms fail) - if lastRequestedBolus != nil, dosingDecision.automaticDoseRecommendation == nil, dosingDecision.manualBolusRecommendation == nil { - dosingDecision.appendWarning(.bolusInProgress) - } - - return (dosingDecision, nil) - } - - return updatePredictedGlucoseAndRecommendedDose(with: dosingDecision) - } - - private func notify(forChange context: LoopUpdateContext) { - NotificationCenter.default.post(name: .LoopDataUpdated, - object: self, - userInfo: [ - type(of: self).LoopUpdateContextKey: context.rawValue - ] - ) - } - - /// Computes amount of insulin from boluses that have been issued and not confirmed, and - /// remaining insulin delivery from temporary basal rate adjustments above scheduled rate - /// that are still in progress. - /// - /// - Returns: The amount of pending insulin, in units - /// - Throws: LoopError.configurationError - private func getPendingInsulin() throws -> Double { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - guard let basalRates = basalRateScheduleApplyingOverrideHistory else { - throw LoopError.configurationError(.basalRateSchedule) - } - - let pendingTempBasalInsulin: Double - let date = now() - - if let basalDeliveryState = basalDeliveryState, case .tempBasal(let lastTempBasal) = basalDeliveryState, lastTempBasal.endDate > date { - let normalBasalRate = basalRates.value(at: date) - let remainingTime = lastTempBasal.endDate.timeIntervalSince(date) - let remainingUnits = (lastTempBasal.unitsPerHour - normalBasalRate) * remainingTime.hours - - pendingTempBasalInsulin = max(0, remainingUnits) - } else { - pendingTempBasalInsulin = 0 - } - - let pendingBolusAmount: Double = lastRequestedBolus?.programmedUnits ?? 0 - - // All outstanding potential insulin delivery - return pendingTempBasalInsulin + pendingBolusAmount - } - - /// - Throws: - /// - LoopError.missingDataError - /// - LoopError.configurationError - /// - LoopError.glucoseTooOld - /// - LoopError.invalidFutureGlucose - /// - LoopError.pumpDataTooOld - fileprivate func predictGlucose( - startingAt startingGlucoseOverride: GlucoseValue? = nil, - using inputs: PredictionInputEffect, - historicalInsulinEffect insulinEffectOverride: [GlucoseEffect]? = nil, - insulinCounteractionEffects insulinCounteractionEffectsOverride: [GlucoseEffectVelocity]? = nil, - historicalCarbEffect carbEffectOverride: [GlucoseEffect]? = nil, - potentialBolus: DoseEntry? = nil, - potentialCarbEntry: NewCarbEntry? = nil, - replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil, - includingPendingInsulin: Bool = false, - includingPositiveVelocityAndRC: Bool = true - ) throws -> [PredictedGlucoseValue] { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - guard let glucose = startingGlucoseOverride ?? self.glucoseStore.latestGlucose else { - throw LoopError.missingDataError(.glucose) - } - - let pumpStatusDate = doseStore.lastAddedPumpData - let lastGlucoseDate = glucose.startDate - - guard now().timeIntervalSince(lastGlucoseDate) <= LoopAlgorithm.inputDataRecencyInterval else { - throw LoopError.glucoseTooOld(date: glucose.startDate) - } - - guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.futureGlucoseDataInterval else { - throw LoopError.invalidFutureGlucose(date: lastGlucoseDate) - } - - guard now().timeIntervalSince(pumpStatusDate) <= LoopAlgorithm.inputDataRecencyInterval else { - throw LoopError.pumpDataTooOld(date: pumpStatusDate) - } - - var momentum: [GlucoseEffect] = [] - var retrospectiveGlucoseEffect = self.retrospectiveGlucoseEffect - var effects: [[GlucoseEffect]] = [] - - let insulinCounteractionEffects = insulinCounteractionEffectsOverride ?? self.insulinCounteractionEffects - if inputs.contains(.carbs) { - if let potentialCarbEntry = potentialCarbEntry { - let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) - - if potentialCarbEntry.startDate > lastGlucoseDate || recentCarbEntries?.isEmpty != false, replacedCarbEntry == nil { - // The potential carb effect is independent and can be summed with the existing effect - if let carbEffect = carbEffectOverride ?? self.carbEffect { - effects.append(carbEffect) - } - - let potentialCarbEffect = try carbStore.glucoseEffects( - of: [potentialCarbEntry], - startingAt: retrospectiveStart, - endingAt: nil, - effectVelocities: insulinCounteractionEffects - ) - - effects.append(potentialCarbEffect) - } else { - var recentEntries = self.recentCarbEntries ?? [] - if let replacedCarbEntry = replacedCarbEntry, let index = recentEntries.firstIndex(of: replacedCarbEntry) { - recentEntries.remove(at: index) - } - - // If the entry is in the past or an entry is replaced, DCA and RC effects must be recomputed - var entries = recentEntries.map { NewCarbEntry(quantity: $0.quantity, startDate: $0.startDate, foodType: nil, absorptionTime: $0.absorptionTime) } - entries.append(potentialCarbEntry) - entries.sort(by: { $0.startDate > $1.startDate }) - - let potentialCarbEffect = try carbStore.glucoseEffects( - of: entries, - startingAt: retrospectiveStart, - endingAt: nil, - effectVelocities: insulinCounteractionEffects - ) - - effects.append(potentialCarbEffect) - - retrospectiveGlucoseEffect = computeRetrospectiveGlucoseEffect(startingAt: glucose, carbEffects: potentialCarbEffect) - } - } else if let carbEffect = carbEffectOverride ?? self.carbEffect { - effects.append(carbEffect) - } - } - - if inputs.contains(.insulin) { - let computationInsulinEffect: [GlucoseEffect]? - if insulinEffectOverride != nil { - computationInsulinEffect = insulinEffectOverride - } else { - computationInsulinEffect = includingPendingInsulin ? self.insulinEffectIncludingPendingInsulin : self.insulinEffect - } - - if let insulinEffect = computationInsulinEffect { - effects.append(insulinEffect) - } - - if let potentialBolus = potentialBolus { - guard let sensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { - throw LoopError.configurationError(.insulinSensitivitySchedule) - } - - let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) - let nextEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate - let bolusEffect = [potentialBolus] - .glucoseEffects(insulinModelProvider: doseStore.insulinModelProvider, longestEffectDuration: doseStore.longestEffectDuration, insulinSensitivity: sensitivity) - .filterDateRange(nextEffectDate, nil) - effects.append(bolusEffect) - } - } - - if inputs.contains(.momentum), let momentumEffect = self.glucoseMomentumEffect { - if !includingPositiveVelocityAndRC, let netMomentum = momentumEffect.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { - momentum = [] - } else { - momentum = momentumEffect - } - } - - if inputs.contains(.retrospection) { - if !includingPositiveVelocityAndRC, let netRC = retrospectiveGlucoseEffect.netEffect(), netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { - // positive RC is turned off - } else { - effects.append(retrospectiveGlucoseEffect) - } - } - - // Append effect of suspending insulin delivery when selected by the user on the Predicted Glucose screen (for information purposes only) - if inputs.contains(.suspend) { - effects.append(suspendInsulinDeliveryEffect) - } - - var prediction = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: effects) - - // Dosing requires prediction entries at least as long as the insulin model duration. - // If our prediction is shorter than that, then extend it here. - let finalDate = glucose.startDate.addingTimeInterval(doseStore.longestEffectDuration) - if let last = prediction.last, last.startDate < finalDate { - prediction.append(PredictedGlucoseValue(startDate: finalDate, quantity: last.quantity)) - } - - return prediction - } - - fileprivate func predictGlucoseFromManualGlucose( - _ glucose: NewGlucoseSample, - potentialBolus: DoseEntry?, - potentialCarbEntry: NewCarbEntry?, - replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, - includingPendingInsulin: Bool, - considerPositiveVelocityAndRC: Bool - ) throws -> [PredictedGlucoseValue] { - let retrospectiveStart = glucose.date.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) - let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) - let nextEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate - let insulinEffectStartDate = nextEffectDate.addingTimeInterval(.minutes(-5)) - - let updateGroup = DispatchGroup() - let effectCalculationError = Locked(nil) - - var insulinEffect: [GlucoseEffect]? - let basalDosingEnd = includingPendingInsulin ? nil : now() - updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: basalDosingEnd) { result in - switch result { - case .failure(let error): - effectCalculationError.mutate { $0 = error } - case .success(let effects): - insulinEffect = effects - } - - updateGroup.leave() - } - - updateGroup.wait() - - if let error = effectCalculationError.value { - throw error - } - - var insulinCounteractionEffects = self.insulinCounteractionEffects - if nextEffectDate < glucose.date, let insulinEffect = insulinEffect { - updateGroup.enter() - glucoseStore.getGlucoseSamples(start: nextEffectDate, end: nil) { result in - switch result { - case .failure(let error): - self.logger.error("Failure getting glucose samples: %{public}@", String(describing: error)) - case .success(let samples): - var samples = samples - let manualSample = StoredGlucoseSample(sample: glucose.quantitySample) - let insertionIndex = samples.partitioningIndex(where: { manualSample.startDate < $0.startDate }) - samples.insert(manualSample, at: insertionIndex) - let velocities = self.glucoseStore.counteractionEffects(for: samples, to: insulinEffect) - insulinCounteractionEffects.append(contentsOf: velocities) - } - insulinCounteractionEffects = insulinCounteractionEffects.filterDateRange(earliestEffectDate, nil) - - updateGroup.leave() - } - - updateGroup.wait() - } - - var carbEffect: [GlucoseEffect]? - updateGroup.enter() - carbStore.getGlucoseEffects( - start: retrospectiveStart, end: nil, - effectVelocities: insulinCounteractionEffects - ) { result in - switch result { - case .failure(let error): - effectCalculationError.mutate { $0 = error } - case .success(let (_, effects)): - carbEffect = effects - } - - updateGroup.leave() - } - - updateGroup.wait() - - if let error = effectCalculationError.value { - throw error - } - - return try predictGlucose( - startingAt: glucose.quantitySample, - using: [.insulin, .carbs], - historicalInsulinEffect: insulinEffect, - insulinCounteractionEffects: insulinCounteractionEffects, - historicalCarbEffect: carbEffect, - potentialBolus: potentialBolus, - potentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: replacedCarbEntry, - includingPendingInsulin: true, - includingPositiveVelocityAndRC: considerPositiveVelocityAndRC - ) - } - - fileprivate func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - guard lastRequestedBolus == nil else { - // Don't recommend changes if a bolus was just requested. - // Sending additional pump commands is not going to be - // successful in any case. - return nil - } - - let pendingInsulin = try getPendingInsulin() - let shouldIncludePendingInsulin = pendingInsulin > 0 - let prediction = try predictGlucoseFromManualGlucose(glucose, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) - return try recommendManualBolus(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) - } - - /// - Throws: LoopError.missingDataError - fileprivate func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - guard lastRequestedBolus == nil else { - // Don't recommend changes if a bolus was just requested. - // Sending additional pump commands is not going to be - // successful in any case. - return nil - } - - let pendingInsulin = try getPendingInsulin() - let shouldIncludePendingInsulin = pendingInsulin > 0 - let prediction = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - return try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) - } - - /// - Throws: - /// - LoopError.missingDataError - /// - LoopError.glucoseTooOld - /// - LoopError.invalidFutureGlucose - /// - LoopError.pumpDataTooOld - /// - LoopError.configurationError - fileprivate func recommendBolusValidatingDataRecency(forPrediction predictedGlucose: [Sample], - consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { - guard let glucose = glucoseStore.latestGlucose else { - throw LoopError.missingDataError(.glucose) - } - - let pumpStatusDate = doseStore.lastAddedPumpData - let lastGlucoseDate = glucose.startDate - - guard now().timeIntervalSince(lastGlucoseDate) <= LoopAlgorithm.inputDataRecencyInterval else { - throw LoopError.glucoseTooOld(date: glucose.startDate) - } - - guard lastGlucoseDate.timeIntervalSince(now()) <= LoopAlgorithm.inputDataRecencyInterval else { - throw LoopError.invalidFutureGlucose(date: lastGlucoseDate) - } - - guard now().timeIntervalSince(pumpStatusDate) <= LoopAlgorithm.inputDataRecencyInterval else { - throw LoopError.pumpDataTooOld(date: pumpStatusDate) - } - - guard glucoseMomentumEffect != nil else { - throw LoopError.missingDataError(.momentumEffect) - } - - guard carbEffect != nil else { - throw LoopError.missingDataError(.carbEffect) - } - - guard insulinEffect != nil else { - throw LoopError.missingDataError(.insulinEffect) - } - - return try recommendManualBolus(forPrediction: predictedGlucose, consideringPotentialCarbEntry: potentialCarbEntry) - } - - /// - Throws: LoopError.configurationError - private func recommendManualBolus(forPrediction predictedGlucose: [Sample], - consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { - guard let glucoseTargetRange = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) else { - throw LoopError.configurationError(.glucoseTargetRangeSchedule) - } - guard let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { - throw LoopError.configurationError(.insulinSensitivitySchedule) - } - guard let maxBolus = settings.maximumBolus else { - throw LoopError.configurationError(.maximumBolus) - } - - guard lastRequestedBolus == nil - else { - // Don't recommend changes if a bolus was just requested. - // Sending additional pump commands is not going to be - // successful in any case. - return nil - } - - let volumeRounder = { (_ units: Double) in - return self.delegate?.roundBolusVolume(units: units) ?? units - } - - let model = doseStore.insulinModelProvider.model(for: pumpInsulinType) - - var recommendation = predictedGlucose.recommendedManualBolus( - to: glucoseTargetRange, - at: now(), - suspendThreshold: settings.suspendThreshold?.quantity, - sensitivity: insulinSensitivity, - model: model, - maxBolus: maxBolus - ) - - // Round to pump precision - recommendation.amount = volumeRounder(recommendation.amount) - return recommendation - } - - /// 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) - - retrospectiveGlucoseEffect = retrospectiveCorrection.computeEffect( - startingAt: glucose, - retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, - recencyInterval: LoopAlgorithm.inputDataRecencyInterval, - retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval - ) - } - - private func computeRetrospectiveGlucoseEffect(startingAt glucose: GlucoseValue, carbEffects: [GlucoseEffect]) -> [GlucoseEffect] { - - let retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: carbStore.delta) - let retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) - return retrospectiveCorrection.computeEffect( - startingAt: glucose, - retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, - recencyInterval: LoopAlgorithm.inputDataRecencyInterval, - retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval - ) - } - - /// Generates a glucose prediction effect of suspending insulin delivery over duration of insulin action starting at current date - /// - /// - Throws: LoopError.configurationError - private func updateSuspendInsulinDeliveryEffect() throws { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - // Get settings, otherwise clear effect and throw error - guard - let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory - else { - suspendInsulinDeliveryEffect = [] - throw LoopError.configurationError(.insulinSensitivitySchedule) - } - guard - let basalRateSchedule = basalRateScheduleApplyingOverrideHistory - else { - suspendInsulinDeliveryEffect = [] - throw LoopError.configurationError(.basalRateSchedule) - } - - let insulinModel = doseStore.insulinModelProvider.model(for: pumpInsulinType) - let insulinActionDuration = insulinModel.effectDuration - - let startSuspend = now() - let endSuspend = startSuspend.addingTimeInterval(insulinActionDuration) - - var suspendDoses: [DoseEntry] = [] - let basalItems = basalRateSchedule.between(start: startSuspend, end: endSuspend) - - // Iterate over basal entries during suspension of insulin delivery - for (index, basalItem) in basalItems.enumerated() { - var startSuspendDoseDate: Date - var endSuspendDoseDate: Date - - if index == 0 { - startSuspendDoseDate = startSuspend - } else { - startSuspendDoseDate = basalItem.startDate - } - - if index == basalItems.count - 1 { - endSuspendDoseDate = endSuspend - } else { - endSuspendDoseDate = basalItems[index + 1].startDate - } - - let suspendDose = DoseEntry(type: .tempBasal, startDate: startSuspendDoseDate, endDate: endSuspendDoseDate, value: -basalItem.value, unit: DoseUnit.unitsPerHour) - - suspendDoses.append(suspendDose) - } - - // Calculate predicted glucose effect of suspending insulin delivery - suspendInsulinDeliveryEffect = suspendDoses.glucoseEffects(insulinModelProvider: doseStore.insulinModelProvider, longestEffectDuration: doseStore.longestEffectDuration, insulinSensitivity: insulinSensitivity).filterDateRange(startSuspend, endSuspend) - } - - /// Runs the glucose prediction on the latest effect data. - /// - /// - Throws: - /// - LoopError.configurationError - /// - LoopError.glucoseTooOld - /// - LoopError.invalidFutureGlucose - /// - LoopError.missingDataError - /// - LoopError.pumpDataTooOld - private func updatePredictedGlucoseAndRecommendedDose(with dosingDecision: StoredDosingDecision) -> (StoredDosingDecision, LoopError?) { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - var dosingDecision = dosingDecision - - self.logger.debug("Recomputing prediction and recommendations.") - - let startDate = now() - - guard let glucose = glucoseStore.latestGlucose else { - logger.error("Latest glucose missing") - dosingDecision.appendError(.missingDataError(.glucose)) - return (dosingDecision, .missingDataError(.glucose)) - } - - var errors = [LoopError]() - - if startDate.timeIntervalSince(glucose.startDate) > LoopAlgorithm.inputDataRecencyInterval { - errors.append(.glucoseTooOld(date: glucose.startDate)) - } - - if glucose.startDate.timeIntervalSince(startDate) > LoopAlgorithm.inputDataRecencyInterval { - errors.append(.invalidFutureGlucose(date: glucose.startDate)) - } - - let pumpStatusDate = doseStore.lastAddedPumpData - - if startDate.timeIntervalSince(pumpStatusDate) > LoopAlgorithm.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. + /// Adds and stores carb data, and recommends a bolus if needed /// - /// - 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) + /// - 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.clearOverride(matching: .preMeal) + return storedCarbEntry } -} - -extension LoopDataManager { - private struct LoopStateView: LoopState { + @discardableResult + func deleteCarbEntry(_ oldEntry: StoredCarbEntry) async throws -> Bool { + try await carbStore.deleteCarbEntry(oldEntry) + } - private let loopDataManager: LoopDataManager - private let updateError: LoopError? + /// 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, syncIdentifier: syncIdentifier, insulinType: insulinType, manuallyEntered: true) - init(loopDataManager: LoopDataManager, updateError: LoopError?) { - self.loopDataManager = loopDataManager - self.updateError = updateError + do { + try await doseStore.addDoses([dose], from: nil) + self.notify(forChange: .insulin) + } catch { + logger.error("Error storing manual dose: %{public}@", error.localizedDescription) } + } - var carbsOnBoard: CarbValue? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.carbsOnBoard - } - - var insulinOnBoard: InsulinValue? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.insulinOnBoard - } + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async { + let dosingDecision = StoredDosingDecision(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) + await dosingDecisionStore.storeDosingDecision(dosingDecision) + } - var error: LoopError? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return updateError ?? loopDataManager.lastLoopError - } + private func notify(forChange context: LoopUpdateContext) { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: self, + userInfo: [ + type(of: self).LoopUpdateContextKey: context.rawValue + ] + ) + } - var insulinCounteractionEffects: [GlucoseEffectVelocity] { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.insulinCounteractionEffects - } + /// 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 = LoopAlgorithm.insulinModelProvider.model(for: insulinType).effectDuration + let endSuspend = startSuspend.addingTimeInterval(insulinEffectDuration) - var predictedGlucose: [PredictedGlucoseValue]? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.predictedGlucose - } + var suspendDoses: [DoseEntry] = [] - var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.predictedGlucoseIncludingPendingInsulin - } + let basal = try await settingsProvider.getBasalHistory(startDate: startSuspend, endDate: endSuspend) + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: startSuspend, endDate: endSuspend) - var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - guard loopDataManager.lastRequestedBolus == nil else { - return nil - } - return loopDataManager.recommendedAutomaticDose - } + // Iterate over basal entries during suspension of insulin delivery + for (index, basalItem) in basal.enumerated() { + var startSuspendDoseDate: Date + var endSuspendDoseDate: Date - var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.retrospectiveGlucoseDiscrepanciesSummed - } + guard basalItem.endDate > startSuspend && basalItem.startDate < endSuspend else { + continue + } - var totalRetrospectiveCorrection: HKQuantity? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return loopDataManager.retrospectiveCorrection.totalGlucoseCorrectionEffect - } + if index == 0 { + startSuspendDoseDate = startSuspend + } else { + startSuspendDoseDate = basalItem.startDate + } - func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.predictGlucose(using: inputs, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: includingPendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - } + if index == basal.count - 1 { + endSuspendDoseDate = endSuspend + } else { + endSuspendDoseDate = basal[index + 1].startDate + } - func predictGlucoseFromManualGlucose( - _ glucose: NewGlucoseSample, - potentialBolus: DoseEntry?, - potentialCarbEntry: NewCarbEntry?, - replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, - includingPendingInsulin: Bool, - considerPositiveVelocityAndRC: Bool - ) throws -> [PredictedGlucoseValue] { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.predictGlucoseFromManualGlucose(glucose, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: includingPendingInsulin, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) - } + let suspendDose = DoseEntry(type: .tempBasal, startDate: startSuspendDoseDate, endDate: endSuspendDoseDate, value: -basalItem.value, unit: DoseUnit.unitsPerHour) - func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.recommendBolus(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) + suspendDoses.append(suspendDose) } - func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { - dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.recommendBolusForManualGlucose(glucose, consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) - } + // Calculate predicted glucose effect of suspending insulin delivery + return suspendDoses.glucoseEffects( + insulinModelProvider: LoopAlgorithm.insulinModelProvider, + insulinSensitivityHistory: sensitivity + ).filterDateRange(startSuspend, endSuspend) } - /// Executes a closure with access to the current state of the loop. - /// - /// This operation is performed asynchronously and the closure will be executed on an arbitrary background queue. - /// - /// - Parameter handler: A closure called when the state is ready - /// - Parameter manager: The loop manager - /// - Parameter state: The current state of the manager. This is invalid to access outside of the closure. - func getLoopState(_ handler: @escaping (_ manager: LoopDataManager, _ state: LoopState) -> Void) { - dataAccessQueue.async { - let (_, updateError) = self.update(for: .getLoopState) - - handler(self, LoopStateView(loopDataManager: self, updateError: updateError)) - } - } - - func generateSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { - + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { + var dosingDecision = BolusDosingDecision(for: .simpleBolus) - - var activeInsulin: Double? = nil - let semaphore = DispatchSemaphore(value: 0) - doseStore.insulinOnBoard(at: Date()) { (result) in - if case .success(let iobValue) = result { - activeInsulin = iobValue.value - dosingDecision.insulinOnBoard = iobValue - } - semaphore.signal() - } - semaphore.wait() - - guard let iob = activeInsulin, - let suspendThreshold = settings.suspendThreshold?.quantity, - let carbRatioSchedule = carbStore.carbRatioScheduleApplyingOverrideHistory, - let correctionRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: mealCarbs != nil), - let sensitivitySchedule = insulinSensitivityScheduleApplyingOverrideHistory + + guard let iob = displayState.activeInsulin?.value, + let suspendThreshold = settingsProvider.settings.suspendThreshold?.quantity, + let carbRatioSchedule = temporaryPresetsManager.carbRatioScheduleApplyingOverrideHistory, + let correctionRangeSchedule = temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: mealCarbs != nil), + let sensitivitySchedule = temporaryPresetsManager.insulinSensitivityScheduleApplyingOverrideHistory else { // Settings incomplete; should never get here; remove when therapy settings non-optional return nil } - - if let scheduleOverride = settings.scheduleOverride, !scheduleOverride.hasFinished() { - dosingDecision.scheduleOverride = settings.scheduleOverride + + if let scheduleOverride = temporaryPresetsManager.scheduleOverride, !scheduleOverride.hasFinished() { + dosingDecision.scheduleOverride = temporaryPresetsManager.scheduleOverride } dosingDecision.glucoseTargetRangeSchedule = correctionRangeSchedule - + var notice: BolusRecommendationNotice? = nil if let manualGlucose = manualGlucose { let glucoseValue = SimpleGlucoseValue(startDate: date, quantity: manualGlucose) @@ -2153,7 +778,7 @@ extension LoopDataManager { } } } - + let bolusAmount = SimpleBolusCalculator.recommendedInsulin( mealCarbs: mealCarbs, manualGlucose: manualGlucose, @@ -2162,169 +787,110 @@ extension LoopDataManager { correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule, at: date) - + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit()), notice: notice), date: Date()) - + return dosingDecision } + + } -extension LoopDataManager { - /// Generates a diagnostic report about the current state - /// - /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue. - /// - /// - parameter completion: A closure called once the report has been generated. The closure takes a single argument of the report string. - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { - getLoopState { (manager, state) in - - var entries: [String] = [ - "## LoopDataManager", - "settings: \(String(reflecting: manager.settings))", - - "insulinCounteractionEffects: [", - "* GlucoseEffectVelocity(start, end, mg/dL/min)", - manager.insulinCounteractionEffects.reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.endDate), \(entry.quantity.doubleValue(for: GlucoseEffectVelocity.unit))\n") - }), - "]", - - "insulinEffect: [", - "* GlucoseEffect(start, mg/dL)", - (manager.insulinEffect ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "carbEffect: [", - "* GlucoseEffect(start, mg/dL)", - (manager.carbEffect ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "predictedGlucose: [", - "* PredictedGlucoseValue(start, mg/dL)", - (state.predictedGlucoseIncludingPendingInsulin ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "integralRetrospectiveCorrectionEnabled: \(UserDefaults.standard.integralRetrospectiveCorrectionEnabled)", - - "retrospectiveGlucoseDiscrepancies: [", - "* GlucoseEffect(start, mg/dL)", - (manager.retrospectiveGlucoseDiscrepancies ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "retrospectiveGlucoseDiscrepanciesSummed: [", - "* GlucoseChange(start, end, mg/dL)", - (manager.retrospectiveGlucoseDiscrepanciesSummed ?? []).reduce(into: "", { (entries, entry) in - entries.append("* \(entry.startDate), \(entry.endDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") - }), - "]", - - "glucoseMomentumEffect: \(manager.glucoseMomentumEffect ?? [])", - "retrospectiveGlucoseEffect: \(manager.retrospectiveGlucoseEffect)", - "recommendedAutomaticDose: \(String(describing: state.recommendedAutomaticDose))", - "lastBolus: \(String(describing: manager.lastRequestedBolus))", - "lastLoopCompleted: \(String(describing: manager.lastLoopCompleted))", - "basalDeliveryState: \(String(describing: manager.basalDeliveryState))", - "carbsOnBoard: \(String(describing: state.carbsOnBoard))", - "insulinOnBoard: \(String(describing: manager.insulinOnBoard))", - "error: \(String(describing: state.error))", - "overrideInUserDefaults: \(String(describing: UserDefaults.appGroup?.intentExtensionOverrideToSet))", - "glucoseBasedApplicationFactorEnabled: \(UserDefaults.standard.glucoseBasedApplicationFactorEnabled)", - "", - String(reflecting: self.retrospectiveCorrection), - "", - ] +extension NewCarbEntry { + var asStoredCarbEntry: StoredCarbEntry { + StoredCarbEntry( + startDate: startDate, + quantity: quantity, + foodType: foodType, + absorptionTime: absorptionTime, + userCreatedDate: date + ) + } +} - self.glucoseStore.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - self.carbStore.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - self.doseStore.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - self.mealDetectionManager.generateDiagnosticReport { report in - entries.append(report) - entries.append("") - - UNUserNotificationCenter.current().generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - UIDevice.current.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - completion(entries.joined(separator: "\n")) - } - } - } - } - } - } - } +extension NewGlucoseSample { + var asStoredGlucoseStample: StoredGlucoseSample { + StoredGlucoseSample( + syncIdentifier: syncIdentifier, + syncVersion: syncVersion, + startDate: date, + quantity: quantity, + condition: condition, + trend: trend, + trendRate: trendRate, + isDisplayOnly: isDisplayOnly, + wasUserEntered: wasUserEntered, + device: device + ) } } -extension Notification.Name { - static let LoopDataUpdated = Notification.Name(rawValue: "com.loopkit.Loop.LoopDataUpdated") - static let LoopRunning = Notification.Name(rawValue: "com.loopkit.Loop.LoopRunning") - static let LoopCompleted = Notification.Name(rawValue: "com.loopkit.Loop.LoopCompleted") -} +extension LoopAlgorithmInput { -protocol LoopDataManagerDelegate: AnyObject { + func addingDose(dose: DoseEntry?) -> LoopAlgorithmInput { + 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: NewGlucoseSample?) -> LoopAlgorithmInput { + var rval = self + if let sample { + rval.glucoseHistory.append(sample.asStoredGlucoseStample) + } + 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: NewCarbEntry?) -> LoopAlgorithmInput { + var rval = self + if let carbEntry { + rval.carbEntries = carbEntries + [carbEntry.asStoredCarbEntry] + } + return rval + } + + func removingCarbEntry(carbEntry: StoredCarbEntry?) -> LoopAlgorithmInput { + 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, + 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 { @@ -2363,111 +929,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) } @@ -2476,19 +943,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) @@ -2529,7 +993,7 @@ extension LoopDataManager: ServicesManagerDelegate { let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) - let _ = try await devliverCarbEntry(candidateCarbEntry) + let _ = try await carbStore.addCarbEntry(candidateCarbEntry) } enum CarbActionError: LocalizedError { @@ -2566,19 +1030,203 @@ extension LoopDataManager: ServicesManagerDelegate { return formatter }() } +} + +extension LoopDataManager: SimpleBolusViewModelDelegate { + + func insulinOnBoard(at date: Date) async -> LoopKit.InsulinValue? { + displayState.activeInsulin + } + + var maximumBolus: Double? { + settingsProvider.settings.maximumBolus + } - //Can't add this concurrency wrapper method to LoopKit due to the minimum iOS version - func devliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { - return try await withCheckedThrowingContinuation { continuation in - carbStore.addCarbEntry(carbEntry) { result in - switch result { - case .success(let storedCarbEntry): - continuation.resume(returning: storedCarbEntry) - case .failure(let error): - continuation.resume(throwing: error) - } + var suspendThreshold: HKQuantity? { + settingsProvider.settings.suspendThreshold?.quantity + } + + func enactBolus(units: Double, activationType: BolusActivationType) async throws { + try await deliveryDelegate?.enactBolus(units: units, activationType: activationType) + } + +} + +extension LoopDataManager: BolusEntryViewModelDelegate { + func saveGlucose(sample: LoopKit.NewGlucoseSample) async throws -> LoopKit.StoredGlucoseSample { + let storedSamples = try await addGlucose([sample]) + return storedSamples.first! + } + + var preMealOverride: TemporaryScheduleOverride? { + temporaryPresetsManager.preMealOverride + } + + var mostRecentGlucoseDataDate: Date? { + displayState.input?.glucoseHistory.last?.startDate + } + + var mostRecentPumpDataDate: Date? { + return doseStore.lastAddedPumpData + } + + func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? { + temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: presumingMealEntry) + } + + func generatePrediction(input: LoopAlgorithmInput) throws -> [PredictedGlucoseValue] { + try input.predictGlucose() + } +} + + +extension LoopDataManager: CarbEntryViewModelDelegate { + func scheduleOverrideEnabled(at date: Date) -> Bool { + temporaryPresetsManager.scheduleOverrideEnabled(at: date) + } + + var defaultAbsorptionTimes: LoopKit.CarbStore.DefaultAbsorptionTimes { + carbStore.defaultAbsorptionTimes + } + +} + +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 LoopAlgorithm.insulinModelProvider.model(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: LoopAlgorithmInput, output: LoopAlgorithmOutput) { + self.historicalGlucose = input.glucoseHistory.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } + switch output.recommendationResult { + case .success(let recommendation): + self.automaticDoseRecommendation = recommendation.automatic + case .failure(let error): + self.appendError(error as? LoopError ?? .unknownError(error)) + } + if let activeInsulin = output.activeInsulin { + self.insulinOnBoard = InsulinValue(startDate: input.predictionStart, value: activeInsulin) + } + if let activeCarbs = output.activeCarbs { + self.carbsOnBoard = CarbValue(startDate: input.predictionStart, value: activeCarbs) + } + self.predictedGlucose = output.predictedGlucose + } +} + +enum CancelActiveTempBasalReason: String { + case automaticDosingDisabled + case unreliableCGMData + case maximumBasalRateChanged +} + +extension LoopDataManager : AlgorithmDisplayStateProvider { + var algorithmState: AlgorithmDisplayState { + return displayState + } +} + +extension LoopDataManager: DiagnosticReportGenerator { + func generateDiagnosticReport() async -> String { + let (algoInput, algoOutput) = displayState.asTuple + + var loopError: Error? + var doseRecommendation: LoopAlgorithmDoseRecommendation? + + if let algoOutput { + switch algoOutput.recommendationResult { + case .success(let recommendation): + doseRecommendation = recommendation + case .failure(let error): + loopError = error } } + + let entries: [String] = [ + "## LoopDataManager", + "settings: \(String(reflecting: settingsProvider.settings))", + + "insulinCounteractionEffects: [", + "* GlucoseEffectVelocity(start, end, mg/dL/min)", + (algoOutput?.effects.insulinCounteraction ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.endDate), \(entry.quantity.doubleValue(for: GlucoseEffectVelocity.unit))\n") + }), + "]", + + "insulinEffect: [", + "* GlucoseEffect(start, mg/dL)", + (algoOutput?.effects.insulin ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "carbEffect: [", + "* GlucoseEffect(start, mg/dL)", + (algoOutput?.effects.carbs ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "predictedGlucose: [", + "* PredictedGlucoseValue(start, mg/dL)", + (algoOutput?.predictedGlucose ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "integralRetrospectiveCorrectionEnabled: \(UserDefaults.standard.integralRetrospectiveCorrectionEnabled)", + + "retrospectiveCorrection: [", + "* GlucoseEffect(start, mg/dL)", + (algoOutput?.effects.retrospectiveCorrection ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "glucoseMomentumEffect: \(algoOutput?.effects.momentum ?? [])", + "recommendedAutomaticDose: \(String(describing: doseRecommendation))", + "lastLoopCompleted: \(String(describing: lastLoopCompleted))", + "carbsOnBoard: \(String(describing: algoOutput?.activeCarbs))", + "insulinOnBoard: \(String(describing: algoOutput?.activeInsulin))", + "error: \(String(describing: loopError))", + "overrideInUserDefaults: \(String(describing: UserDefaults.appGroup?.intentExtensionOverrideToSet))", + "glucoseBasedApplicationFactorEnabled: \(UserDefaults.standard.glucoseBasedApplicationFactorEnabled)", + "integralRetrospectiveCorrectionEanbled: \(String(describing: algoInput?.useIntegralRetrospectiveCorrection))", + "" + ] + return entries.joined(separator: "\n") + } - } + +extension LoopDataManager: LoopControl { } diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift index a3922a873a..bf000d3e95 100644 --- a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -11,21 +11,28 @@ import HealthKit import OSLog import LoopCore import LoopKit +import Combine enum MissedMealStatus: Equatable { case hasMissedMeal(startTime: Date, carbAmount: Double) case noMissedMeal } +protocol BolusStateProvider { + var bolusState: PumpManagerStatus.BolusState? { get } +} + +protocol AlgorithmDisplayStateProvider { + var algorithmState: AlgorithmDisplayState { get async } +} + +@MainActor class MealDetectionManager { private let log = OSLog(category: "MealDetectionManager") + // All math for meal detection occurs in mg/dL, with settings being converted if in mmol/L private let unit = HKUnit.milligramsPerDeciliter - public var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? - public var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? - public var maximumBolus: Double? - /// The last missed meal notification that was sent /// Internal for unit testing var lastMissedMealNotification: MissedMealNotification? = UserDefaults.standard.lastMissedMealNotification { @@ -40,46 +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,13 @@ 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)) + } +} + diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 996d147047..b91ab70614 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -19,6 +19,7 @@ enum NotificationManager { } } +@MainActor extension NotificationManager { private static var notificationCategories: Set { var categories = [UNNotificationCategory]() @@ -115,7 +116,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteBolusNotification(amount: Double) { let notification = UNMutableNotificationContent() let quantityFormatter = QuantityFormatter(for: .internationalUnit()) @@ -138,7 +138,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteBolusFailureNotification(for error: Error, amountInUnits: Double) { let notification = UNMutableNotificationContent() let quantityFormatter = QuantityFormatter(for: .internationalUnit()) @@ -159,7 +158,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteCarbEntryNotification(amountInGrams: Double) { let notification = UNMutableNotificationContent() @@ -180,7 +178,6 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } - @MainActor static func sendRemoteCarbEntryFailureNotification(for error: Error, amountInGrams: Double) { let notification = UNMutableNotificationContent() diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index b9f6c8c232..c8918a351d 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -11,6 +11,7 @@ import HealthKit import LoopKit import LoopKitUI +@MainActor class OnboardingManager { private let pluginManager: PluginManager private let bluetoothProvider: BluetoothProvider @@ -18,6 +19,7 @@ class OnboardingManager { private let statefulPluginManager: StatefulPluginManager private let servicesManager: ServicesManager private let loopDataManager: LoopDataManager + private let settingsManager: SettingsManager private let supportManager: SupportManager private weak var windowProvider: WindowProvider? private let userDefaults: UserDefaults @@ -43,6 +45,7 @@ class OnboardingManager { init(pluginManager: PluginManager, bluetoothProvider: BluetoothProvider, deviceDataManager: DeviceDataManager, + settingsManager: SettingsManager, statefulPluginManager: StatefulPluginManager, servicesManager: ServicesManager, loopDataManager: LoopDataManager, @@ -53,6 +56,7 @@ class OnboardingManager { self.pluginManager = pluginManager self.bluetoothProvider = bluetoothProvider self.deviceDataManager = deviceDataManager + self.settingsManager = settingsManager self.statefulPluginManager = statefulPluginManager self.servicesManager = servicesManager self.loopDataManager = loopDataManager @@ -62,9 +66,9 @@ class OnboardingManager { self.isSuspended = userDefaults.onboardingManagerIsSuspended - self.isComplete = userDefaults.onboardingManagerIsComplete && loopDataManager.therapySettings.isComplete + self.isComplete = userDefaults.onboardingManagerIsComplete && settingsManager.therapySettings.isComplete if !isComplete { - if loopDataManager.therapySettings.isComplete { + if settingsManager.therapySettings.isComplete { self.completedOnboardingIdentifiers = userDefaults.onboardingManagerCompletedOnboardingIdentifiers } if let activeOnboardingRawValue = userDefaults.onboardingManagerActiveOnboardingRawValue { @@ -255,12 +259,12 @@ extension OnboardingManager: OnboardingDelegate { func onboarding(_ onboarding: OnboardingUI, hasNewTherapySettings therapySettings: TherapySettings) { guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } - loopDataManager.therapySettings = therapySettings + settingsManager.therapySettings = therapySettings } func onboarding(_ onboarding: OnboardingUI, hasNewDosingEnabled dosingEnabled: Bool) { guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } - loopDataManager.mutateSettings { settings in + settingsManager.mutateLoopSettings { settings in settings.dosingEnabled = dosingEnabled } } @@ -395,6 +399,11 @@ extension OnboardingManager: PumpManagerProvider { guard let pumpManager = deviceDataManager.pumpManager else { return deviceDataManager.setupPumpManager(withIdentifier: identifier, initialSettings: settings, prefersToSkipUserInteraction: prefersToSkipUserInteraction) } + + guard let pumpManager = pumpManager as? PumpManagerUI else { + return .failure(OnboardingError.invalidState) + } + guard pumpManager.pluginIdentifier == identifier else { return .failure(OnboardingError.invalidState) } @@ -442,7 +451,7 @@ extension OnboardingManager: ServiceProvider { extension OnboardingManager: TherapySettingsProvider { var onboardingTherapySettings: TherapySettings { - return loopDataManager.therapySettings + return settingsManager.therapySettings } } diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index 296e3befa9..9f89aeb1a8 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -9,6 +9,7 @@ import os.log import Foundation import LoopKit +import UIKit enum RemoteDataType: String, CaseIterable { case alert = "Alert" @@ -37,6 +38,7 @@ struct UploadTaskKey: Hashable { } } +@MainActor final class RemoteDataServicesManager { public typealias RawState = [String: Any] @@ -126,7 +128,7 @@ final class RemoteDataServicesManager { private let doseStore: DoseStore - private let dosingDecisionStore: DosingDecisionStore + private let dosingDecisionStore: DosingDecisionStoreProtocol private let glucoseStore: GlucoseStore @@ -142,7 +144,7 @@ final class RemoteDataServicesManager { alertStore: AlertStore, carbStore: CarbStore, doseStore: DoseStore, - dosingDecisionStore: DosingDecisionStore, + dosingDecisionStore: DosingDecisionStoreProtocol, glucoseStore: GlucoseStore, cgmEventStore: CgmEventStore, settingsStore: SettingsStore, @@ -618,8 +620,10 @@ extension RemoteDataServicesManager { } } +extension RemoteDataServicesManager: UploadEventListener { } + protocol RemoteDataServicesManagerDelegate: AnyObject { - var shouldSyncToRemoteService: Bool {get} + var shouldSyncToRemoteService: Bool { get } } diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 2393ceb073..78867235b3 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 @@ -121,6 +122,10 @@ class ServicesManager { return servicesLock.withLock { services } } + public func getServices() -> [Service] { + return servicesLock.withLock { services } + } + public func addActiveService(_ service: Service) { servicesLock.withLock { service.serviceDelegate = self @@ -213,10 +218,10 @@ class ServicesManager { private func beginBackgroundTask(name: String) async -> UIBackgroundTaskIdentifier? { var backgroundTask: UIBackgroundTaskIdentifier? - backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: name) { + backgroundTask = UIApplication.shared.beginBackgroundTask(withName: name) { guard let backgroundTask = backgroundTask else {return} Task { - await UIApplication.shared.endBackgroundTask(backgroundTask) + UIApplication.shared.endBackgroundTask(backgroundTask) } self.log.error("Background Task Expired: %{public}@", name) @@ -227,7 +232,7 @@ class ServicesManager { private func endBackgroundTask(_ backgroundTask: UIBackgroundTaskIdentifier?) async { guard let backgroundTask else {return} - await UIApplication.shared.endBackgroundTask(backgroundTask) + UIApplication.shared.endBackgroundTask(backgroundTask) } } @@ -320,11 +325,11 @@ extension ServicesManager: ServiceDelegate { 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) + NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) await remoteDataServicesManager.triggerUpload(for: .carb) analyticsServicesManager.didAddCarbs(source: "Remote", amount: amountInGrams) } catch { - await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) + NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) throw error } } @@ -345,11 +350,11 @@ extension ServicesManager: ServiceDelegate { } try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) - await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) + NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) await remoteDataServicesManager.triggerUpload(for: .dose) analyticsServicesManager.didBolus(source: "Remote", units: amountInUnits) } catch { - await NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) + NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) throw error } } @@ -375,14 +380,20 @@ extension ServicesManager: ServiceDelegate { extension ServicesManager: AlertIssuer { func issueAlert(_ alert: Alert) { - alertManager.issueAlert(alert) + Task { @MainActor in + alertManager.issueAlert(alert) + } } func retractAlert(identifier: Alert.Identifier) { - alertManager.retractAlert(identifier: identifier) + Task { @MainActor in + alertManager.retractAlert(identifier: identifier) + } } } +extension ServicesManager: ActiveServicesProvider { } + // MARK: - ServiceOnboardingDelegate extension ServicesManager: ServiceOnboardingDelegate { diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index e3fdb60bf7..cbac8f6b2d 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -22,19 +22,22 @@ protocol DeviceStatusProvider { var cgmManagerStatus: CGMManagerStatus? { get } } +@MainActor class SettingsManager { let settingsStore: SettingsStore var remoteDataServicesManager: RemoteDataServicesManager? + var analyticsServicesManager: AnalyticsServicesManager? + var deviceStatusProvider: DeviceStatusProvider? var alertMuter: AlertMuter var displayGlucosePreference: DisplayGlucosePreference? - public var latestSettings: StoredSettings + public var settings: StoredSettings private var remoteNotificationRegistrationResult: Swift.Result? @@ -42,18 +45,26 @@ class SettingsManager { private let log = OSLog(category: "SettingsManager") - init(cacheStore: PersistenceController, expireAfter: TimeInterval, alertMuter: AlertMuter) + private var loopSettingsLock = UnfairLock() + + @Published private(set) var dosingEnabled: Bool + + init(cacheStore: PersistenceController, expireAfter: TimeInterval, alertMuter: AlertMuter, analyticsServicesManager: AnalyticsServicesManager? = nil) { + self.analyticsServicesManager = analyticsServicesManager + settingsStore = SettingsStore(store: cacheStore, expireAfter: expireAfter) self.alertMuter = alertMuter if let storedSettings = settingsStore.latestSettings { - latestSettings = storedSettings + settings = storedSettings } else { - log.default("SettingsStore has no latestSettings: initializing empty StoredSettings.") - latestSettings = StoredSettings() + log.default("SettingsStore has no settings: initializing empty StoredSettings.") + settings = StoredSettings() } + dosingEnabled = settings.dosingEnabled + settingsStore.delegate = self // Migrate old settings from UserDefaults @@ -69,20 +80,9 @@ class SettingsManager { UserDefaults.appGroup?.removeLegacyLoopSettings() } - NotificationCenter.default - .publisher(for: .LoopDataUpdated) - .receive(on: DispatchQueue.main) - .sink { [weak self] note in - let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue - if case .preferences = LoopDataManager.LoopUpdateContext(rawValue: context), let loopDataManager = note.object as? LoopDataManager { - self?.storeSettings(newLoopSettings: loopDataManager.settings) - } - } - .store(in: &cancellables) - self.alertMuter.$configuration .sink { [weak self] alertMuterConfiguration in - guard var notificationSettings = self?.latestSettings.notificationSettings else { return } + guard var notificationSettings = self?.settings.notificationSettings else { return } let newTemporaryMuteAlertsSetting = NotificationSettings.TemporaryMuteAlertSetting(enabled: alertMuterConfiguration.shouldMute, duration: alertMuterConfiguration.duration) if notificationSettings.temporaryMuteAlertsSetting != newTemporaryMuteAlertsSetting { notificationSettings.temporaryMuteAlertsSetting = newTemporaryMuteAlertsSetting @@ -95,21 +95,19 @@ class SettingsManager { var loopSettings: LoopSettings { get { return LoopSettings( - dosingEnabled: latestSettings.dosingEnabled, - glucoseTargetRangeSchedule: latestSettings.glucoseTargetRangeSchedule, - insulinSensitivitySchedule: latestSettings.insulinSensitivitySchedule, - basalRateSchedule: latestSettings.basalRateSchedule, - carbRatioSchedule: latestSettings.carbRatioSchedule, - preMealTargetRange: latestSettings.preMealTargetRange, - legacyWorkoutTargetRange: latestSettings.workoutTargetRange, - overridePresets: latestSettings.overridePresets, - scheduleOverride: latestSettings.scheduleOverride, - preMealOverride: latestSettings.preMealOverride, - maximumBasalRatePerHour: latestSettings.maximumBasalRatePerHour, - maximumBolus: latestSettings.maximumBolus, - suspendThreshold: latestSettings.suspendThreshold, - automaticDosingStrategy: latestSettings.automaticDosingStrategy, - defaultRapidActingModel: latestSettings.defaultRapidActingModel?.presetForRapidActingInsulin) + dosingEnabled: settings.dosingEnabled, + glucoseTargetRangeSchedule: settings.glucoseTargetRangeSchedule, + insulinSensitivitySchedule: settings.insulinSensitivitySchedule, + basalRateSchedule: settings.basalRateSchedule, + carbRatioSchedule: settings.carbRatioSchedule, + preMealTargetRange: settings.preMealTargetRange, + legacyWorkoutTargetRange: settings.workoutTargetRange, + overridePresets: settings.overridePresets, + maximumBasalRatePerHour: settings.maximumBasalRatePerHour, + maximumBolus: settings.maximumBolus, + suspendThreshold: settings.suspendThreshold, + automaticDosingStrategy: settings.automaticDosingStrategy, + defaultRapidActingModel: settings.defaultRapidActingModel?.presetForRapidActingInsulin) } } @@ -124,8 +122,6 @@ class SettingsManager { preMealTargetRange: newLoopSettings.preMealTargetRange, workoutTargetRange: newLoopSettings.legacyWorkoutTargetRange, overridePresets: newLoopSettings.overridePresets, - scheduleOverride: newLoopSettings.scheduleOverride, - preMealOverride: newLoopSettings.preMealOverride, maximumBasalRatePerHour: newLoopSettings.maximumBasalRatePerHour, maximumBolus: newLoopSettings.maximumBolus, suspendThreshold: newLoopSettings.suspendThreshold, @@ -153,40 +149,98 @@ class SettingsManager { let mergedSettings = mergeSettings(newLoopSettings: newLoopSettings, notificationSettings: notificationSettings, deviceToken: deviceTokenStr) - if latestSettings == mergedSettings { + guard settings != mergedSettings else { // Skipping unchanged settings store return } - latestSettings = mergedSettings + settings = mergedSettings if remoteNotificationRegistrationResult == nil && FeatureFlags.remoteCommandsEnabled { // remote notification registration not finished return } - if latestSettings.insulinSensitivitySchedule == nil { + if settings.insulinSensitivitySchedule == nil { log.default("Saving settings with no ISF schedule.") } - settingsStore.storeSettings(latestSettings) { error in + settingsStore.storeSettings(settings) { error in if let error = error { self.log.error("Error storing settings: %{public}@", error.localizedDescription) } } } + /// Sets a new time zone for a the schedule-based settings + /// + /// - Parameter timeZone: The time zone + func setScheduleTimeZone(_ timeZone: TimeZone) { + self.mutateLoopSettings { settings in + settings.basalRateSchedule?.timeZone = timeZone + settings.carbRatioSchedule?.timeZone = timeZone + settings.insulinSensitivitySchedule?.timeZone = timeZone + settings.glucoseTargetRangeSchedule?.timeZone = timeZone + } + } + + private func notify(forChange context: LoopUpdateContext) { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: self, + userInfo: [ + LoopDataManager.LoopUpdateContextKey: context.rawValue + ] + ) + } + + func mutateLoopSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { + loopSettingsLock.withLock { + let oldValue = loopSettings + var newValue = oldValue + changes(&newValue) + + guard oldValue != newValue else { + return + } + + storeSettings(newLoopSettings: newValue) + + if newValue.insulinSensitivitySchedule != oldValue.insulinSensitivitySchedule { + analyticsServicesManager?.didChangeInsulinSensitivitySchedule() + } + + if newValue.basalRateSchedule != oldValue.basalRateSchedule { + if let newValue = newValue.basalRateSchedule, let oldValue = oldValue.basalRateSchedule, newValue.items != oldValue.items { + analyticsServicesManager?.didChangeBasalRateSchedule() + } + } + + if newValue.carbRatioSchedule != oldValue.carbRatioSchedule { + analyticsServicesManager?.didChangeCarbRatioSchedule() + } + + if newValue.defaultRapidActingModel != oldValue.defaultRapidActingModel { + analyticsServicesManager?.didChangeInsulinModel() + } + + if newValue.dosingEnabled != oldValue.dosingEnabled { + self.dosingEnabled = newValue.dosingEnabled + } + } + notify(forChange: .preferences) + } + func storeSettingsCheckingNotificationPermissions() { UNUserNotificationCenter.current().getNotificationSettings() { notificationSettings in DispatchQueue.main.async { - guard let latestSettings = self.settingsStore.latestSettings else { + guard let settings = self.settingsStore.latestSettings else { return } let temporaryMuteAlertSetting = NotificationSettings.TemporaryMuteAlertSetting(enabled: self.alertMuter.configuration.shouldMute, duration: self.alertMuter.configuration.duration) let notificationSettings = NotificationSettings(notificationSettings, temporaryMuteAlertsSetting: temporaryMuteAlertSetting) - if notificationSettings != latestSettings.notificationSettings + if notificationSettings != settings.notificationSettings { self.storeSettings(notificationSettings: notificationSettings) } @@ -206,8 +260,77 @@ class SettingsManager { func purgeHistoricalSettingsObjects(completion: @escaping (Error?) -> Void) { settingsStore.purgeHistoricalSettingsObjects(completion: completion) } + + // MARK: Historical queries + + func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + try await settingsStore.getBasalHistory(startDate: startDate, endDate: endDate) + } + + func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + try await settingsStore.getCarbRatioHistory(startDate: startDate, endDate: endDate) + } + + func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + try await settingsStore.getInsulinSensitivityHistory(startDate: startDate, endDate: endDate) + } + + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] { + try await settingsStore.getTargetRangeHistory(startDate: startDate, endDate: endDate) + } + + func getDosingLimits(at date: Date) async throws -> DosingLimits { + try await settingsStore.getDosingLimits(at: date) + } + } +extension SettingsManager { + public var therapySettings: TherapySettings { + get { + let settings = self.settings + return TherapySettings(glucoseTargetRangeSchedule: settings.glucoseTargetRangeSchedule, + correctionRangeOverrides: CorrectionRangeOverrides(preMeal: settings.preMealTargetRange, workout: settings.workoutTargetRange), + overridePresets: settings.overridePresets, + maximumBasalRatePerHour: settings.maximumBasalRatePerHour, + maximumBolus: settings.maximumBolus, + suspendThreshold: settings.suspendThreshold, + insulinSensitivitySchedule: settings.insulinSensitivitySchedule, + carbRatioSchedule: settings.carbRatioSchedule, + basalRateSchedule: settings.basalRateSchedule, + defaultRapidActingModel: settings.defaultRapidActingModel?.presetForRapidActingInsulin) + } + + set { + mutateLoopSettings { settings in + settings.defaultRapidActingModel = newValue.defaultRapidActingModel + settings.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule + settings.carbRatioSchedule = newValue.carbRatioSchedule + settings.basalRateSchedule = newValue.basalRateSchedule + settings.glucoseTargetRangeSchedule = newValue.glucoseTargetRangeSchedule + settings.preMealTargetRange = newValue.correctionRangeOverrides?.preMeal + settings.legacyWorkoutTargetRange = newValue.correctionRangeOverrides?.workout + settings.suspendThreshold = newValue.suspendThreshold + settings.maximumBolus = newValue.maximumBolus + settings.maximumBasalRatePerHour = newValue.maximumBasalRatePerHour + settings.overridePresets = newValue.overridePresets ?? [] + } + } + } +} + +protocol SettingsProvider { + var settings: StoredSettings { get } + + func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] + func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] + func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] + func getDosingLimits(at date: Date) async throws -> DosingLimits +} + +extension SettingsManager: SettingsProvider {} + // MARK: - SettingsStoreDelegate extension SettingsManager: SettingsStoreDelegate { func settingsStoreHasUpdatedSettingsData(_ settingsStore: SettingsStore) { @@ -247,3 +370,5 @@ private extension NotificationSettings { ) } } + + diff --git a/Loop/Managers/StatefulPluginManager.swift b/Loop/Managers/StatefulPluginManager.swift index 22fc035b0c..9dfa3f0ede 100644 --- a/Loop/Managers/StatefulPluginManager.swift +++ b/Loop/Managers/StatefulPluginManager.swift @@ -11,12 +11,12 @@ import LoopKitUI import LoopCore import Combine +@MainActor class StatefulPluginManager: StatefulPluggableProvider { private let pluginManager: PluginManager private let servicesManager: ServicesManager - private var statefulPlugins = [StatefulPluggable]() private let statefulPluginLock = UnfairLock() @@ -123,3 +123,5 @@ extension StatefulPluginManager: StatefulPluggableDelegate { removeActiveStatefulPlugin(plugin) } } + +extension StatefulPluginManager: ActiveStatefulPluginsProvider { } diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index a7ffef2e5e..bf41a4d3fd 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -10,47 +10,17 @@ 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 } - + + func getCarbEntries(start: Date?, end: Date?) 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 + 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) + } extension CarbStore: CarbStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift index dd21ea2a1f..3bd2bcbdbb 100644 --- a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift @@ -10,52 +10,15 @@ import LoopKit import HealthKit protocol DoseStoreProtocol: AnyObject { - // MARK: settings - var basalProfile: LoopKit.BasalRateSchedule? { get set } + func getDoses(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 getTotalUnitsDelivered(since startDate: Date) async throws -> InsulinValue - func addReservoirValue(_ unitVolume: Double, at date: Date, completion: @escaping (_ value: ReservoirValue?, _ previousValue: ReservoirValue?, _ areStoredValuesContinuous: Bool, _ error: DoseStore.DoseStoreError?) -> Void) - - func getNormalizedDoseEntries(start: Date, end: Date?, completion: @escaping (_ result: DoseStoreResult<[DoseEntry]>) -> Void) - - func executePumpEventQuery(fromQueryAnchor queryAnchor: DoseStore.QueryAnchor?, limit: Int, completion: @escaping (DoseStore.PumpEventQueryResult) -> Void) - - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) - - func addDoses(_ doses: [DoseEntry], from device: HKDevice?, completion: @escaping (_ error: Error?) -> Void) - - // MARK: IOB and insulin effect - func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) - - func getGlucoseEffects(start: Date, end: Date?, basalDosingEnd: Date?, completion: @escaping (_ result: DoseStoreResult<[GlucoseEffect]>) -> Void) - - func getInsulinOnBoardValues(start: Date, end: Date? , basalDosingEnd: Date?, completion: @escaping (_ result: DoseStoreResult<[InsulinValue]>) -> Void) - - func getTotalUnitsDelivered(since startDate: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) - + var lastAddedPumpData: Date { get } } -extension DoseStore: DoseStoreProtocol { } +extension DoseStore: DoseStoreProtocol {} diff --git a/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift index 6ff38926f9..79ba9ca090 100644 --- a/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift @@ -8,8 +8,12 @@ import LoopKit -protocol DosingDecisionStoreProtocol: AnyObject { - func storeDosingDecision(_ dosingDecision: StoredDosingDecision, completion: @escaping () -> Void) +protocol DosingDecisionStoreProtocol: CriticalEventLog { + var delegate: DosingDecisionStoreDelegate? { get set } + + func storeDosingDecision(_ dosingDecision: StoredDosingDecision) async + + func executeDosingDecisionQuery(fromQueryAnchor queryAnchor: DosingDecisionStore.QueryAnchor?, limit: Int, completion: @escaping (DosingDecisionStore.DosingDecisionQueryResult) -> Void) } extension DosingDecisionStore: DosingDecisionStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift index adde73c4c7..8e15e5145f 100644 --- a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift @@ -10,30 +10,8 @@ import LoopKit import HealthKit 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 2111882e87..5e44909a8d 100644 --- a/Loop/Managers/SupportManager.swift +++ b/Loop/Managers/SupportManager.swift @@ -17,9 +17,10 @@ public protocol DeviceSupportDelegate { var pumpManagerStatus: LoopKit.PumpManagerStatus? { get } var cgmManagerStatus: LoopKit.CGMManagerStatus? { get } - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) + func generateDiagnosticReport() async -> String } +@MainActor public final class SupportManager { private lazy var log = DiagnosticLog(category: "SupportManager") @@ -91,7 +92,7 @@ public final class SupportManager { } .store(in: &cancellables) - NotificationCenter.default.publisher(for: .LoopCompleted) + NotificationCenter.default.publisher(for: .LoopCycleCompleted) .sink { [weak self] _ in self?.performCheck() } @@ -234,8 +235,8 @@ extension SupportManager: SupportUIDelegate { return Bundle.main.localizedNameAndVersion } - public func generateIssueReport(completion: @escaping (String) -> Void) { - deviceSupportDelegate.generateDiagnosticReport(completion) + public func generateIssueReport() async -> String { + await deviceSupportDelegate.generateDiagnosticReport() } public func issueAlert(_ alert: LoopKit.Alert) { diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift new file mode 100644 index 0000000000..c90463885d --- /dev/null +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -0,0 +1,283 @@ +// +// TemporaryPresetsManager.swift +// Loop +// +// Created by Pete Schwamb on 11/1/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import os.log +import LoopCore +import HealthKit + +protocol PresetActivationObserver: AnyObject { + func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) + func presetDeactivated(context: TemporaryScheduleOverride.Context) +} + +class TemporaryPresetsManager { + + private let log = OSLog(category: "TemporaryPresetsManager") + + private var settingsProvider: SettingsProvider + + var overrideHistory = UserDefaults.appGroup?.overrideHistory ?? TemporaryScheduleOverrideHistory.init() + + private var presetActivationObservers: [PresetActivationObserver] = [] + + private var overrideIntentObserver: NSKeyValueObservation? = nil + + init(settingsProvider: SettingsProvider) { + self.settingsProvider = settingsProvider + + self.overrideHistory.relevantTimeWindow = LoopCoreConstants.defaultCarbAbsorptionTimes.slow * 2 + + scheduleOverride = overrideHistory.activeOverride(at: Date()) + + // TODO: Pre-meal is not stored in overrideHistory yet. https://tidepool.atlassian.net/browse/LOOP-4759 + //preMealOverride = overrideHistory.preMealOverride + + overrideIntentObserver = UserDefaults.appGroup?.observe( + \.intentExtensionOverrideToSet, + options: [.new], + changeHandler: + { [weak self] (defaults, change) in + self?.handleIntentOverrideAction(default: defaults, change: change) + } + ) + } + + private func handleIntentOverrideAction(default: UserDefaults, change: NSKeyValueObservedChange) { + guard let name = change.newValue??.lowercased(), + let appGroup = UserDefaults.appGroup else + { + return + } + + guard let preset = settingsProvider.settings.overridePresets.first(where: {$0.name.lowercased() == name}) else + { + log.error("Override Intent: Unable to find override named '%s'", String(describing: name)) + return + } + + log.default("Override Intent: setting override named '%s'", String(describing: name)) + scheduleOverride = preset.createOverride(enactTrigger: .remote("Siri")) + + // Remove the override from UserDefaults so we don't set it multiple times + appGroup.intentExtensionOverrideToSet = nil + } + + public func addTemporaryPresetObserver(_ observer: PresetActivationObserver) { + presetActivationObservers.append(observer) + } + + public var scheduleOverride: TemporaryScheduleOverride? { + didSet { + guard oldValue != scheduleOverride else { + return + } + + if let newValue = scheduleOverride, newValue.context == .preMeal { + preconditionFailure("The `scheduleOverride` field should not be used for a pre-meal target range override; use `preMealOverride` instead") + } + + if scheduleOverride != oldValue { + overrideHistory.recordOverride(scheduleOverride) + + if let oldPreset = oldValue { + for observer in self.presetActivationObservers { + observer.presetDeactivated(context: oldPreset.context) + } + } + if let newPreset = scheduleOverride { + for observer in self.presetActivationObservers { + observer.presetActivated(context: newPreset.context, duration: newPreset.duration) + } + } + } + + if scheduleOverride?.context == .legacyWorkout { + preMealOverride = nil + } + + notify(forChange: .preferences) + } + } + + public var preMealOverride: TemporaryScheduleOverride? { + didSet { + guard oldValue != preMealOverride else { + return + } + + if let newValue = preMealOverride, newValue.context != .preMeal || newValue.settings.insulinNeedsScaleFactor != nil { + preconditionFailure("The `preMealOverride` field should be used only for a pre-meal target range override") + } + + if preMealOverride != nil, scheduleOverride?.context == .legacyWorkout { + scheduleOverride = nil + } + + notify(forChange: .preferences) + } + } + + public var isScheduleOverrideInfiniteWorkout: Bool { + guard let scheduleOverride = scheduleOverride else { return false } + return scheduleOverride.context == .legacyWorkout && scheduleOverride.duration.isInfinite + } + + public func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool = false) -> GlucoseRangeSchedule? { + + guard let glucoseTargetRangeSchedule = settingsProvider.settings.glucoseTargetRangeSchedule else { + return nil + } + + let preMealOverride = presumingMealEntry ? nil : self.preMealOverride + + let currentEffectiveOverride: TemporaryScheduleOverride? + switch (preMealOverride, scheduleOverride) { + case (let preMealOverride?, nil): + currentEffectiveOverride = preMealOverride + case (nil, let scheduleOverride?): + currentEffectiveOverride = scheduleOverride + case (let preMealOverride?, let scheduleOverride?): + currentEffectiveOverride = preMealOverride.scheduledEndDate > Date() + ? preMealOverride + : scheduleOverride + case (nil, nil): + currentEffectiveOverride = nil + } + + if let effectiveOverride = currentEffectiveOverride { + return glucoseTargetRangeSchedule.applyingOverride(effectiveOverride) + } else { + return glucoseTargetRangeSchedule + } + } + + public func scheduleOverrideEnabled(at date: Date = Date()) -> Bool { + return scheduleOverride?.isActive(at: date) == true + } + + public func nonPreMealOverrideEnabled(at date: Date = Date()) -> Bool { + return scheduleOverride?.isActive(at: date) == true + } + + public func preMealTargetEnabled(at date: Date = Date()) -> Bool { + return preMealOverride?.isActive(at: date) == true + } + + public func futureOverrideEnabled(relativeTo date: Date = Date()) -> Bool { + guard let scheduleOverride = scheduleOverride else { return false } + return scheduleOverride.startDate > date + } + + public func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { + preMealOverride = makePreMealOverride(beginningAt: date, for: duration) + } + + private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let preMealTargetRange = settingsProvider.settings.preMealTargetRange else { + return nil + } + return TemporaryScheduleOverride( + context: .preMeal, + settings: TemporaryScheduleOverrideSettings(targetRange: preMealTargetRange), + startDate: date, + duration: .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + public func enableLegacyWorkoutOverride(at date: Date = Date(), for duration: TimeInterval) { + scheduleOverride = legacyWorkoutOverride(beginningAt: date, for: duration) + preMealOverride = nil + } + + public func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let legacyWorkoutTargetRange = settingsProvider.settings.workoutTargetRange else { + return nil + } + + return TemporaryScheduleOverride( + context: .legacyWorkout, + settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), + startDate: date, + duration: duration.isInfinite ? .indefinite : .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + public func clearOverride(matching context: TemporaryScheduleOverride.Context? = nil) { + if context == .preMeal { + preMealOverride = nil + return + } + + guard let scheduleOverride = scheduleOverride else { return } + + if let context = context { + if scheduleOverride.context == context { + self.scheduleOverride = nil + } + } else { + self.scheduleOverride = nil + } + } + + public var basalRateScheduleApplyingOverrideHistory: BasalRateSchedule? { + if let basalSchedule = settingsProvider.settings.basalRateSchedule { + return overrideHistory.resolvingRecentBasalSchedule(basalSchedule) + } else { + return nil + } + } + + /// The insulin sensitivity schedule, applying recent overrides relative to the current moment in time. + public var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { + if let insulinSensitivitySchedule = settingsProvider.settings.insulinSensitivitySchedule { + return overrideHistory.resolvingRecentInsulinSensitivitySchedule(insulinSensitivitySchedule) + } else { + return nil + } + } + + public var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { + if let carbRatioSchedule = carbRatioSchedule { + return overrideHistory.resolvingRecentCarbRatioSchedule(carbRatioSchedule) + } else { + return nil + } + } + + private func notify(forChange context: LoopUpdateContext) { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: self, + userInfo: [ + LoopDataManager.LoopUpdateContextKey: context.rawValue + ] + ) + } + +} + +public protocol SettingsWithOverridesProvider { + var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { get } + var carbRatioSchedule: CarbRatioSchedule? { get } + var maximumBolus: Double? { get } +} + +extension TemporaryPresetsManager : SettingsWithOverridesProvider { + var carbRatioSchedule: LoopKit.CarbRatioSchedule? { + settingsProvider.settings.carbRatioSchedule + } + + var maximumBolus: Double? { + settingsProvider.settings.maximumBolus + } +} diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index 94ee1e609a..eff494ebd2 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) + } + } -// MARK: - TestingScenarioManager requirement implementations + 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") + } + + 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 ( @@ -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) { @@ -228,16 +271,15 @@ extension TestingScenariosManagerRequirements { return } - self.deviceManager.carbStore.addCarbEntries(instance.carbEntries) { result in - switch result { - case .success(_): + self.carbStore.addNewCarbEntries(entries: instance.carbEntries) { error in + if let error { + bail(with: error) + } else { 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) } } } @@ -253,9 +295,9 @@ extension TestingScenariosManagerRequirements { 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") } @@ -311,7 +353,7 @@ extension TestingScenariosManagerRequirements { return } - self.deviceManager.carbStore.deleteAllCarbEntries() { error in + self.carbStore.deleteAllCarbEntries() { error in guard error == nil else { completion(error!) return @@ -326,37 +368,9 @@ extension TestingScenariosManagerRequirements { private extension CarbStore { - /// Errors if adding any individual entry errors. - func addCarbEntries(_ entries: [NewCarbEntry], completion: @escaping (CarbStoreResult<[StoredCarbEntry]>) -> Void) { - addCarbEntries(entries[...], completion: completion) - } - - private func addCarbEntries(_ entries: ArraySlice, completion: @escaping (CarbStoreResult<[StoredCarbEntry]>) -> Void) { - guard let entry = entries.first else { - completion(.success([])) - return - } - - addCarbEntry(entry) { individualResult in - switch individualResult { - case .success(let entry): - let remainder = entries.dropFirst() - self.addCarbEntries(remainder) { collectiveResult in - switch collectiveResult { - case .success(let entries): - completion(.success([entry] + entries)) - case .failure(let error): - completion(.failure(error)) - } - } - case .failure(let error): - completion(.failure(error)) - } - } - } /// Errors if getting carb entries errors, or if deleting any individual entry errors. - func deleteAllCarbEntries(completion: @escaping (CarbStoreError?) -> Void) { + func deleteAllCarbEntries(completion: @escaping (Error?) -> Void) { getCarbEntries() { result in switch result { case .success(let entries): @@ -367,7 +381,7 @@ private extension CarbStore { } } - private func deleteCarbEntries(_ entries: ArraySlice, completion: @escaping (CarbStoreError?) -> Void) { + private func deleteCarbEntries(_ entries: ArraySlice, completion: @escaping (Error?) -> Void) { guard let entry = entries.first else { completion(nil) return diff --git a/Loop/Managers/TrustedTimeChecker.swift b/Loop/Managers/TrustedTimeChecker.swift index 4d627b9f8f..ee2704a09f 100644 --- a/Loop/Managers/TrustedTimeChecker.swift +++ b/Loop/Managers/TrustedTimeChecker.swift @@ -9,8 +9,9 @@ import LoopKit import TrueTime import UIKit +import Combine -fileprivate extension UserDefaults { +extension UserDefaults { private enum Key: String { case detectedSystemTimeOffset = "com.loopkit.Loop.DetectedSystemTimeOffset" } @@ -25,7 +26,12 @@ fileprivate extension UserDefaults { } } -class TrustedTimeChecker { +protocol TrustedTimeChecker { + var detectedSystemTimeOffset: TimeInterval { get } +} + +@MainActor +class LoopTrustedTimeChecker: TrustedTimeChecker { private let acceptableTimeDelta = TimeInterval.seconds(120) // For NTP time checking @@ -33,9 +39,15 @@ class TrustedTimeChecker { private weak var alertManager: AlertManager? private lazy var log = DiagnosticLog(category: "TrustedTimeChecker") + lazy private var cancellables = Set() + + nonisolated var detectedSystemTimeOffset: TimeInterval { - didSet { - UserDefaults.standard.detectedSystemTimeOffset = detectedSystemTimeOffset + get { + UserDefaults.standard.detectedSystemTimeOffset ?? 0 + } + set { + UserDefaults.standard.detectedSystemTimeOffset = newValue } } @@ -48,11 +60,23 @@ class TrustedTimeChecker { #endif ntpClient.start() self.alertManager = alertManager - self.detectedSystemTimeOffset = UserDefaults.standard.detectedSystemTimeOffset ?? 0 - NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, - object: nil, queue: nil) { [weak self] _ in self?.checkTrustedTime() } - NotificationCenter.default.addObserver(forName: .LoopRunning, - object: nil, queue: nil) { [weak self] _ in self?.checkTrustedTime() } + + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .sink { [weak self] _ in + Task { + self?.checkTrustedTime() + } + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .LoopRunning) + .sink { [weak self] _ in + Task { + self?.checkTrustedTime() + } + } + .store(in: &cancellables) + checkTrustedTime() } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index bac60b71dc..dc0997b791 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -12,19 +12,41 @@ import WatchConnectivity import LoopKit import LoopCore +@MainActor final class WatchDataManager: NSObject { private unowned let deviceManager: DeviceDataManager - - init(deviceManager: DeviceDataManager, healthStore: HKHealthStore) { + private unowned let settingsManager: SettingsManager + private unowned let loopDataManager: LoopDataManager + private unowned let carbStore: CarbStore + private unowned let glucoseStore: GlucoseStore + private unowned let analyticsServicesManager: AnalyticsServicesManager? + private unowned let temporaryPresetsManager: TemporaryPresetsManager + + init( + deviceManager: DeviceDataManager, + settingsManager: SettingsManager, + loopDataManager: LoopDataManager, + carbStore: CarbStore, + glucoseStore: GlucoseStore, + analyticsServicesManager: AnalyticsServicesManager?, + temporaryPresetsManager: TemporaryPresetsManager, + healthStore: HKHealthStore + ) { self.deviceManager = deviceManager + self.settingsManager = settingsManager + self.loopDataManager = loopDataManager + self.carbStore = carbStore + self.glucoseStore = glucoseStore + self.analyticsServicesManager = analyticsServicesManager + self.temporaryPresetsManager = temporaryPresetsManager self.sleepStore = SleepStore(healthStore: healthStore) self.lastBedtimeQuery = UserDefaults.appGroup?.lastBedtimeQuery ?? .distantPast self.bedtime = UserDefaults.appGroup?.bedtime super.init() - NotificationCenter.default.addObserver(self, selector: #selector(updateWatch(_:)), name: .LoopDataUpdated, object: deviceManager.loopManager) + NotificationCenter.default.addObserver(self, selector: #selector(updateWatch(_:)), name: .LoopDataUpdated, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sendSupportedBolusVolumesIfNeeded), name: .PumpManagerChanged, object: deviceManager) watchSession?.delegate = self @@ -41,7 +63,7 @@ final class WatchDataManager: NSObject { } }() - private var lastSentSettings: LoopSettings? + private var lastSentUserInfo: LoopSettingsUserInfo? private var lastSentBolusVolumes: [Double]? private var contextDosingDecisions: [Date: BolusDosingDecision] { @@ -100,8 +122,8 @@ final class WatchDataManager: NSObject { @objc private func updateWatch(_ notification: Notification) { guard - let rawUpdateContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, - let updateContext = LoopDataManager.LoopUpdateContext(rawValue: rawUpdateContext) + let rawUpdateContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopUpdateContext.RawValue, + let updateContext = LoopUpdateContext(rawValue: rawUpdateContext) else { return } @@ -120,7 +142,10 @@ final class WatchDataManager: NSObject { private lazy var minTrendUnit = HKUnit.milligramsPerDeciliter private func sendSettingsIfNeeded() { - let settings = deviceManager.loopManager.settings + let userInfo = LoopSettingsUserInfo( + loopSettings: settingsManager.loopSettings, + scheduleOverride: temporaryPresetsManager.scheduleOverride, + preMealOverride: temporaryPresetsManager.preMealOverride) guard let session = watchSession, session.isPaired, session.isWatchAppInstalled else { return @@ -131,12 +156,11 @@ final class WatchDataManager: NSObject { return } - guard settings != lastSentSettings else { - log.default("Skipping settings transfer due to no changes") + guard userInfo != lastSentUserInfo else { return } - lastSentSettings = settings + lastSentUserInfo = userInfo // clear any old pending settings transfers for transfer in session.outstandingUserInfoTransfers { @@ -146,9 +170,9 @@ final class WatchDataManager: NSObject { } } - let userInfo = LoopSettingsUserInfo(settings: settings).rawValue - log.default("Transferring LoopSettingsUserInfo: %{public}@", userInfo) - session.transferUserInfo(userInfo) + let rawUserInfo = userInfo.rawValue + log.default("Transferring LoopSettingsUserInfo: %{public}@", rawUserInfo) + session.transferUserInfo(rawUserInfo) } @objc private func sendSupportedBolusVolumesIfNeeded() { @@ -167,7 +191,6 @@ final class WatchDataManager: NSObject { } guard volumes != lastSentBolusVolumes else { - log.default("Skipping bolus volumes transfer due to no changes") return } @@ -187,7 +210,8 @@ final class WatchDataManager: NSObject { return } - createWatchContext { (context) in + Task { @MainActor in + let context = await createWatchContext() self.sendWatchContext(context) } } @@ -231,131 +255,116 @@ final class WatchDataManager: NSObject { } } - private func createWatchContext(recommendingBolusFor potentialCarbEntry: NewCarbEntry? = nil, _ completion: @escaping (_ context: WatchContext) -> Void) { + @MainActor + private func createWatchContext(recommendingBolusFor potentialCarbEntry: NewCarbEntry? = nil) async -> WatchContext { var dosingDecision = BolusDosingDecision(for: .watchBolus) - let loopManager = deviceManager.loopManager! - - let glucose = deviceManager.glucoseStore.latestGlucose - let reservoir = deviceManager.doseStore.lastReservoirValue + let glucose = loopDataManager.latestGlucose + let reservoir = loopDataManager.lastReservoirValue let basalDeliveryState = deviceManager.pumpManager?.status.basalDeliveryState - loopManager.getLoopState { (manager, state) in - let updateGroup = DispatchGroup() + let (_, algoOutput) = loopDataManager.displayState.asTuple - let carbsOnBoard = state.carbsOnBoard + let carbsOnBoard = loopDataManager.activeCarbs - let context = WatchContext(glucose: glucose, glucoseUnit: self.deviceManager.preferredGlucoseUnit) - context.reservoir = reservoir?.unitVolume - context.loopLastRunDate = manager.lastLoopCompleted - context.cob = carbsOnBoard?.quantity.doubleValue(for: HKUnit.gram()) + let context = WatchContext(glucose: glucose, glucoseUnit: self.deviceManager.displayGlucosePreference.unit) + context.reservoir = reservoir?.unitVolume + context.loopLastRunDate = loopDataManager.lastLoopCompleted + context.cob = carbsOnBoard?.quantity.doubleValue(for: HKUnit.gram()) - if let glucoseDisplay = self.deviceManager.glucoseDisplay(for: glucose) { - context.glucoseTrend = glucoseDisplay.trendType - context.glucoseTrendRate = glucoseDisplay.trendRate - } + if let glucoseDisplay = self.deviceManager.glucoseDisplay(for: glucose) { + context.glucoseTrend = glucoseDisplay.trendType + context.glucoseTrendRate = glucoseDisplay.trendRate + } - dosingDecision.carbsOnBoard = carbsOnBoard + dosingDecision.carbsOnBoard = carbsOnBoard - context.cgmManagerState = self.deviceManager.cgmManager?.rawValue - - let settings = self.deviceManager.loopManager.settings + context.cgmManagerState = self.deviceManager.cgmManager?.rawValue - context.isClosedLoop = settings.dosingEnabled + let settings = self.settingsManager.loopSettings - context.potentialCarbEntry = potentialCarbEntry - if let recommendedBolus = try? state.recommendBolus(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses) - { - context.recommendedBolusDose = recommendedBolus.amount - dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: recommendedBolus, - date: Date()) - } + context.isClosedLoop = settings.dosingEnabled - var historicalGlucose: [HistoricalGlucoseValue]? - if let glucose = glucose { - updateGroup.enter() - let historicalGlucoseStartDate = Date(timeIntervalSinceNow: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval) - self.deviceManager.glucoseStore.getGlucoseSamples(start: min(historicalGlucoseStartDate, glucose.startDate), end: nil) { (result) in - var sample: StoredGlucoseSample? - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - sample = nil - case .success(let samples): - sample = samples.last - historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } - } - context.glucose = sample?.quantity - context.glucoseDate = sample?.startDate - context.glucoseIsDisplayOnly = sample?.isDisplayOnly - context.glucoseWasUserEntered = sample?.wasUserEntered - context.glucoseSyncIdentifier = sample?.syncIdentifier - updateGroup.leave() - } - } + context.potentialCarbEntry = potentialCarbEntry - var insulinOnBoard: InsulinValue? - updateGroup.enter() - self.deviceManager.doseStore.insulinOnBoard(at: Date()) { (result) in - switch result { - case .success(let iobValue): - context.iob = iobValue.value - insulinOnBoard = iobValue - case .failure: - context.iob = nil - } - updateGroup.leave() - } + if let recommendedBolus = try? await loopDataManager.recommendManualBolus( + manualGlucoseSample: nil, + potentialCarbEntry: potentialCarbEntry, + originalCarbEntry: nil + ) { + context.recommendedBolusDose = recommendedBolus.amount + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate( + recommendation: recommendedBolus, + date: Date()) + } - _ = updateGroup.wait(timeout: .distantFuture) + var historicalGlucose: [HistoricalGlucoseValue]? - dosingDecision.historicalGlucose = historicalGlucose - dosingDecision.insulinOnBoard = insulinOnBoard + if let glucose = glucose { + var sample: StoredGlucoseSample? - if let basalDeliveryState = basalDeliveryState, - let basalSchedule = manager.basalRateScheduleApplyingOverrideHistory, - let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, settings: manager.settings) - { - context.lastNetTempBasalDose = netBasal.rate + let historicalGlucoseStartDate = Date(timeIntervalSinceNow: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval) + if let input = loopDataManager.displayState.input { + let start = min(historicalGlucoseStartDate, glucose.startDate) + let samples = input.glucoseHistory.filterDateRange(start, nil) + sample = samples.last + historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } } + context.glucose = sample?.quantity + context.glucoseDate = sample?.startDate + context.glucoseIsDisplayOnly = sample?.isDisplayOnly + context.glucoseWasUserEntered = sample?.wasUserEntered + context.glucoseSyncIdentifier = sample?.syncIdentifier + } - if let predictedGlucose = state.predictedGlucoseIncludingPendingInsulin { - // Drop the first element in predictedGlucose because it is the current glucose - let filteredPredictedGlucose = predictedGlucose.dropFirst() - if filteredPredictedGlucose.count > 0 { - context.predictedGlucose = WatchPredictedGlucose(values: Array(filteredPredictedGlucose)) - } - } + context.iob = loopDataManager.activeInsulin?.value - dosingDecision.predictedGlucose = state.predictedGlucoseIncludingPendingInsulin ?? state.predictedGlucose + dosingDecision.historicalGlucose = historicalGlucose + dosingDecision.insulinOnBoard = loopDataManager.activeInsulin - var preMealOverride = settings.preMealOverride - if preMealOverride?.hasFinished() == true { - preMealOverride = nil - } + if let basalDeliveryState = basalDeliveryState, + let basalSchedule = self.temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory, + let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, maximumBasalRatePerHour: self.settingsManager.settings.maximumBasalRatePerHour) + { + context.lastNetTempBasalDose = netBasal.rate + } - var scheduleOverride = settings.scheduleOverride - if scheduleOverride?.hasFinished() == true { - scheduleOverride = nil + if let predictedGlucose = algoOutput?.predictedGlucose { + // Drop the first element in predictedGlucose because it is the current glucose + let filteredPredictedGlucose = predictedGlucose.dropFirst() + if filteredPredictedGlucose.count > 0 { + context.predictedGlucose = WatchPredictedGlucose(values: Array(filteredPredictedGlucose)) } + } - dosingDecision.scheduleOverride = scheduleOverride + dosingDecision.predictedGlucose = algoOutput?.predictedGlucose - if scheduleOverride != nil || preMealOverride != nil { - dosingDecision.glucoseTargetRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) - } else { - dosingDecision.glucoseTargetRangeSchedule = settings.glucoseTargetRangeSchedule - } + var preMealOverride = self.temporaryPresetsManager.preMealOverride + if preMealOverride?.hasFinished() == true { + preMealOverride = nil + } - // Remove any expired context dosing decisions and add new - self.contextDosingDecisions = self.contextDosingDecisions.filter { (date, _) in date.timeIntervalSinceNow > self.contextDosingDecisionExpirationDuration } - self.contextDosingDecisions[context.creationDate] = dosingDecision + var scheduleOverride = self.temporaryPresetsManager.scheduleOverride + if scheduleOverride?.hasFinished() == true { + scheduleOverride = nil + } - completion(context) + dosingDecision.scheduleOverride = scheduleOverride + + if scheduleOverride != nil || preMealOverride != nil { + dosingDecision.glucoseTargetRangeSchedule = self.temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) + } else { + dosingDecision.glucoseTargetRangeSchedule = settings.glucoseTargetRangeSchedule } + + // Remove any expired context dosing decisions and add new + self.contextDosingDecisions = self.contextDosingDecisions.filter { (date, _) in date.timeIntervalSinceNow > self.contextDosingDecisionExpirationDuration } + self.contextDosingDecisions[context.creationDate] = dosingDecision + + return context } - private func addCarbEntryAndBolusFromWatchMessage(_ message: [String: Any]) { + private func addCarbEntryAndBolusFromWatchMessage(_ message: [String: Any]) async throws { guard let bolus = SetBolusUserInfo(rawValue: message as SetBolusUserInfo.RawValue) else { log.error("Could not enact bolus from from unknown message: %{public}@", String(describing: message)) return @@ -374,43 +383,30 @@ final class WatchDataManager: NSObject { dosingDecision = BolusDosingDecision(for: .watchBolus) // The user saved without waiting for recommendation (no bolus) } - func enactBolus() { - dosingDecision.manualBolusRequested = bolus.value - deviceManager.loopManager.storeManualBolusDosingDecision(dosingDecision, withDate: bolus.startDate) - - guard bolus.value > 0 else { - // Ensure active carbs is updated in the absence of a bolus - sendWatchContextIfNeeded() - return - } - - deviceManager.enactBolus(units: bolus.value, activationType: bolus.activationType) { (error) in - if error == nil { - self.deviceManager.analyticsServicesManager.didBolus(source: "Watch", units: bolus.value) - } - - // When we've successfully started the bolus, send a new context with our new prediction - self.sendWatchContextIfNeeded() - - self.deviceManager.loopManager.updateRemoteRecommendation() - } - } - if let carbEntry = bolus.carbEntry { - deviceManager.loopManager.addCarbEntry(carbEntry) { (result) in - switch result { - case .success(let storedCarbEntry): - dosingDecision.carbEntry = storedCarbEntry - self.deviceManager.analyticsServicesManager.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) - enactBolus() - case .failure(let error): - self.log.error("%{public}@", String(describing: error)) - } - } + let storedCarbEntry = try await loopDataManager.addCarbEntry(carbEntry) + dosingDecision.carbEntry = storedCarbEntry + self.analyticsServicesManager?.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) } else { dosingDecision.carbEntry = nil - enactBolus() } + + dosingDecision.manualBolusRequested = bolus.value + await loopDataManager.storeManualBolusDosingDecision(dosingDecision, withDate: bolus.startDate) + + guard bolus.value > 0 else { + // Ensure active carbs is updated in the absence of a bolus + sendWatchContextIfNeeded() + return + } + + do { + try await deviceManager.enactBolus(units: bolus.value, activationType: bolus.activationType) + self.analyticsServicesManager?.didBolus(source: "Watch", units: bolus.value) + } catch { } + + // When we've started the bolus, send a new context with our new prediction + self.sendWatchContextIfNeeded() } } @@ -420,7 +416,8 @@ extension WatchDataManager: WCSessionDelegate { switch message["name"] as? String { case PotentialCarbEntryUserInfo.name?: if let potentialCarbEntry = PotentialCarbEntryUserInfo(rawValue: message)?.carbEntry { - self.createWatchContext(recommendingBolusFor: potentialCarbEntry) { (context) in + Task { @MainActor in + let context = await createWatchContext(recommendingBolusFor: potentialCarbEntry) replyHandler(context.rawValue) } } else { @@ -429,31 +426,31 @@ extension WatchDataManager: WCSessionDelegate { } case SetBolusUserInfo.name?: // Add carbs if applicable; start the bolus and reply when it's successfully requested - addCarbEntryAndBolusFromWatchMessage(message) - + Task { @MainActor in + try await addCarbEntryAndBolusFromWatchMessage(message) + } // Reply immediately replyHandler([:]) + case LoopSettingsUserInfo.name?: - if let watchSettings = LoopSettingsUserInfo(rawValue: message)?.settings { + if let userInfo = LoopSettingsUserInfo(rawValue: message) { // So far we only support watch changes of temporary schedule overrides - var loopSettings = deviceManager.loopManager.settings - loopSettings.preMealOverride = watchSettings.preMealOverride - loopSettings.scheduleOverride = watchSettings.scheduleOverride + temporaryPresetsManager.preMealOverride = userInfo.preMealOverride + temporaryPresetsManager.scheduleOverride = userInfo.scheduleOverride // Prevent re-sending these updated settings back to the watch - lastSentSettings = loopSettings - deviceManager.loopManager.mutateSettings { settings in - settings = loopSettings - } + lastSentUserInfo?.preMealOverride = userInfo.preMealOverride + lastSentUserInfo?.scheduleOverride = userInfo.scheduleOverride } // Since target range affects recommended bolus, send back a new one - createWatchContext { (context) in + Task { @MainActor in + let context = await createWatchContext() replyHandler(context.rawValue) } case CarbBackfillRequestUserInfo.name?: if let userInfo = CarbBackfillRequestUserInfo(rawValue: message) { - deviceManager.carbStore.getSyncCarbObjects(start: userInfo.startDate) { (result) in + carbStore.getSyncCarbObjects(start: userInfo.startDate) { (result) in switch result { case .failure(let error): self.log.error("%{public}@", String(describing: error)) @@ -467,7 +464,7 @@ extension WatchDataManager: WCSessionDelegate { } case GlucoseBackfillRequestUserInfo.name?: if let userInfo = GlucoseBackfillRequestUserInfo(rawValue: message) { - deviceManager.glucoseStore.getSyncGlucoseSamples(start: userInfo.startDate.addingTimeInterval(1)) { (result) in + 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)) @@ -480,8 +477,8 @@ extension WatchDataManager: WCSessionDelegate { replyHandler([:]) } case WatchContextRequestUserInfo.name?: - self.createWatchContext { (context) in - // Send back the updated prediction and recommended bolus + Task { @MainActor in + let context = await createWatchContext() replyHandler(context.rawValue) } default: @@ -517,12 +514,12 @@ extension WatchDataManager: WCSessionDelegate { // This might be useless, as userInfoTransfer.userInfo seems to be nil when error is non-nil. switch userInfoTransfer.userInfo["name"] as? String { case nil: - lastSentSettings = nil + lastSentUserInfo = nil sendSettingsIfNeeded() lastSentBolusVolumes = nil sendSupportedBolusVolumesIfNeeded() case LoopSettingsUserInfo.name: - lastSentSettings = nil + lastSentUserInfo = nil sendSettingsIfNeeded() case SupportedBolusVolumesUserInfo.name: lastSentBolusVolumes = nil @@ -538,7 +535,7 @@ extension WatchDataManager: WCSessionDelegate { } func sessionDidDeactivate(_ session: WCSession) { - lastSentSettings = nil + lastSentUserInfo = nil watchSession = WCSession.default watchSession?.delegate = self watchSession?.activate() @@ -555,7 +552,7 @@ extension WatchDataManager { override var debugDescription: String { var items = [ "## WatchDataManager", - "lastSentSettings: \(String(describing: lastSentSettings))", + "lastSentUserInfo: \(String(describing: lastSentUserInfo))", "lastComplicationContext: \(String(describing: lastComplicationContext))", "lastBedtimeQuery: \(String(describing: lastBedtimeQuery))", "bedtime: \(String(describing: bedtime))", diff --git a/Loop/Models/ApplicationFactorStrategy.swift b/Loop/Models/ApplicationFactorStrategy.swift index bf67935c4e..d3244ec1c2 100644 --- a/Loop/Models/ApplicationFactorStrategy.swift +++ b/Loop/Models/ApplicationFactorStrategy.swift @@ -14,7 +14,6 @@ import LoopCore protocol ApplicationFactorStrategy { func calculateDosingFactor( for glucose: HKQuantity, - correctionRangeSchedule: GlucoseRangeSchedule, - settings: LoopSettings + correctionRange: ClosedRange ) -> Double } diff --git a/Loop/Models/ConstantApplicationFactorStrategy.swift b/Loop/Models/ConstantApplicationFactorStrategy.swift index 7489367cae..0ef8dc1d13 100644 --- a/Loop/Models/ConstantApplicationFactorStrategy.swift +++ b/Loop/Models/ConstantApplicationFactorStrategy.swift @@ -14,10 +14,9 @@ import LoopCore struct ConstantApplicationFactorStrategy: ApplicationFactorStrategy { func calculateDosingFactor( for glucose: HKQuantity, - correctionRangeSchedule: GlucoseRangeSchedule, - settings: LoopSettings + correctionRange: ClosedRange ) -> Double { // The original strategy uses a constant dosing factor. - return LoopAlgorithm.bolusPartialApplicationFactor + return LoopAlgorithm.defaultBolusPartialApplicationFactor } } diff --git a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift index 41caa3d773..7f03337011 100644 --- a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift +++ b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift @@ -21,12 +21,10 @@ struct GlucoseBasedApplicationFactorStrategy: ApplicationFactorStrategy { func calculateDosingFactor( for glucose: HKQuantity, - correctionRangeSchedule: GlucoseRangeSchedule, - settings: LoopSettings + correctionRange: ClosedRange ) -> Double { // Calculate current glucose and lower bound target let currentGlucose = glucose.doubleValue(for: .milligramsPerDeciliter) - let correctionRange = correctionRangeSchedule.quantityRange(at: Date()) let lowerBoundTarget = correctionRange.lowerBound.doubleValue(for: .milligramsPerDeciliter) // Calculate minimum glucose sliding scale and scaling fraction diff --git a/Loop/Models/LoopError.swift b/Loop/Models/LoopError.swift index 015d5cc05c..6cb28cb5bd 100644 --- a/Loop/Models/LoopError.swift +++ b/Loop/Models/LoopError.swift @@ -17,7 +17,7 @@ enum ConfigurationErrorDetail: String, Codable { case insulinSensitivitySchedule case maximumBasalRatePerHour case maximumBolus - + func localized() -> String { switch self { case .pumpManager: @@ -45,7 +45,7 @@ enum MissingDataErrorDetail: String, Codable { case insulinEffect case activeInsulin case insulinEffectIncludingPendingInsulin - + var localizedDetail: String { switch self { case .glucose: @@ -105,6 +105,9 @@ enum LoopError: Error { // Pump Manager Error case pumpManagerError(PumpManagerError) + // Loop State loop in progress + case loopInProgress + // Some other error case unknownError(Error) } @@ -134,6 +137,8 @@ extension LoopError { return "pumpSuspended" case .pumpManagerError: return "pumpManagerError" + case .loopInProgress: + return "loopInProgress" case .unknownError: return "unknownError" } @@ -201,11 +206,13 @@ extension LoopError: LocalizedError { 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 .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/PredictionInputEffect.swift b/Loop/Models/PredictionInputEffect.swift index 45fb5ea0c7..164db3a234 100644 --- a/Loop/Models/PredictionInputEffect.swift +++ b/Loop/Models/PredictionInputEffect.swift @@ -8,7 +8,7 @@ import Foundation import HealthKit - +import LoopKit struct PredictionInputEffect: OptionSet { let rawValue: Int @@ -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/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index fc770192e9..378617b680 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -30,6 +30,10 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif var automaticDosingStatus: AutomaticDosingStatus! + var loopDataManager: LoopDataManager! + var carbStore: CarbStore! + var analyticsServicesManager: AnalyticsServicesManager! + override func viewDidLoad() { super.viewDidLoad() @@ -40,10 +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) + } } }, ] @@ -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,73 @@ 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: -carbStore.maximumAbsorptionTimeInterval)) - let reloadGroup = DispatchGroup() let shouldUpdateGlucose = currentContext.contains(.glucose) let shouldUpdateCarbs = currentContext.contains(.carbs) var carbEffects: [GlucoseEffect]? var carbStatuses: [CarbStatus]? var carbsOnBoard: CarbValue? - var carbTotal: CarbValue? var insulinCounteractionEffects: [GlucoseEffectVelocity]? - // TODO: Don't always assume currentContext.contains(.status) - reloadGroup.enter() - deviceManager.loopManager.getLoopState { (manager, state) in - if shouldUpdateGlucose || shouldUpdateCarbs { - let allInsulinCounteractionEffects = state.insulinCounteractionEffects - insulinCounteractionEffects = allInsulinCounteractionEffects.filterDateRange(chartStartDate, nil) - - reloadGroup.enter() - self.deviceManager.carbStore.getCarbStatus(start: listStart, end: nil, effectVelocities: allInsulinCounteractionEffects) { (result) in - switch result { - case .success(let status): - carbStatuses = status - carbsOnBoard = status.getClampedCarbsOnBoard() - case .failure(let error): - self.log.error("CarbStore failed to get carbStatus: %{public}@", String(describing: error)) - retryContext.update(with: .carbs) - } - - reloadGroup.leave() - } - - reloadGroup.enter() - self.deviceManager.carbStore.getGlucoseEffects(start: chartStartDate, end: nil, effectVelocities: insulinCounteractionEffects!) { (result) in - switch result { - case .success((_, let effects)): - carbEffects = effects - case .failure(let error): - carbEffects = [] - self.log.error("CarbStore failed to get glucoseEffects: %{public}@", String(describing: error)) - retryContext.update(with: .carbs) - } - reloadGroup.leave() - } + if shouldUpdateGlucose || shouldUpdateCarbs { + do { + let review = try await loopDataManager.fetchCarbAbsorptionReview(start: listStart, end: Date()) + insulinCounteractionEffects = review.effectsVelocities.filterDateRange(chartStartDate, nil) + carbStatuses = review.carbStatuses + carbsOnBoard = carbStatuses?.getClampedCarbsOnBoard() + 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() } } @@ -450,16 +418,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) } } } @@ -495,7 +460,9 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif let originalCarbEntry = carbStatuses[indexPath.row].entry - let viewModel = CarbEntryViewModel(delegate: deviceManager, originalCarbEntry: originalCarbEntry) + let viewModel = CarbEntryViewModel(delegate: loopDataManager, originalCarbEntry: originalCarbEntry) + viewModel.analyticsServicesManager = analyticsServicesManager + viewModel.deliveryDelegate = deviceManager let carbEntryView = CarbEntryView(viewModel: viewModel) .environmentObject(deviceManager.displayGlucosePreference) .environment(\.dismissAction, carbEditWasCanceled) @@ -514,14 +481,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)) + 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 = CarbEntryViewModel(delegate: loopDataManager) + viewModel.analyticsServicesManager = analyticsServicesManager let carbEntryView = CarbEntryView(viewModel: viewModel) .environmentObject(deviceManager.displayGlucosePreference) let hostingController = DismissibleHostingController(rootView: carbEntryView, isModalInPresentation: false) diff --git a/Loop/View Controllers/CommandResponseViewController.swift b/Loop/View Controllers/CommandResponseViewController.swift index e14c41c8a4..2bd93cc09f 100644 --- a/Loop/View Controllers/CommandResponseViewController.swift +++ b/Loop/View Controllers/CommandResponseViewController.swift @@ -13,21 +13,20 @@ import LoopKitUI extension CommandResponseViewController { typealias T = CommandResponseViewController - static func generateDiagnosticReport(deviceManager: DeviceDataManager) -> T { + static func generateDiagnosticReport(reportGenerator: DiagnosticReportGenerator) -> T { let date = Date() let vc = T(command: { (completionHandler) in - deviceManager.generateDiagnosticReport { (report) in - DispatchQueue.main.async { - completionHandler([ - "Use the Share button above to save this diagnostic report to aid investigating your problem. Issues can be filed at https://github.com/LoopKit/Loop/issues.", - "Generated: \(date)", - "", - report, - "", - ].joined(separator: "\n\n")) - } + Task { @MainActor in + let report = await reportGenerator.generateDiagnosticReport() + // TODO: https://tidepool.atlassian.net/browse/LOOP-4771 + completionHandler([ + "Use the Share button above to save this diagnostic report to aid investigating your problem. Issues can be filed at https://github.com/LoopKit/Loop/issues.", + "Generated: \(date)", + "", + report, + "", + ].joined(separator: "\n\n")) } - return NSLocalizedString("Loading...", comment: "The loading message for the diagnostic report screen") }) vc.fileName = "Loop Report \(ISO8601DateFormatter.string(from: date, timeZone: .current, formatOptions: [.withSpaceBetweenDateAndTime, .withInternetDateTime])).md" diff --git a/Loop/View Controllers/InsulinDeliveryTableViewController.swift b/Loop/View Controllers/InsulinDeliveryTableViewController.swift index c340f8f536..54ea7273d7 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() + } } } } @@ -222,7 +221,7 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } } - private func reloadData() { + private func reloadData() async { let sinceDate = Date().addingTimeInterval(-InsulinDeliveryTableViewController.historicDataDisplayTimeInterval) switch state { case .unknown: @@ -240,52 +239,24 @@ public final class InsulinDeliveryTableViewController: UITableViewController { self.tableView.tableHeaderView?.isHidden = false self.tableView.tableFooterView = nil - switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! { - case .reservoir: - doseStore?.getReservoirValues(since: sinceDate) { (result) in - DispatchQueue.main.async { () -> Void in - switch result { - case .failure(let error): - self.state = .unavailable(error) - case .success(let reservoirValues): - self.values = .reservoir(reservoirValues) - self.tableView.reloadData() - } - } - - self.updateTimelyStats(nil) - self.updateTotal() - } - case .history: - doseStore?.getPumpEventValues(since: sinceDate) { (result) in - DispatchQueue.main.async { () -> Void in - switch result { - case .failure(let error): - self.state = .unavailable(error) - case .success(let pumpEventValues): - self.values = .history(pumpEventValues) - self.tableView.reloadData() - } - } + guard let doseStore else { + return + } - self.updateTimelyStats(nil) - self.updateTotal() + do { + switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! { + case .reservoir: + self.values = .reservoir(try await doseStore.getReservoirValues(since: sinceDate, limit: nil)) + case .history: + self.values = .history(try await doseStore.getPumpEventValues(since: sinceDate)) + case .manualEntryDose: + self.values = .manualEntryDoses(try await doseStore.getManuallyEnteredDoses(since: sinceDate)) } - case .manualEntryDose: - doseStore?.getManuallyEnteredDoses(since: sinceDate) { (result) in - DispatchQueue.main.async { () -> Void in - switch result { - case .failure(let error): - self.state = .unavailable(error) - case .success(let values): - self.values = .manualEntryDoses(values) - self.tableView.reloadData() - } - } - } - + self.tableView.reloadData() self.updateTimelyStats(nil) self.updateTotal() + } catch { + self.state = .unavailable(error) } } } @@ -314,35 +285,27 @@ public final class InsulinDeliveryTableViewController: UITableViewController { 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 { + let midnight = Calendar.current.startOfDay(for: Date()) + + if let result = try? await doseStore?.getTotalUnitsDelivered(since: midnight) { + self.totalValueLabel.text = NumberFormatter.localizedString(from: NSNumber(value: result.value), number: .none) + self.totalDateLabel.text = String(format: NSLocalizedString("com.loudnate.InsulinKit.totalDateLabel", value: "since %1$@", comment: "The format string describing the starting date of a total value. The first format argument is the localized date."), DateFormatter.localizedString(from: result.startDate, dateStyle: .none, timeStyle: .short)) + } else { + self.totalValueLabel.text = "…" + self.totalDateLabel.text = nil } } } @@ -357,7 +320,9 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } @IBAction func selectedSegmentChanged(_ sender: Any) { - reloadData() + Task { @MainActor in + await reloadData() + } } @IBAction func confirmDeletion(_ sender: Any) { @@ -495,7 +460,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() + } } } } @@ -510,7 +477,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() + } } } } @@ -524,7 +493,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() + } } } } diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index a460e52aaf..1f48cb0c88 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -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) + } } }, ] @@ -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,69 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable let date = Date(timeIntervalSinceNow: -TimeInterval(hours: 1)) chartStartDate = calendar.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date - let reloadGroup = DispatchGroup() var glucoseSamples: [StoredGlucoseSample]? var totalRetrospectiveCorrection: HKQuantity? - if self.refreshContext.remove(.glucose) != nil { - reloadGroup.enter() - deviceManager.glucoseStore.getGlucoseSamples(start: self.chartStartDate, end: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - glucoseSamples = nil - case .success(let samples): - glucoseSamples = samples - } - reloadGroup.leave() - } - } - // For now, do this every time _ = self.refreshContext.remove(.status) - reloadGroup.enter() - deviceManager.loopManager.getLoopState { (manager, state) in - self.retrospectiveGlucoseDiscrepancies = state.retrospectiveGlucoseDiscrepancies - totalRetrospectiveCorrection = state.totalRetrospectiveCorrection - self.glucoseChart.setPredictedGlucoseValues(state.predictedGlucoseIncludingPendingInsulin ?? []) - - do { - let glucose = try state.predictGlucose(using: self.selectedInputs, includingPendingInsulin: true) - self.glucoseChart.setAlternatePredictedGlucoseValues(glucose) - } catch { - self.refreshContext.update(with: .status) - self.glucoseChart.setAlternatePredictedGlucoseValues([]) - } + let (algoInput, algoOutput) = await loopDataManager.algorithmDisplayState.asTuple - if let lastPoint = self.glucoseChart.alternatePredictedGlucosePoints?.last?.y { - self.eventualGlucoseDescription = String(describing: lastPoint) - } else { - self.eventualGlucoseDescription = nil - } + if self.refreshContext.remove(.glucose) != nil, let algoInput { + glucoseSamples = algoInput.glucoseHistory.filterDateRange(self.chartStartDate, nil) + } - if self.refreshContext.remove(.targets) != nil { - self.glucoseChart.targetGlucoseSchedule = manager.settings.glucoseTargetRangeSchedule - } + self.retrospectiveGlucoseDiscrepancies = algoOutput?.effects.retrospectiveGlucoseDiscrepancies + totalRetrospectiveCorrection = algoOutput?.effects.totalGlucoseCorrectionEffect + + self.glucoseChart.setPredictedGlucoseValues(algoOutput?.predictedGlucose ?? []) - reloadGroup.leave() + do { + let glucose = try algoInput?.predictGlucose(effectsOptions: self.selectedInputs.algorithmEffectOptions) ?? [] + self.glucoseChart.setAlternatePredictedGlucoseValues(glucose) + } catch { + self.refreshContext.update(with: .status) + self.glucoseChart.setAlternatePredictedGlucoseValues([]) } - reloadGroup.notify(queue: .main) { - if let glucoseSamples = glucoseSamples { - self.glucoseChart.setGlucoseValues(glucoseSamples) - } - self.charts.invalidateChart(atIndex: 0) + if let lastPoint = self.glucoseChart.alternatePredictedGlucosePoints?.last?.y { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + self.eventualGlucoseDescription = nil + } - if let totalRetrospectiveCorrection = totalRetrospectiveCorrection { - self.totalRetrospectiveCorrection = totalRetrospectiveCorrection - } + if self.refreshContext.remove(.targets) != nil { + self.glucoseChart.targetGlucoseSchedule = self.settingsManager.settings.glucoseTargetRangeSchedule + } - self.charts.prerender() + if let glucoseSamples = glucoseSamples { + self.glucoseChart.setGlucoseValues(glucoseSamples) + } + self.charts.invalidateChart(atIndex: 0) - self.tableView.beginUpdates() - for cell in self.tableView.visibleCells { - switch cell { - case let cell as ChartTableViewCell: - cell.reloadChart() + if let totalRetrospectiveCorrection = totalRetrospectiveCorrection { + self.totalRetrospectiveCorrection = totalRetrospectiveCorrection + } - if let indexPath = self.tableView.indexPath(for: cell) { - self.tableView(self.tableView, updateTitleFor: cell, at: indexPath) - } - case let cell as PredictionInputEffectTableViewCell: - if let indexPath = self.tableView.indexPath(for: cell) { - self.tableView(self.tableView, updateTextFor: cell, at: indexPath) - } - default: - break + self.charts.prerender() + + self.tableView.beginUpdates() + for cell in self.tableView.visibleCells { + switch cell { + case let cell as ChartTableViewCell: + cell.reloadChart() + + if let indexPath = self.tableView.indexPath(for: cell) { + self.tableView(self.tableView, updateTitleFor: cell, at: indexPath) } + case let cell as PredictionInputEffectTableViewCell: + if let indexPath = self.tableView.indexPath(for: cell) { + self.tableView(self.tableView, updateTextFor: cell, at: indexPath) + } + default: + break } - self.tableView.endUpdates() } + self.tableView.endUpdates() } // MARK: - UITableViewDataSource @@ -263,7 +253,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable if input == .retrospection, let lastDiscrepancy = retrospectiveGlucoseDiscrepancies?.last, - let currentGlucose = deviceManager.glucoseStore.latestGlucose + let currentGlucose = loopDataManager.latestGlucose { let formatter = QuantityFormatter(for: glucoseChart.glucoseUnit) let predicted = HKQuantity(unit: glucoseChart.glucoseUnit, doubleValue: currentGlucose.quantity.doubleValue(for: glucoseChart.glucoseUnit) - lastDiscrepancy.quantity.doubleValue(for: glucoseChart.glucoseUnit)) @@ -326,6 +316,9 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable tableView.deselectRow(at: indexPath, animated: true) refreshContext.update(with: .status) - reloadData() + + Task { + await reloadData() + } } } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 84bc9428c6..41935ed1f2 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -25,6 +25,7 @@ private extension RefreshContext { static let all: Set = [.status, .glucose, .insulin, .carbs, .targets] } +@MainActor final class StatusTableViewController: LoopChartsTableViewController { private let log = OSLog(category: "StatusTableViewController") @@ -39,10 +40,31 @@ final class StatusTableViewController: LoopChartsTableViewController { var alertPermissionsChecker: AlertPermissionsChecker! + var settingsManager: SettingsManager! + + var temporaryPresetsManager: TemporaryPresetsManager! + + var loopManager: LoopDataManager! + var alertMuter: AlertMuter! var supportManager: SupportManager! + var diagnosticReportGenerator: DiagnosticReportGenerator! + + var analyticsServicesManager: AnalyticsServicesManager? + + var servicesManager: ServicesManager! + + var simulatedData: SimulatedData! + + var carbStore: CarbStore! + + var doseStore: DoseStore! + + var criticalEventLogExportManager: CriticalEventLogExportManager! + + lazy private var cancellables = Set() override func viewDidLoad() { @@ -67,10 +89,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,40 +102,40 @@ final class StatusTableViewController: LoopChartsTableViewController { self?.refreshContext.update(with: .carbs) case .glucose?: self?.refreshContext.formUnion([.glucose, .carbs]) - case .loopFinished?: - self?.refreshContext.update(with: .insulin) + default: + break } self?.hudView?.loopCompletionHUD.loopInProgress = false self?.log.debug("[reloadData] from notification with context %{public}@", String(describing: context)) - self?.reloadData(animated: true) + await self?.reloadData(animated: true) } - + WidgetCenter.shared.reloadAllTimelines() }, - notificationCenter.addObserver(forName: .LoopRunning, object: deviceManager.loopManager, queue: nil) { [weak self] _ in - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .LoopRunning, object: nil, queue: nil) { _ in + Task { @MainActor [weak self] in self?.hudView?.loopCompletionHUD.loopInProgress = true } }, - notificationCenter.addObserver(forName: .PumpManagerChanged, object: deviceManager, queue: nil) { [weak self] (notification: Notification) in - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .PumpManagerChanged, object: deviceManager, queue: nil) { (notification: Notification) in + Task { @MainActor [weak self] in self?.registerPumpManager() self?.configurePumpManagerHUDViews() self?.updateToolbarItems() } }, - notificationCenter.addObserver(forName: .CGMManagerChanged, object: deviceManager, queue: nil) { [weak self] (notification: Notification) in - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .CGMManagerChanged, object: deviceManager, queue: nil) { (notification: Notification) in + Task { @MainActor [weak self] in self?.registerCGMManager() self?.configureCGMManagerHUDViews() self?.updateToolbarItems() } }, - notificationCenter.addObserver(forName: .PumpEventsAdded, object: deviceManager, queue: nil) { [weak self] (notification: Notification) in - DispatchQueue.main.async { + notificationCenter.addObserver(forName: .PumpEventsAdded, object: deviceManager, queue: nil) { (notification: Notification) in + Task { @MainActor [weak self] in self?.refreshContext.update(with: .insulin) - self?.reloadData(animated: true) + await self?.reloadData(animated: true) } }, ] @@ -125,11 +147,12 @@ final class StatusTableViewController: LoopChartsTableViewController { 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) @@ -172,11 +195,12 @@ final class StatusTableViewController: LoopChartsTableViewController { onboardingManager.$isComplete .merge(with: onboardingManager.$isSuspended) - .receive(on: RunLoop.main) .sink { [weak self] _ in - self?.refreshContext.update(with: .status) - self?.reloadData(animated: true) - self?.updateToolbarItems() + Task { @MainActor in + self?.refreshContext.update(with: .status) + await self?.reloadData(animated: true) + self?.updateToolbarItems() + } } .store(in: &cancellables) } @@ -187,15 +211,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() } @@ -249,8 +273,10 @@ final class StatusTableViewController: LoopChartsTableViewController { default: break } - refreshContext.update(with: .status) - reloadData(animated: true) + Task { @MainActor in + refreshContext.update(with: .status) + await reloadData(animated: true) + } } } } @@ -307,9 +333,11 @@ final class StatusTableViewController: LoopChartsTableViewController { public var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil { didSet { if oldValue != basalDeliveryState { - log.debug("New basalDeliveryState: %@", String(describing: basalDeliveryState)) - refreshContext.update(with: .status) - reloadData(animated: true) + Task { @MainActor in + log.debug("New basalDeliveryState: %@", String(describing: basalDeliveryState)) + refreshContext.update(with: .status) + await reloadData(animated: true) + } } } } @@ -359,7 +387,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let availableWidth = (refreshContext.newSize ?? tableView.bounds.size).width - charts.fixedHorizontalMargin let totalHours = floor(Double(availableWidth / LoopConstants.minimumChartWidthPerHour)) - let futureHours = ceil(deviceManager.doseStore.longestEffectDuration.hours) + let futureHours = ceil(doseStore.longestEffectDuration.hours) let historyHours = max(LoopConstants.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours) let date = Date(timeIntervalSinceNow: -TimeInterval(hours: historyHours)) @@ -372,10 +400,10 @@ final class StatusTableViewController: LoopChartsTableViewController { charts.updateEndDate(charts.maxEndDate) } - override func reloadData(animated: Bool = false) { + override func reloadData(animated: Bool = false) async { dispatchPrecondition(condition: .onQueue(.main)) // This should be kept up to date immediately - hudView?.loopCompletionHUD.lastLoopCompleted = deviceManager.loopManager.lastLoopCompleted + hudView?.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted guard !reloading && !deviceManager.authorizationRequired else { return @@ -402,11 +430,9 @@ 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]? @@ -418,231 +444,171 @@ final class StatusTableViewController: LoopChartsTableViewController { let basalDeliveryState = self.basalDeliveryState let automaticDosingEnabled = automaticDosingStatus.automaticDosingEnabled - // TODO: Don't always assume currentContext.contains(.status) - reloadGroup.enter() - deviceManager.loopManager.getLoopState { (manager, state) -> Void in - predictedGlucoseValues = state.predictedGlucoseIncludingPendingInsulin ?? [] - - // Retry this refresh again if predicted glucose isn't available - if state.predictedGlucose == nil { - retryContext.update(with: .status) - } - - /// Update the status HUDs immediately - let lastLoopError = state.error + let state = await loopManager.algorithmDisplayState + predictedGlucoseValues = state.output?.predictedGlucose ?? [] - // Net basal rate HUD - let netBasal: NetBasal? - if let basalSchedule = manager.basalRateScheduleApplyingOverrideHistory { - netBasal = basalDeliveryState?.getNetBasal(basalSchedule: basalSchedule, settings: manager.settings) - } else { - netBasal = nil - } - self.log.debug("Update net basal to %{public}@", String(describing: netBasal)) + /// Update the status HUDs immediately + let lastLoopError: Error? + if let output = state.output, case .failure(let error) = output.recommendationResult { + lastLoopError = error + } else { + lastLoopError = nil + } - DispatchQueue.main.async { - self.lastLoopError = lastLoopError + // Net basal rate HUD + let netBasal: NetBasal? + if let basalSchedule = temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory { + netBasal = basalDeliveryState?.getNetBasal(basalSchedule: basalSchedule, maximumBasalRatePerHour: settingsManager.settings.maximumBasalRatePerHour) + } else { + netBasal = nil + } + self.log.debug("Update net basal to %{public}@", String(describing: netBasal)) - if let netBasal = netBasal { - self.hudView?.pumpStatusHUD.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percent, at: netBasal.start) - } - } + self.lastLoopError = lastLoopError - if currentContext.contains(.carbs) { - reloadGroup.enter() - self.deviceManager.carbStore.getCarbsOnBoardValues(start: startDate, end: nil, effectVelocities: state.insulinCounteractionEffects) { (result) in - switch result { - case .failure(let error): - self.log.error("CarbStore failed to get carbs on board values: %{public}@", String(describing: error)) - retryContext.update(with: .carbs) - cobValues = [] - case .success(let values): - cobValues = values - } - reloadGroup.leave() - } - } - // always check for cob - carbsOnBoard = state.carbsOnBoard?.quantity + if let netBasal = netBasal { + self.hudView?.pumpStatusHUD.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percent, at: netBasal.start) + } - reloadGroup.leave() + if currentContext.contains(.carbs) { + cobValues = await loopManager.dynamicCarbsOnBoard(from: startDate) } + // always check for cob + carbsOnBoard = loopManager.activeCarbs?.quantity + if currentContext.contains(.glucose) { - reloadGroup.enter() - deviceManager.glucoseStore.getGlucoseSamples(start: startDate, end: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - glucoseSamples = nil - case .success(let samples): - glucoseSamples = samples - } - reloadGroup.leave() + do { + glucoseSamples = try await loopManager.glucoseStore.getGlucoseSamples(start: startDate, end: nil) + } catch { + self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) + glucoseSamples = nil } } if currentContext.contains(.insulin) { - reloadGroup.enter() - deviceManager.doseStore.getInsulinOnBoardValues(start: startDate, end: nil, basalDosingEnd: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("DoseStore failed to get insulin on board values: %{public}@", String(describing: error)) - retryContext.update(with: .insulin) - iobValues = [] - case .success(let values): - iobValues = values - } - reloadGroup.leave() - } - - reloadGroup.enter() - deviceManager.doseStore.getNormalizedDoseEntries(start: startDate, end: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.log.error("DoseStore failed to get normalized dose entries: %{public}@", String(describing: error)) - retryContext.update(with: .insulin) - doseEntries = [] - case .success(let doses): - doseEntries = doses - } - reloadGroup.leave() - } - - 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 = loopManager.dosesRelativeToBasal.trimmed(from: startDate) + iobValues = loopManager.iobValues.trimmed(from: startDate) + totalDelivery = try? await loopManager.doseStore.getTotalUnitsDelivered(since: Calendar.current.startOfDay(for: Date())).value } updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) - if deviceManager.loopManager.settings.preMealTargetRange == nil { + if settingsManager.settings.preMealTargetRange == nil { preMealMode = nil } else { - preMealMode = deviceManager.loopManager.settings.preMealTargetEnabled() + preMealMode = temporaryPresetsManager.preMealTargetEnabled() } - if !FeatureFlags.sensitivityOverridesEnabled, deviceManager.loopManager.settings.legacyWorkoutTargetRange == nil { + if !FeatureFlags.sensitivityOverridesEnabled, settingsManager.settings.workoutTargetRange == nil { workoutMode = nil } else { - workoutMode = deviceManager.loopManager.settings.nonPreMealOverrideEnabled() + workoutMode = temporaryPresetsManager.nonPreMealOverrideEnabled() } - reloadGroup.notify(queue: .main) { - /// Update the chart data + /// Update the chart data - // Glucose - if let glucoseSamples = glucoseSamples { - self.statusCharts.setGlucoseValues(glucoseSamples) - } - if (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled), let predictedGlucoseValues = predictedGlucoseValues { - self.statusCharts.setPredictedGlucoseValues(predictedGlucoseValues) - } else { - self.statusCharts.setPredictedGlucoseValues([]) - } - if !FeatureFlags.predictedGlucoseChartClampEnabled, - let lastPoint = self.statusCharts.glucose.predictedGlucosePoints.last?.y - { - self.eventualGlucoseDescription = String(describing: lastPoint) - } else { - // if the predicted glucose values are clamped, the eventually glucose description should not be displayed, since it may not align with what is being charted. - self.eventualGlucoseDescription = nil - } - if currentContext.contains(.targets) { - self.statusCharts.targetGlucoseSchedule = self.deviceManager.loopManager.settings.glucoseTargetRangeSchedule - self.statusCharts.preMealOverride = self.deviceManager.loopManager.settings.preMealOverride - self.statusCharts.scheduleOverride = self.deviceManager.loopManager.settings.scheduleOverride - } - if self.statusCharts.scheduleOverride?.hasFinished() == true { - self.statusCharts.scheduleOverride = nil - } + // Glucose + if let glucoseSamples = glucoseSamples { + self.statusCharts.setGlucoseValues(glucoseSamples) + } + if (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled), let predictedGlucoseValues = predictedGlucoseValues { + self.statusCharts.setPredictedGlucoseValues(predictedGlucoseValues) + } else { + self.statusCharts.setPredictedGlucoseValues([]) + } + if !FeatureFlags.predictedGlucoseChartClampEnabled, + let lastPoint = self.statusCharts.glucose.predictedGlucosePoints.last?.y + { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + // if the predicted glucose values are clamped, the eventually glucose description should not be displayed, since it may not align with what is being charted. + self.eventualGlucoseDescription = nil + } + if currentContext.contains(.targets) { + self.statusCharts.targetGlucoseSchedule = settingsManager.settings.glucoseTargetRangeSchedule + self.statusCharts.preMealOverride = temporaryPresetsManager.preMealOverride + self.statusCharts.scheduleOverride = temporaryPresetsManager.scheduleOverride + } + if self.statusCharts.scheduleOverride?.hasFinished() == true { + self.statusCharts.scheduleOverride = nil + } - let charts = self.statusCharts + let charts = self.statusCharts - // Active Insulin - if let iobValues = iobValues { - charts.setIOBValues(iobValues) - } + // Active Insulin + if let iobValues = iobValues { + charts.setIOBValues(iobValues) + } - // Show the larger of the value either before or after the current date - if let maxValue = charts.iob.iobPoints.allElementsAdjacent(to: Date()).max(by: { - return $0.y.scalar < $1.y.scalar - }) { - self.currentIOBDescription = String(describing: maxValue.y) - } else { - self.currentIOBDescription = nil - } + // Show the larger of the value either before or after the current date + if let 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 + } - // Insulin Delivery - if let doseEntries = doseEntries { - charts.setDoseEntries(doseEntries) - } - if let totalDelivery = totalDelivery { - self.totalDelivery = totalDelivery - } + // Insulin Delivery + if let doseEntries = doseEntries { + charts.setDoseEntries(doseEntries) + } + if let totalDelivery = totalDelivery { + self.totalDelivery = totalDelivery + } - // Active Carbohydrates - if let cobValues = cobValues { - charts.setCOBValues(cobValues) - } - if let index = charts.cob.cobPoints.closestIndex(priorTo: Date()) { - self.currentCOBDescription = String(describing: charts.cob.cobPoints[index].y) - } else if let carbsOnBoard = carbsOnBoard { - self.currentCOBDescription = self.carbFormatter.string(from: carbsOnBoard) - } else { - self.currentCOBDescription = nil - } + // Active Carbohydrates + if let cobValues = cobValues { + charts.setCOBValues(cobValues) + } + if let index = charts.cob.cobPoints.closestIndex(priorTo: Date()) { + self.currentCOBDescription = String(describing: charts.cob.cobPoints[index].y) + } else if let carbsOnBoard = carbsOnBoard { + self.currentCOBDescription = self.carbFormatter.string(from: carbsOnBoard) + } else { + self.currentCOBDescription = nil + } - self.tableView.beginUpdates() - if let hudView = self.hudView { - // CGM Status - if let glucose = self.deviceManager.glucoseStore.latestGlucose { - let unit = self.statusCharts.glucose.glucoseUnit - hudView.cgmStatusHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: unit), - at: glucose.startDate, - unit: unit, - staleGlucoseAge: LoopAlgorithm.inputDataRecencyInterval, - glucoseDisplay: self.deviceManager.glucoseDisplay(for: glucose), - wasUserEntered: glucose.wasUserEntered, - isDisplayOnly: glucose.isDisplayOnly) - } - hudView.cgmStatusHUD.presentStatusHighlight(self.deviceManager.cgmStatusHighlight) - hudView.cgmStatusHUD.presentStatusBadge(self.deviceManager.cgmStatusBadge) - hudView.cgmStatusHUD.lifecycleProgress = self.deviceManager.cgmLifecycleProgress - - // Pump Status - hudView.pumpStatusHUD.presentStatusHighlight(self.deviceManager.pumpStatusHighlight) - hudView.pumpStatusHUD.presentStatusBadge(self.deviceManager.pumpStatusBadge) - hudView.pumpStatusHUD.lifecycleProgress = self.deviceManager.pumpLifecycleProgress + self.tableView.beginUpdates() + if let hudView = self.hudView { + // CGM Status + if let glucose = self.loopManager.latestGlucose { + let unit = self.statusCharts.glucose.glucoseUnit + hudView.cgmStatusHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: unit), + at: glucose.startDate, + unit: unit, + staleGlucoseAge: LoopAlgorithm.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 - // 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 + } + + // Show/hide the table view rows + let statusRowMode = self.determineStatusRowMode() - self.updateBannerAndHUDandStatusRows(statusRowMode: statusRowMode, newSize: currentContext.newSize, animated: animated) + updateBannerAndHUDandStatusRows(statusRowMode: statusRowMode, newSize: currentContext.newSize, animated: animated) - self.redrawCharts() + redrawCharts() - self.tableView.endUpdates() + tableView.endUpdates() - self.reloading = false - let reloadNow = !self.refreshContext.isEmpty - self.refreshContext.formUnion(retryContext) + reloading = false + let reloadNow = !self.refreshContext.isEmpty - // Trigger a reload if new context exists. - if reloadNow { - self.log.debug("[reloadData] due to context change during previous reload") - self.reloadData() - } + // Trigger a reload if new context exists. + if reloadNow { + log.debug("[reloadData] due to context change during previous reload") + await reloadData() } } @@ -723,11 +689,11 @@ final class StatusTableViewController: LoopChartsTableViewController { statusRowMode = .onboardingSuspended } else if onboardingManager.isComplete, deviceManager.isGlucoseValueStale { statusRowMode = .recommendManualGlucoseEntry - } else if let scheduleOverride = deviceManager.loopManager.settings.scheduleOverride, + } else if let scheduleOverride = temporaryPresetsManager.scheduleOverride, !scheduleOverride.hasFinished() { statusRowMode = .scheduleOverrideEnabled(scheduleOverride) - } else if let premealOverride = deviceManager.loopManager.settings.preMealOverride, + } else if let premealOverride = temporaryPresetsManager.preMealOverride, !premealOverride.hasFinished() { statusRowMode = .scheduleOverrideEnabled(premealOverride) @@ -837,14 +803,14 @@ final class StatusTableViewController: LoopChartsTableViewController { } private lazy var preMealModeAllowed: Bool = { onboardingManager.isComplete && - (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) - && deviceManager.loopManager.settings.preMealTargetRange != nil + (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) + && settingsManager.settings.preMealTargetRange != nil }() private func updatePresetModeAvailability(automaticDosingEnabled: Bool) { preMealModeAllowed = onboardingManager.isComplete && - (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) - && deviceManager.loopManager.settings.preMealTargetRange != nil + (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) + && settingsManager.settings.preMealTargetRange != nil workoutModeAllowed = onboardingManager.isComplete && workoutMode != nil updateToolbarItems() } @@ -1213,7 +1179,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case .pumpSuspended(let resuming) where !resuming: updateBannerAndHUDandStatusRows(statusRowMode: .pumpSuspended(resuming: true) , newSize: nil, animated: true) deviceManager.pumpManager?.resumeDelivery() { (error) in - DispatchQueue.main.async { + Task { @MainActor in if let error = error { let alert = UIAlertController(with: error, title: NSLocalizedString("Failed to Resume Insulin Delivery", comment: "The alert title for a resume error")) self.present(alert, animated: true, completion: nil) @@ -1224,7 +1190,7 @@ final class StatusTableViewController: LoopChartsTableViewController { self.updateBannerAndHUDandStatusRows(statusRowMode: self.determineStatusRowMode(), newSize: nil, animated: true) self.refreshContext.update(with: .insulin) self.log.debug("[reloadData] after manually resuming suspend") - self.reloadData() + await self.reloadData() } } } @@ -1328,22 +1294,28 @@ final class StatusTableViewController: LoopChartsTableViewController { vc.isOnboardingComplete = onboardingManager.isComplete vc.automaticDosingStatus = automaticDosingStatus vc.deviceManager = deviceManager + vc.loopDataManager = loopManager + vc.analyticsServicesManager = analyticsServicesManager + vc.carbStore = carbStore vc.hidesBottomBarWhenPushed = true case let vc as InsulinDeliveryTableViewController: - vc.deviceManager = deviceManager + vc.loopDataManager = loopManager + vc.doseStore = doseStore vc.hidesBottomBarWhenPushed = true vc.enableEntryDeletion = FeatureFlags.entryDeletionEnabled vc.headerValueLabelColor = .insulinTintColor case let vc as OverrideSelectionViewController: - if deviceManager.loopManager.settings.futureOverrideEnabled() { - vc.scheduledOverride = deviceManager.loopManager.settings.scheduleOverride + if temporaryPresetsManager.futureOverrideEnabled() { + vc.scheduledOverride = temporaryPresetsManager.scheduleOverride } - vc.presets = deviceManager.loopManager.settings.overridePresets + vc.presets = loopManager.settings.overridePresets vc.glucoseUnit = statusCharts.glucose.glucoseUnit - vc.overrideHistory = deviceManager.loopManager.overrideHistory.getEvents() + vc.overrideHistory = temporaryPresetsManager.overrideHistory.getEvents() vc.delegate = self case let vc as PredictionTableViewController: vc.deviceManager = deviceManager + vc.settingsManager = settingsManager + vc.loopDataManager = loopManager default: break } @@ -1360,7 +1332,7 @@ final class StatusTableViewController: LoopChartsTableViewController { func presentCarbEntryScreen(_ activity: NSUserActivity?) { let navigationWrapper: UINavigationController if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { - let viewModel = SimpleBolusViewModel(delegate: deviceManager, displayMealEntry: true) + let viewModel = SimpleBolusViewModel(delegate: loopManager, displayMealEntry: true, displayGlucosePreference: deviceManager.displayGlucosePreference) if let activity = activity { viewModel.restoreUserActivityState(activity) } @@ -1370,7 +1342,9 @@ final class StatusTableViewController: LoopChartsTableViewController { hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) } else { - let viewModel = CarbEntryViewModel(delegate: deviceManager) + let viewModel = CarbEntryViewModel(delegate: loopManager) + viewModel.deliveryDelegate = deviceManager + viewModel.analyticsServicesManager = loopManager.analyticsServicesManager if let activity { viewModel.restoreUserActivityState(activity) } @@ -1379,7 +1353,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let hostingController = DismissibleHostingController(rootView: carbEntryView, isModalInPresentation: false) present(hostingController, animated: true) } - deviceManager.analyticsServicesManager.didDisplayCarbEntryScreen() + analyticsServicesManager?.didDisplayCarbEntryScreen() } @IBAction func presentBolusScreen() { @@ -1391,24 +1365,21 @@ final class StatusTableViewController: LoopChartsTableViewController { if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { SimpleBolusView( viewModel: SimpleBolusViewModel( - delegate: deviceManager, - displayMealEntry: false + delegate: loopManager, + displayMealEntry: false, + displayGlucosePreference: deviceManager.displayGlucosePreference ) ) .environmentObject(deviceManager.displayGlucosePreference) } else { let viewModel: BolusEntryViewModel = { let viewModel = BolusEntryViewModel( - delegate: deviceManager, + delegate: loopManager, screenWidth: UIScreen.main.bounds.width, isManualGlucoseEntryEnabled: enableManualGlucoseEntry ) - - Task { @MainActor in - await viewModel.generateRecommendationAndStartObserving() - } - - viewModel.analyticsServicesManager = deviceManager.analyticsServicesManager + viewModel.deliveryDelegate = deviceManager + viewModel.analyticsServicesManager = analyticsServicesManager return viewModel }() @@ -1428,7 +1399,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let navigationWrapper = UINavigationController(rootViewController: hostingController) hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) - deviceManager.analyticsServicesManager.didDisplayBolusScreen() + analyticsServicesManager?.didDisplayBolusScreen() } private func createPreMealButtonItem(selected: Bool, isEnabled: Bool) -> UIBarButtonItem { @@ -1466,25 +1437,17 @@ final class StatusTableViewController: LoopChartsTableViewController { } @IBAction func premealButtonTapped(_ sender: UIBarButtonItem) { - togglePreMealMode(confirm: false) + togglePreMealMode() } - func togglePreMealMode(confirm: Bool = true) { + func togglePreMealMode() { if preMealMode == true { - if confirm { - let alert = UIAlertController(title: "Disable Pre-Meal Preset?", message: "This will remove any currently applied pre-meal preset.", preferredStyle: .alert) - alert.addCancelAction() - alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in - self?.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } - })) - present(alert, animated: true) - } else { - deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } - } + let alert = UIAlertController(title: "Disable Pre-Meal Preset?", message: "This will remove any currently applied pre-meal preset.", preferredStyle: .alert) + alert.addCancelAction() + alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in + self?.temporaryPresetsManager.preMealOverride = nil + })) + present(alert, animated: true) } else { presentPreMealModeAlertController() } @@ -1496,41 +1459,26 @@ final class StatusTableViewController: LoopChartsTableViewController { guard self.workoutMode != true else { // allow cell animation when switching between presets - self.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride() - } + self.temporaryPresetsManager.scheduleOverride = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.deviceManager.loopManager.mutateSettings { settings in - settings.enablePreMealOverride(at: startDate, for: duration) - } + self.temporaryPresetsManager.enablePreMealOverride(at: startDate, for: duration) } return } - - self.deviceManager.loopManager.mutateSettings { settings in - settings.enablePreMealOverride(at: startDate, for: duration) - } + self.temporaryPresetsManager.enablePreMealOverride(at: startDate, for: duration) }) present(vc, animated: true, completion: nil) } - func presentCustomPresets(confirm: Bool = true) { + func presentCustomPresets() { if workoutMode == true { - if confirm { - let alert = UIAlertController(title: "Disable Preset?", message: "This will remove any currently applied preset.", preferredStyle: .alert) - alert.addCancelAction() - alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in - self?.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride() - } - })) - present(alert, animated: true) - } else { - deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride() - } - } + let alert = UIAlertController(title: "Disable Preset?", message: "This will remove any currently applied preset.", preferredStyle: .alert) + alert.addCancelAction() + alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in + self?.temporaryPresetsManager.scheduleOverride = nil + })) + present(alert, animated: true) } else { if FeatureFlags.sensitivityOverridesEnabled { performSegue(withIdentifier: OverrideSelectionViewController.className, sender: toolbarItems![6]) @@ -1546,27 +1494,21 @@ final class StatusTableViewController: LoopChartsTableViewController { guard self.preMealMode != true else { // allow cell animation when switching between presets - self.deviceManager.loopManager.mutateSettings { settings in - settings.clearOverride(matching: .preMeal) - } + self.temporaryPresetsManager.clearOverride(matching: .preMeal) DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.deviceManager.loopManager.mutateSettings { settings in - settings.enableLegacyWorkoutOverride(at: startDate, for: duration) - } + self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: duration) } return } - self.deviceManager.loopManager.mutateSettings { settings in - settings.enableLegacyWorkoutOverride(at: startDate, for: duration) - } + self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: duration) }) present(vc, animated: true, completion: nil) } @IBAction func toggleWorkoutMode(_ sender: UIBarButtonItem) { - presentCustomPresets(confirm: false) + presentCustomPresets() } @IBAction func onSettingsTapped(_ sender: UIBarButtonItem) { @@ -1585,7 +1527,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } : nil } let pumpViewModel = PumpManagerViewModel( - image: { [weak self] in self?.deviceManager.pumpManager?.smallImage }, + image: { [weak self] in (self?.deviceManager.pumpManager as? PumpManagerUI)?.smallImage }, name: { [weak self] in self?.deviceManager.pumpManager?.localizedTitle ?? "" }, isSetUp: { [weak self] in self?.deviceManager.pumpManager?.isOnboarded == true }, availableDevices: deviceManager.availablePumpManagers, @@ -1610,8 +1552,8 @@ final class StatusTableViewController: LoopChartsTableViewController { self?.addCGMManager(withIdentifier: $0.identifier) }) let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled, - availableServices: { [weak self] in self?.deviceManager.servicesManager.availableServices ?? [] }, - activeServices: { [weak self] in self?.deviceManager.servicesManager.activeServices ?? [] }, + availableServices: { [weak self] in self?.servicesManager.availableServices ?? [] }, + activeServices: { [weak self] in self?.servicesManager.activeServices ?? [] }, delegate: self) let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, @@ -1620,12 +1562,12 @@ final class StatusTableViewController: LoopChartsTableViewController { pumpManagerSettingsViewModel: pumpViewModel, cgmManagerSettingsViewModel: cgmViewModel, servicesViewModel: servicesViewModel, - criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: deviceManager.criticalEventLogExportManager), - therapySettings: { [weak self] in self?.deviceManager.loopManager.therapySettings ?? TherapySettings() }, + criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), + therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - initialDosingEnabled: deviceManager.loopManager.settings.dosingEnabled, + initialDosingEnabled: self.settingsManager.settings.dosingEnabled, isClosedLoopAllowed: automaticDosingStatus.$isAutomaticDosingAllowed, - automaticDosingStrategy: deviceManager.loopManager.settings.automaticDosingStrategy, + automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, availableSupports: supportManager.availableSupports, isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceManager, @@ -1639,10 +1581,11 @@ final class StatusTableViewController: LoopChartsTableViewController { } 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) @@ -1690,7 +1633,7 @@ final class StatusTableViewController: LoopChartsTableViewController { // when HUD view is initialized, update loop completion HUD (e.g., icon and last loop completed) hudView.loopCompletionHUD.stateColors = .loopStatus hudView.loopCompletionHUD.loopIconClosed = automaticDosingStatus.automaticDosingEnabled - hudView.loopCompletionHUD.lastLoopCompleted = deviceManager.loopManager.lastLoopCompleted + hudView.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted hudView.cgmStatusHUD.stateColors = .cgmStatus hudView.cgmStatusHUD.tintColor = .label @@ -1698,8 +1641,10 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView.pumpStatusHUD.tintColor = .insulinTintColor refreshContext.update(with: .status) - log.debug("[reloadData] after hudView loaded") - reloadData() + Task { @MainActor in + log.debug("[reloadData] after hudView loaded") + await reloadData() + } } } @@ -1761,7 +1706,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) @@ -1855,9 +1802,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 } @@ -1893,14 +1842,14 @@ final class StatusTableViewController: LoopChartsTableViewController { }) } actionSheet.addAction(UIAlertAction(title: "Remove Exports Directory", style: .default) { _ in - if let error = self.deviceManager.removeExportsDirectory() { + if let error = self.criticalEventLogExportManager.removeExportsDirectory() { self.presentError(error) } }) if FeatureFlags.mockTherapySettingsEnabled { actionSheet.addAction(UIAlertAction(title: "Mock Therapy Settings", style: .default) { _ in let therapySettings = TherapySettings.mockTherapySettings - self.deviceManager.loopManager.mutateSettings { settings in + self.settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout @@ -1974,7 +1923,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() @@ -1982,7 +1931,7 @@ final class StatusTableViewController: LoopChartsTableViewController { return } - self.deviceManager.generateSimulatedHistoricalCoreData() { error in + self.simulatedData.generateSimulatedHistoricalCoreData() { error in DispatchQueue.main.async { dismissActivityIndicator() if let error = error { @@ -2001,7 +1950,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 { @@ -2067,21 +2016,22 @@ extension StatusTableViewController: CompletionDelegate { extension StatusTableViewController: PumpManagerStatusObserver { func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { - dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ did update status", String(describing: type(of: pumpManager))) + Task { @MainActor in - basalDeliveryState = status.basalDeliveryState - bolusState = status.bolusState + basalDeliveryState = status.basalDeliveryState + bolusState = status.bolusState - refreshContext.update(with: .status) - reloadData(animated: true) + refreshContext.update(with: .status) + await self.reloadData(animated: true) + } } } extension StatusTableViewController: CGMManagerStatusObserver { func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) { refreshContext.update(with: .status) - reloadData(animated: true) + Task { await reloadData(animated: true) } } } @@ -2095,7 +2045,7 @@ extension StatusTableViewController: DoseProgressObserver { self.bolusProgressReporter = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { self.bolusState = .noBolus - self.reloadData(animated: true) + Task { await self.reloadData(animated: true) } }) } } @@ -2103,15 +2053,13 @@ extension StatusTableViewController: DoseProgressObserver { extension StatusTableViewController: OverrideSelectionViewControllerDelegate { func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didUpdatePresets presets: [TemporaryScheduleOverridePreset]) { - deviceManager.loopManager.mutateSettings { settings in + settingsManager.mutateLoopSettings { settings in settings.overridePresets = presets } } func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = override - } + temporaryPresetsManager.scheduleOverride = override } func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmPreset preset: TemporaryScheduleOverridePreset) { @@ -2126,29 +2074,21 @@ extension StatusTableViewController: OverrideSelectionViewControllerDelegate { os_log(.error, "Failed to donate intent: %{public}@", String(describing: error)) } } - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = preset.createOverride(enactTrigger: .local) - } + temporaryPresetsManager.scheduleOverride = preset.createOverride(enactTrigger: .local) } func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didCancelOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = nil - } + temporaryPresetsManager.scheduleOverride = nil } } extension StatusTableViewController: AddEditOverrideTableViewControllerDelegate { func addEditOverrideTableViewController(_ vc: AddEditOverrideTableViewController, didSaveOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = override - } + temporaryPresetsManager.scheduleOverride = override } func addEditOverrideTableViewController(_ vc: AddEditOverrideTableViewController, didCancelOverride override: TemporaryScheduleOverride) { - deviceManager.loopManager.mutateSettings { settings in - settings.scheduleOverride = nil - } + temporaryPresetsManager.scheduleOverride = nil } } @@ -2172,9 +2112,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 @@ -2202,7 +2142,7 @@ extension StatusTableViewController { extension StatusTableViewController: BluetoothObserver { func bluetoothDidUpdateState(_ state: BluetoothState) { refreshContext.update(with: .status) - reloadData(animated: true) + Task { await reloadData(animated: true) } } } @@ -2213,13 +2153,13 @@ extension StatusTableViewController: SettingsViewModelDelegate { } func dosingEnabledChanged(_ value: Bool) { - deviceManager.loopManager.mutateSettings { settings in + settingsManager.mutateLoopSettings { settings in settings.dosingEnabled = value } } func dosingStrategyChanged(_ strategy: AutomaticDosingStrategy) { - self.deviceManager.loopManager.mutateSettings { settings in + settingsManager.mutateLoopSettings { settings in settings.automaticDosingStrategy = strategy } } @@ -2228,7 +2168,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) } @@ -2239,13 +2179,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: @@ -2255,7 +2195,7 @@ 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) @@ -2263,7 +2203,7 @@ extension StatusTableViewController: ServicesViewModelDelegate { fileprivate func showServiceSettings(_ serviceUI: ServiceUI) { var settingsViewController = serviceUI.settingsViewController(colorPalette: .default) - settingsViewController.serviceOnboardingDelegate = deviceManager.servicesManager + settingsViewController.serviceOnboardingDelegate = servicesManager settingsViewController.completionDelegate = self show(settingsViewController, sender: self) } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index ff0408e1a2..38532c6495 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -19,41 +19,34 @@ import SwiftUI import SwiftCharts 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) - - 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) + var settings: StoredSettings { get } + var scheduleOverride: TemporaryScheduleOverride? { get } + var preMealOverride: TemporaryScheduleOverride? { get } + var pumpInsulinType: InsulinType? { get } + var mostRecentGlucoseDataDate: Date? { get } + var mostRecentPumpDataDate: Date? { get } - func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult) -> Void) - + func fetchData(for baseTime: Date, disablingPreMeal: Bool) async throws -> LoopAlgorithmInput + func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? func insulinActivityDuration(for type: InsulinType?) -> TimeInterval - var mostRecentGlucoseDataDate: Date? { get } - - var mostRecentPumpDataDate: Date? { get } - - var isPumpConfigured: Bool { get } - - var pumpInsulinType: InsulinType? { get } - - var settings: LoopSettings { get } + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry + func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async + func enactBolus(units: Double, activationType: BolusActivationType) async throws - var displayGlucosePreference: DisplayGlucosePreference { get } + func recommendManualBolus( + manualGlucoseSample: NewGlucoseSample?, + potentialCarbEntry: NewCarbEntry?, + originalCarbEntry: StoredCarbEntry? + ) async throws -> ManualBolusRecommendation? - func roundBolusVolume(units: Double) -> Double - func updateRemoteRecommendation() + func generatePrediction(input: LoopAlgorithmInput) throws -> [PredictedGlucoseValue] + + var activeInsulin: InsulinValue? { get } + var activeCarbs: CarbValue? { get } } @MainActor @@ -151,6 +144,7 @@ final class BolusEntryViewModel: ObservableObject { // MARK: - Seams private weak var delegate: BolusEntryViewModelDelegate? + weak var deliveryDelegate: DeliveryDelegate? private let now: () -> Date private let screenWidth: CGFloat private let debounceIntervalMilliseconds: Int @@ -215,8 +209,8 @@ final class BolusEntryViewModel: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] note in Task { - if let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, - let context = LoopDataManager.LoopUpdateContext(rawValue: rawContext), + if let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopUpdateContext.RawValue, + let context = LoopUpdateContext(rawValue: rawContext), context == .preferences { self?.updateSettings() @@ -233,8 +227,8 @@ final class BolusEntryViewModel: ObservableObject { .removeDuplicates() .debounce(for: .milliseconds(debounceIntervalMilliseconds), scheduler: RunLoop.main) .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state) + Task { + await self?.updatePredictedGlucoseValues() } } .store(in: &cancellables) @@ -248,13 +242,11 @@ final class BolusEntryViewModel: ObservableObject { // Clear out any entered bolus whenever the glucose entry changes self.enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) - self.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state, completion: { - // Ensure the manual glucose entry appears on the chart at the same time as the updated prediction - self?.updateGlucoseChartValues() - }) - - self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + Task { + await self.updatePredictedGlucoseValues() + // Ensure the manual glucose entry appears on the chart at the same time as the updated prediction + self.updateGlucoseChartValues() + await self.updateRecommendedBolusAndNotice(isUpdatingFromUserInput: true) } if let manualGlucoseQuantity = manualGlucoseQuantity { @@ -301,21 +293,7 @@ final class BolusEntryViewModel: ObservableObject { } func saveCarbEntry(_ entry: NewCarbEntry, replacingEntry: StoredCarbEntry?) async -> StoredCarbEntry? { - guard let delegate = delegate else { - return nil - } - - return await withCheckedContinuation { continuation in - delegate.addCarbEntry(entry, replacing: replacingEntry) { result in - switch result { - case .success(let storedCarbEntry): - continuation.resume(returning: storedCarbEntry) - case .failure(let error): - self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) - continuation.resume(returning: nil) - } - } - } + try? await delegate?.addCarbEntry(entry, replacing: replacingEntry) } // returns true if action succeeded @@ -331,16 +309,16 @@ final class BolusEntryViewModel: ObservableObject { // returns true if no errors func saveAndDeliver() async -> Bool { - guard delegate?.isPumpConfigured ?? false else { - presentAlert(.noPumpManagerConfigured) + guard let delegate, let deliveryDelegate else { + assertionFailure("Missing Delegate") return false } - guard let delegate = delegate else { - assertionFailure("Missing BolusEntryViewModelDelegate") + guard deliveryDelegate.isPumpConfigured else { + presentAlert(.noPumpManagerConfigured) return false } - + guard let maximumBolus = maximumBolus else { presentAlert(.noMaxBolusConfigured) return false @@ -351,7 +329,8 @@ final class BolusEntryViewModel: ObservableObject { return false } - let amountToDeliver = delegate.roundBolusVolume(units: enteredBolusAmount) + let amountToDeliver = deliveryDelegate.roundBolusVolume(units: enteredBolusAmount) + guard enteredBolusAmount == 0 || amountToDeliver > 0 else { presentAlert(.bolusTooSmall) return false @@ -378,14 +357,10 @@ final class BolusEntryViewModel: ObservableObject { } } - defer { - delegate.updateRemoteRecommendation() - } - - if let manualGlucoseSample = manualGlucoseSample { - if let glucoseValue = await delegate.saveGlucose(sample: manualGlucoseSample) { - dosingDecision.manualGlucoseSample = glucoseValue - } else { + if let manualGlucoseSample { + do { + dosingDecision.manualGlucoseSample = try await delegate.saveGlucose(sample: manualGlucoseSample) + } catch { presentAlert(.manualGlucoseEntryPersistenceFailure) return false } @@ -417,20 +392,21 @@ final class BolusEntryViewModel: ObservableObject { dosingDecision.manualBolusRequested = amountToDeliver let now = self.now() - delegate.storeManualBolusDosingDecision(dosingDecision, withDate: now) + await delegate.storeManualBolusDosingDecision(dosingDecision, withDate: now) if amountToDeliver > 0 { savedPreMealOverride = nil - delegate.enactBolus(units: amountToDeliver, activationType: activationType, completion: { _ in - self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) - }) + do { + try await delegate.enactBolus(units: amountToDeliver, activationType: activationType) + } catch { + log.error("Failed to store bolus: %{public}@", String(describing: error)) + } + self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) } return true } private func presentAlert(_ alert: Alert) { - dispatchPrecondition(condition: .onQueue(.main)) - // As of iOS 13.6 / Xcode 11.6, swapping out an alert while one is active crashes SwiftUI. guard activeAlert == nil else { return @@ -497,30 +473,23 @@ 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 @@ -529,28 +498,7 @@ final class BolusEntryViewModel: ObservableObject { } } - private func updateStoredGlucoseValues() { - let historicalGlucoseStartDate = Date(timeInterval: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval, since: now()) - let chartStartDate = chartDateInterval.start - delegate?.getGlucoseSamples(start: min(historicalGlucoseStartDate, chartStartDate), end: nil) { [weak self] result in - DispatchQueue.main.async { - guard let self = self else { return } - switch result { - case .failure(let error): - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - self.storedGlucoseValues = [] - self.dosingDecision.historicalGlucose = [] - case .success(let samples): - self.storedGlucoseValues = samples.filter { $0.startDate >= chartStartDate } - self.dosingDecision.historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } - } - self.updateGlucoseChartValues() - } - } - } - private func updateGlucoseChartValues() { - dispatchPrecondition(condition: .onQueue(.main)) var chartGlucoseValues = storedGlucoseValues if let manualGlucoseSample = manualGlucoseSample { @@ -561,110 +509,59 @@ final class BolusEntryViewModel: ObservableObject { } /// - NOTE: `completion` is invoked on the main queue after predicted glucose values are updated - private func updatePredictedGlucoseValues(from state: LoopState, completion: @escaping () -> Void = {}) { - dispatchPrecondition(condition: .notOnQueue(.main)) - - let (manualGlucoseSample, enteredBolus, insulinType) = DispatchQueue.main.sync { (self.manualGlucoseSample, self.enteredBolus, delegate?.pumpInsulinType) } - - let enteredBolusDose = DoseEntry(type: .bolus, startDate: Date(), value: enteredBolus.doubleValue(for: .internationalUnit()), unit: .units, insulinType: insulinType) + private func updatePredictedGlucoseValues() async { + guard let delegate else { + return + } - let predictedGlucoseValues: [PredictedGlucoseValue] do { - if let manualGlucoseEntry = manualGlucoseSample { - predictedGlucoseValues = try state.predictGlucoseFromManualGlucose( - manualGlucoseEntry, - potentialBolus: enteredBolusDose, - potentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: originalCarbEntry, - includingPendingInsulin: true, - considerPositiveVelocityAndRC: true - ) - } else { - predictedGlucoseValues = try state.predictGlucose( - using: .all, - potentialBolus: enteredBolusDose, - potentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: originalCarbEntry, - includingPendingInsulin: true, - considerPositiveVelocityAndRC: true - ) - } - } catch { - predictedGlucoseValues = [] - } + let startDate = now() + var input = try await delegate.fetchData(for: startDate, disablingPreMeal: potentialCarbEntry != nil) + + let enteredBolusDose = DoseEntry( + type: .bolus, + startDate: startDate, + value: enteredBolus.doubleValue(for: .internationalUnit()), + unit: .units, + insulinType: deliveryDelegate?.pumpInsulinType, + manuallyEntered: true + ) - DispatchQueue.main.async { - self.predictedGlucoseValues = predictedGlucoseValues - self.dosingDecision.predictedGlucose = predictedGlucoseValues - completion() - } - } + storedGlucoseValues = input.glucoseHistory - private func getInsulinOnBoard() async -> InsulinValue? { - guard let delegate = delegate else { - return nil - } + // Add potential bolus, carbs, manual glucose + input = input + .addingDose(dose: enteredBolusDose) + .addingGlucoseSample(sample: manualGlucoseSample) + .removingCarbEntry(carbEntry: originalCarbEntry) + .addingCarbEntry(carbEntry: potentialCarbEntry) - 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) - } - } - } - } - - private func updatePredictionAndRecommendation() async { - guard let delegate = delegate else { - return - } - return await withCheckedContinuation { continuation in - delegate.withLoopState { [weak self] state in - self?.updateCarbsOnBoard(from: state) - self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: false) - self?.updatePredictedGlucoseValues(from: state) - continuation.resume() - } + let prediction = try delegate.generatePrediction(input: input) + predictedGlucoseValues = prediction + dosingDecision.predictedGlucose = prediction + } catch { + predictedGlucoseValues = [] + dosingDecision.predictedGlucose = [] } - } - 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 - } - } - } } - private func updateRecommendedBolusAndNotice(from state: LoopState, isUpdatingFromUserInput: Bool) { - dispatchPrecondition(condition: .notOnQueue(.main)) + private func updateRecommendedBolusAndNotice(isUpdatingFromUserInput: Bool) async { guard let delegate = delegate else { assertionFailure("Missing BolusEntryViewModelDelegate") return } - let now = Date() var recommendation: ManualBolusRecommendation? let recommendedBolus: HKQuantity? let notice: Notice? do { - recommendation = try computeBolusRecommendation(from: state) + recommendation = try await computeBolusRecommendation() + + if let recommendation, let deliveryDelegate { + recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: deliveryDelegate.roundBolusVolume(units: recommendation.amount)) - if let recommendation = recommendation { - recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: recommendation.amount)) - //recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) - switch recommendation.notice { case .glucoseBelowSuspendThreshold: if let suspendThreshold = delegate.settings.suspendThreshold { @@ -698,53 +595,41 @@ final class BolusEntryViewModel: ObservableObject { } } - DispatchQueue.main.async { - let priorRecommendedBolus = self.recommendedBolus - self.recommendedBolus = recommendedBolus - self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now) } - self.activeNotice = notice + let priorRecommendedBolus = self.recommendedBolus + self.recommendedBolus = recommendedBolus + self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now()) } + self.activeNotice = notice - if priorRecommendedBolus != nil, - priorRecommendedBolus != recommendedBolus, - !self.enacting, - !isUpdatingFromUserInput - { - self.presentAlert(.recommendationChanged) - } + if priorRecommendedBolus != nil, + priorRecommendedBolus != recommendedBolus, + !self.enacting, + !isUpdatingFromUserInput + { + self.presentAlert(.recommendationChanged) } } - private func computeBolusRecommendation(from state: LoopState) throws -> ManualBolusRecommendation? { - dispatchPrecondition(condition: .notOnQueue(.main)) - - let manualGlucoseSample = DispatchQueue.main.sync { self.manualGlucoseSample } - if manualGlucoseSample != nil { - return try state.recommendBolusForManualGlucose( - manualGlucoseSample!, - consideringPotentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: originalCarbEntry, - considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses - ) - } else { - return try state.recommendBolus( - consideringPotentialCarbEntry: potentialCarbEntry, - replacingCarbEntry: originalCarbEntry, - considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses - ) + private func computeBolusRecommendation() async throws -> ManualBolusRecommendation? { + guard let delegate else { + return nil } + + return try await delegate.recommendManualBolus( + manualGlucoseSample: manualGlucoseSample, + potentialCarbEntry: potentialCarbEntry, + originalCarbEntry: originalCarbEntry + ) } func updateSettings() { - dispatchPrecondition(condition: .onQueue(.main)) - guard let delegate = delegate else { return } targetGlucoseSchedule = delegate.settings.glucoseTargetRangeSchedule // Pre-meal override should be ignored if we have carbs (LOOP-1964) - preMealOverride = potentialCarbEntry == nil ? delegate.settings.preMealOverride : nil - scheduleOverride = delegate.settings.scheduleOverride + preMealOverride = potentialCarbEntry == nil ? delegate.preMealOverride : nil + scheduleOverride = delegate.scheduleOverride if preMealOverride?.hasFinished() == true { preMealOverride = nil @@ -761,15 +646,13 @@ final class BolusEntryViewModel: ObservableObject { dosingDecision.scheduleOverride = scheduleOverride if scheduleOverride != nil || preMealOverride != nil { - dosingDecision.glucoseTargetRangeSchedule = delegate.settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) + dosingDecision.glucoseTargetRangeSchedule = delegate.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) } else { dosingDecision.glucoseTargetRangeSchedule = targetGlucoseSchedule } } private func updateChartDateInterval() { - dispatchPrecondition(condition: .onQueue(.main)) - // How far back should we show data? Use the screen size as a guide. let viewMarginInset: CGFloat = 14 let availableWidth = screenWidth - chartManager.fixedHorizontalMargin - 2 * viewMarginInset diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index f271793d1c..ee0cbe12bc 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -12,8 +12,8 @@ import HealthKit import Combine protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate { - var analyticsServicesManager: AnalyticsServicesManager { get } var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes { get } + func scheduleOverrideEnabled(at date: Date) -> Bool } final class CarbEntryViewModel: ObservableObject { @@ -83,7 +83,9 @@ final class CarbEntryViewModel: ObservableObject { @Published var selectedFavoriteFoodIndex = -1 weak var delegate: CarbEntryViewModelDelegate? - + weak var analyticsServicesManager: AnalyticsServicesManager? + weak var deliveryDelegate: DeliveryDelegate? + private lazy var cancellables = Set() /// Initalizer for when`CarbEntryView` is presented from the home screen @@ -189,14 +191,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() { @@ -290,13 +290,16 @@ final class CarbEntryViewModel: ObservableObject { } private func checkIfOverrideEnabled() { - if let managerSettings = delegate?.settings, - managerSettings.scheduleOverrideEnabled(at: Date()), - let overrideSettings = managerSettings.scheduleOverride?.settings, - overrideSettings.effectiveInsulinNeedsScaleFactor != 1.0 { - self.warnings.insert(.overrideInProgress) + guard let delegate else { + return } - else { + + if delegate.scheduleOverrideEnabled(at: Date()), + let overrideSettings = delegate.scheduleOverride?.settings, + overrideSettings.effectiveInsulinNeedsScaleFactor != 1.0 + { + self.warnings.insert(.overrideInProgress) + } else { self.warnings.remove(.overrideInProgress) } } diff --git a/Loop/View Models/ManualEntryDoseViewModel.swift b/Loop/View Models/ManualEntryDoseViewModel.swift index 5fcd966c62..269cd3b735 100644 --- a/Loop/View Models/ManualEntryDoseViewModel.swift +++ b/Loop/View Models/ManualEntryDoseViewModel.swift @@ -17,37 +17,22 @@ import LoopKitUI import LoopUI import SwiftUI -protocol ManualDoseViewModelDelegate: AnyObject { - - func withLoopState(do block: @escaping (LoopState) -> Void) - - func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) - - func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func insulinActivityDuration(for type: InsulinType?) -> TimeInterval +enum ManualEntryDoseViewModelError: Error { + case notAuthenticated +} - var mostRecentGlucoseDataDate: Date? { get } - - var mostRecentPumpDataDate: Date? { get } - - var isPumpConfigured: Bool { get } - - var preferredGlucoseUnit: HKUnit { get } - +protocol ManualDoseViewModelDelegate: AnyObject { + var algorithmDisplayState: AlgorithmDisplayState { get async } var pumpInsulinType: InsulinType? { get } + var settings: StoredSettings { get } + var scheduleOverride: TemporaryScheduleOverride? { get } - var settings: LoopSettings { get } + func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) async + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval } +@MainActor final class ManualEntryDoseViewModel: ObservableObject { - - var authenticate: AuthenticationChallenge = LocalAuthentication.deviceOwnerCheck - // MARK: - State @Published var glucoseValues: [GlucoseValue] = [] // stored glucose values @@ -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,41 +156,41 @@ final class ManualEntryDoseViewModel: ObservableObject { .removeDuplicates() .debounce(for: .milliseconds(400), scheduler: RunLoop.main) .sink { [weak self] _ in - self?.delegate?.withLoopState { [weak self] state in - self?.updatePredictedGlucoseValues(from: state) - } + self?.updateTriggered() } .store(in: &cancellables) } + private func updateTriggered() { + Task { @MainActor in + await updateFromLoopState() + } + } + + // MARK: - View API - func saveManualDose(onSuccess completion: @escaping () -> Void) { + func saveManualDose() async throws { + guard enteredBolus.doubleValue(for: .internationalUnit()) > 0 else { + return + } + // Authenticate before saving anything - if enteredBolus.doubleValue(for: .internationalUnit()) > 0 { - let message = String(format: NSLocalizedString("Authenticate to log %@ Units", comment: "The message displayed during a device authentication prompt to log an insulin dose"), enteredBolusAmountString) - authenticate(message) { - switch $0 { - case .success: - self.continueSaving(onSuccess: completion) - case .failure: - break - } - } - } else { - completion() + let message = String(format: NSLocalizedString("Authenticate to log %@ Units", comment: "The message displayed during a device authentication prompt to log an insulin dose"), enteredBolusAmountString) + + if !(await authenticationHandler(message)) { + throw ManualEntryDoseViewModelError.notAuthenticated } + await self.continueSaving() } - private func continueSaving(onSuccess completion: @escaping () -> Void) { + private func continueSaving() async { let doseVolume = enteredBolus.doubleValue(for: .internationalUnit()) guard doseVolume > 0 else { - completion() return } - delegate?.addManuallyEnteredDose(startDate: selectedDoseDate, units: doseVolume, insulinType: selectedInsulinType) - completion() + await delegate?.addManuallyEnteredDose(startDate: selectedDoseDate, units: doseVolume, insulinType: selectedInsulinType) } private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit()) @@ -218,117 +212,53 @@ final class ManualEntryDoseViewModel: ObservableObject { // MARK: - Data upkeep private func update() { - dispatchPrecondition(condition: .onQueue(.main)) // Prevent any UI updates after a bolus has been initiated. guard !isInitiatingSaveOrBolus else { return } updateChartDateInterval() - updateStoredGlucoseValues() - updateFromLoopState() - updateActiveInsulin() - } - - 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 enteredBolusDose = DoseEntry(type: .bolus, startDate: selectedDoseDate, value: enteredBolus.doubleValue(for: .internationalUnit()), unit: .units, insulinType: 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 = [] - } + self.activeInsulin = state.activeInsulin?.quantity + self.activeCarbs = state.activeCarbs?.quantity - DispatchQueue.main.async { - self.predictedGlucoseValues = predictedGlucoseValues - completion() - } - } - private func updateActiveInsulin() { - delegate?.insulinOnBoard(at: Date()) { [weak self] result in - guard let self = self else { return } + if let input = state.input { + self.storedGlucoseValues = input.glucoseHistory - DispatchQueue.main.async { - switch result { - case .success(let iob): - self.activeInsulin = HKQuantity(unit: .internationalUnit(), doubleValue: iob.value) - case .failure: - self.activeInsulin = nil - } - } - } - } - - 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 diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index d4b48766b3..16f5a71f72 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -164,6 +164,7 @@ public class SettingsViewModel: ObservableObject { } // For previews only +@MainActor extension SettingsViewModel { fileprivate class FakeClosedLoopAllowedPublisher { @Published var mockIsClosedLoopAllowed: Bool = false diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift index f803bfa595..ed13799b0f 100644 --- a/Loop/View Models/SimpleBolusViewModel.swift +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -18,30 +18,33 @@ import LocalAuthentication protocol SimpleBolusViewModelDelegate: AnyObject { - func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) - - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? , - completion: @escaping (_ result: Result) -> Void) + func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample + + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async - func enactBolus(units: Double, activationType: BolusActivationType) + func enactBolus(units: Double, activationType: BolusActivationType) async throws - func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) + func insulinOnBoard(at date: Date) async -> InsulinValue? func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? - var displayGlucosePreference: DisplayGlucosePreference { get } - - var maximumBolus: Double { get } + var maximumBolus: Double? { get } - var suspendThreshold: HKQuantity { get } + var suspendThreshold: HKQuantity? { get } } +@MainActor class SimpleBolusViewModel: ObservableObject { var authenticate: AuthenticationChallenge = LocalAuthentication.deviceOwnerCheck + // For testing + func setAuthenticationMethdod(_ authenticate: @escaping AuthenticationChallenge) { + self.authenticate = authenticate + } + enum Alert: Int { case carbEntryPersistenceFailure case manualGlucoseEntryPersistenceFailure @@ -93,7 +96,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 +107,11 @@ class SimpleBolusViewModel: ObservableObject { } private func updateNotice() { - + + guard let maxBolus = delegate.maximumBolus, let suspendThreshold = delegate.suspendThreshold else { + return + } + if let carbs = self.carbQuantity { guard carbs <= LoopConstants.maxCarbEntryQuantity else { activeNotice = .carbohydrateEntryTooLarge @@ -113,7 +120,7 @@ class SimpleBolusViewModel: ObservableObject { } if let bolus = bolus { - guard bolus.doubleValue(for: .internationalUnit()) <= delegate.maximumBolus else { + guard bolus.doubleValue(for: .internationalUnit()) <= maxBolus else { activeNotice = .maxBolusExceeded return } @@ -141,7 +148,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 +159,7 @@ class SimpleBolusViewModel: ObservableObject { @Published private var _manualGlucoseString: String = "" { didSet { - guard let manualGlucoseValue = delegate.displayGlucosePreference.formatter.numberFormatter.number(from: _manualGlucoseString)?.doubleValue + guard let manualGlucoseValue = displayGlucosePreference.formatter.numberFormatter.number(from: _manualGlucoseString)?.doubleValue else { manualGlucoseQuantity = nil return @@ -160,7 +167,7 @@ class SimpleBolusViewModel: ObservableObject { // if needed update manualGlucoseQuantity and related activeNotice if manualGlucoseQuantity == nil || - _manualGlucoseString != delegate.displayGlucosePreference.format(manualGlucoseQuantity!, includeUnit: false) + _manualGlucoseString != displayGlucosePreference.format(manualGlucoseQuantity!, includeUnit: false) { manualGlucoseQuantity = HKQuantity(unit: cachedDisplayGlucoseUnit, doubleValue: manualGlucoseValue) updateNotice() @@ -195,16 +202,18 @@ class SimpleBolusViewModel: ObservableObject { } return false } + + let displayGlucosePreference: DisplayGlucosePreference + + var displayGlucoseUnit: HKUnit { return displayGlucosePreference.unit } - var displayGlucoseUnit: HKUnit { return delegate.displayGlucosePreference.unit } - - var suspendThreshold: HKQuantity { return delegate.suspendThreshold } + var suspendThreshold: HKQuantity? { return delegate.suspendThreshold } private var recommendation: Double? = nil { didSet { - if let recommendation = recommendation { + if let recommendation = recommendation, let maxBolus = delegate.maximumBolus { recommendedBolus = Self.doseAmountFormatter.string(from: recommendation)! - enteredBolusString = Self.doseAmountFormatter.string(from: min(recommendation, delegate.maximumBolus))! + enteredBolusString = Self.doseAmountFormatter.string(from: min(recommendation, maxBolus))! } else { recommendedBolus = NSLocalizedString("–", comment: "String denoting lack of a recommended bolus amount in the simple bolus calculator") enteredBolusString = Self.doseAmountFormatter.string(from: 0.0)! @@ -271,14 +280,18 @@ class SimpleBolusViewModel: ObservableObject { private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit()) var maximumBolusAmountString: String { - let maxBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.maximumBolus) + guard let maxBolus = delegate.maximumBolus else { + return "" + } + let maxBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus) return bolusVolumeFormatter.string(from: maxBolusQuantity)! } - init(delegate: SimpleBolusViewModelDelegate, displayMealEntry: Bool) { + init(delegate: SimpleBolusViewModelDelegate, displayMealEntry: Bool, displayGlucosePreference: DisplayGlucosePreference) { self.delegate = delegate self.displayMealEntry = displayMealEntry - cachedDisplayGlucoseUnit = delegate.displayGlucosePreference.unit + self.displayGlucosePreference = displayGlucosePreference + cachedDisplayGlucoseUnit = displayGlucosePreference.unit enteredBolusString = Self.doseAmountFormatter.string(from: 0.0)! updateRecommendation() dosingDecision = BolusDosingDecision(for: .simpleBolus) @@ -323,121 +336,79 @@ class SimpleBolusViewModel: ObservableObject { } } - func saveAndDeliver(completion: @escaping (Bool) -> Void) { - + func saveAndDeliver() async -> Bool { + let saveDate = Date() - // Authenticate the bolus before saving anything - func authenticateIfNeeded(_ completion: @escaping (Bool) -> Void) { - if let bolus = bolus, bolus.doubleValue(for: .internationalUnit()) > 0 { - let message = String(format: NSLocalizedString("Authenticate to Bolus %@ Units", comment: "The message displayed during a device authentication prompt for bolus specification"), enteredBolusString) + // Authenticate if needed + if let bolus = bolus, bolus.doubleValue(for: .internationalUnit()) > 0 { + let message = String(format: NSLocalizedString("Authenticate to Bolus %@ Units", comment: "The message displayed during a device authentication prompt for bolus specification"), enteredBolusString) + let authenticated = await withCheckedContinuation { continuation in authenticate(message) { switch $0 { case .success: - completion(true) + continuation.resume(returning: true) case .failure: - completion(false) + continuation.resume(returning: false) } } - } else { - completion(true) } - } - - func saveManualGlucose(_ completion: @escaping (Bool) -> Void) { - if let manualGlucoseQuantity = manualGlucoseQuantity { - let manualGlucoseSample = NewGlucoseSample(date: saveDate, - quantity: manualGlucoseQuantity, - condition: nil, // All manual glucose entries are assumed to have no condition. - trend: nil, // All manual glucose entries are assumed to have no trend. - trendRate: nil, // All manual glucose entries are assumed to have no trend rate. - isDisplayOnly: false, - wasUserEntered: true, - syncIdentifier: UUID().uuidString) - delegate.addGlucose([manualGlucoseSample]) { result in - DispatchQueue.main.async { - switch result { - case .failure(let error): - self.presentAlert(.manualGlucoseEntryPersistenceFailure) - self.log.error("Failed to add manual glucose entry: %{public}@", String(describing: error)) - completion(false) - case .success(let storedSamples): - self.dosingDecision?.manualGlucoseSample = storedSamples.first - completion(true) - } - } - } - } else { - completion(true) + if !authenticated { + return false } } - - func saveCarbs(_ completion: @escaping (Bool) -> Void) { - if let carbs = carbQuantity { - - let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) - interaction.donate { [weak self] (error) in - if let error = error { - self?.log.error("Failed to donate intent: %{public}@", String(describing: error)) - } - } - - let carbEntry = NewCarbEntry(date: saveDate, quantity: carbs, startDate: saveDate, foodType: nil, absorptionTime: nil) - - delegate.addCarbEntry(carbEntry, replacing: nil) { result in - DispatchQueue.main.async { - switch result { - case .failure(let error): - self.presentAlert(.carbEntryPersistenceFailure) - self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) - completion(false) - case .success(let storedEntry): - self.dosingDecision?.carbEntry = storedEntry - completion(true) - } - } - } - } else { - completion(true) + + if let manualGlucoseQuantity = manualGlucoseQuantity { + let manualGlucoseSample = NewGlucoseSample(date: saveDate, + quantity: manualGlucoseQuantity, + condition: nil, // All manual glucose entries are assumed to have no condition. + trend: nil, // All manual glucose entries are assumed to have no trend. + trendRate: nil, // All manual glucose entries are assumed to have no trend rate. + isDisplayOnly: false, + wasUserEntered: true, + syncIdentifier: UUID().uuidString) + do { + self.dosingDecision?.manualGlucoseSample = try await delegate.saveGlucose(sample: manualGlucoseSample) + } catch { + self.presentAlert(.manualGlucoseEntryPersistenceFailure) + self.log.error("Failed to add manual glucose entry: %{public}@", String(describing: error)) + return false } } - func enactBolus() { - if let bolusVolume = bolus?.doubleValue(for: .internationalUnit()), bolusVolume > 0 { - delegate.enactBolus(units: bolusVolume, activationType: .activationTypeFor(recommendedAmount: recommendation, bolusAmount: bolusVolume)) - dosingDecision?.manualBolusRequested = bolusVolume + if let carbs = carbQuantity { + let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) + do { + try await interaction.donate() + } catch { + log.error("Failed to donate intent: %{public}@", String(describing: error)) } - } - - func saveBolusDecision() { - if let decision = dosingDecision, let recommendationDate = recommendationDate { - delegate.storeManualBolusDosingDecision(decision, withDate: recommendationDate) + + let carbEntry = NewCarbEntry(date: saveDate, quantity: carbs, startDate: saveDate, foodType: nil, absorptionTime: nil) + + do { + self.dosingDecision?.carbEntry = try await delegate.addCarbEntry(carbEntry, replacing: nil) + } catch { + self.presentAlert(.carbEntryPersistenceFailure) + self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) + return false } } - - func finishWithResult(_ success: Bool) { - saveBolusDecision() - completion(success) - } - - authenticateIfNeeded { (success) in - if success { - saveManualGlucose { (success) in - if success { - saveCarbs { (success) in - if success { - enactBolus() - } - finishWithResult(success) - } - } else { - finishWithResult(false) - } - } - } else { - finishWithResult(false) + + if let bolusVolume = bolus?.doubleValue(for: .internationalUnit()), bolusVolume > 0 { + do { + try await delegate.enactBolus(units: bolusVolume, activationType: .activationTypeFor(recommendedAmount: recommendation, bolusAmount: bolusVolume)) + dosingDecision?.manualBolusRequested = bolusVolume + } catch { + log.error("Unable to enact bolus: %{public}@", String(describing: error)) + return false } } + + if let decision = dosingDecision, let recommendationDate = recommendationDate { + await delegate.storeManualBolusDosingDecision(decision, withDate: recommendationDate) + } + return true } private func presentAlert(_ alert: Alert) { diff --git a/Loop/View Models/VersionUpdateViewModel.swift b/Loop/View Models/VersionUpdateViewModel.swift index fa2b87e6c5..72267c6651 100644 --- a/Loop/View Models/VersionUpdateViewModel.swift +++ b/Loop/View Models/VersionUpdateViewModel.swift @@ -12,6 +12,7 @@ import LoopKit import SwiftUI import LoopKitUI +@MainActor public class VersionUpdateViewModel: ObservableObject { @Published var versionUpdate: VersionUpdate? diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 4dd0c11a52..1d4d1e2c2a 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -73,6 +73,9 @@ struct BolusEntryView: View { enteredBolusStringBinding.wrappedValue = newEnteredBolusString } } + .task { + await self.viewModel.generateRecommendationAndStartObserving() + } } } diff --git a/Loop/Views/ManualEntryDoseView.swift b/Loop/Views/ManualEntryDoseView.swift index e81dccdabb..d361b48ad3 100644 --- a/Loop/Views/ManualEntryDoseView.swift +++ b/Loop/Views/ManualEntryDoseView.swift @@ -239,7 +239,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/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index aa7546c6f9..e0b413df53 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -252,12 +252,11 @@ struct SimpleBolusView: View { if self.viewModel.actionButtonAction == .enterBolus { self.shouldBolusEntryBecomeFirstResponder = true } else { - self.viewModel.saveAndDeliver { (success) in - if success { + Task { + if await viewModel.saveAndDeliver() { self.dismiss() } } - } }, label: { @@ -306,7 +305,7 @@ struct SimpleBolusView: View { } else { title = Text("No Bolus Recommended", comment: "Title for bolus screen warning when glucose is below suspend threshold, and a bolus is not recommended") } - let suspendThresholdString = formatGlucose(viewModel.suspendThreshold) + let suspendThresholdString = formatGlucose(viewModel.suspendThreshold!) return WarningView( title: title, caption: Text(String(format: NSLocalizedString("Your glucose is below your glucose safety limit, %1$@.", comment: "Format string for bolus screen warning when no bolus is recommended due input value below glucose safety limit. (1: suspendThreshold)"), suspendThresholdString)) @@ -362,13 +361,12 @@ struct SimpleBolusView: View { struct SimpleBolusCalculatorView_Previews: PreviewProvider { class MockSimpleBolusViewDelegate: SimpleBolusViewModelDelegate { - func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - completion(.success([])) + func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample { + return StoredGlucoseSample(startDate: sample.date, quantity: sample.quantity) } - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { - - let storedCarbEntry = StoredCarbEntry( + func addCarbEntry(_ carbEntry: LoopKit.NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { + StoredCarbEntry( startDate: carbEntry.startDate, quantity: carbEntry.quantity, uuid: UUID(), @@ -380,9 +378,12 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { createdByCurrentApp: true, userCreatedDate: Date(), userUpdatedDate: nil) - completion(.success(storedCarbEntry)) } + func insulinOnBoard(at date: Date) async -> LoopKit.InsulinValue? { + return nil + } + func enactBolus(units: Double, activationType: BolusActivationType) { } @@ -404,20 +405,24 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { return DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) } - var maximumBolus: Double { + var maximumBolus: Double? { return 6 } - var suspendThreshold: HKQuantity { + var suspendThreshold: HKQuantity? { return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 75) } } - static var viewModel: SimpleBolusViewModel = SimpleBolusViewModel(delegate: MockSimpleBolusViewDelegate(), displayMealEntry: true) - + static var previewViewModel: SimpleBolusViewModel = SimpleBolusViewModel( + delegate: MockSimpleBolusViewDelegate(), + displayMealEntry: true, + displayGlucosePreference: DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + ) + static var previews: some View { NavigationView { - SimpleBolusView(viewModel: viewModel) + SimpleBolusView(viewModel: previewViewModel) } .previewDevice("iPod touch (7th generation)") .environmentObject(DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter)) diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index 82ad76b6cc..1140f60c99 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -20,11 +20,6 @@ public extension AutomaticDosingStrategy { } public struct LoopSettings: Equatable { - public var isScheduleOverrideInfiniteWorkout: Bool { - guard let scheduleOverride = scheduleOverride else { return false } - return scheduleOverride.context == .legacyWorkout && scheduleOverride.duration.isInfinite - } - public var dosingEnabled = false public var glucoseTargetRangeSchedule: GlucoseRangeSchedule? @@ -41,30 +36,6 @@ public struct LoopSettings: Equatable { public var overridePresets: [TemporaryScheduleOverridePreset] = [] - public var scheduleOverride: TemporaryScheduleOverride? { - didSet { - if let newValue = scheduleOverride, newValue.context == .preMeal { - preconditionFailure("The `scheduleOverride` field should not be used for a pre-meal target range override; use `preMealOverride` instead") - } - - if scheduleOverride?.context == .legacyWorkout { - preMealOverride = nil - } - } - } - - public var preMealOverride: TemporaryScheduleOverride? { - didSet { - if let newValue = preMealOverride, newValue.context != .preMeal || newValue.settings.insulinNeedsScaleFactor != nil { - preconditionFailure("The `preMealOverride` field should be used only for a pre-meal target range override") - } - - if preMealOverride != nil, scheduleOverride?.context == .legacyWorkout { - scheduleOverride = nil - } - } - } - public var maximumBasalRatePerHour: Double? public var maximumBolus: Double? @@ -88,8 +59,6 @@ public struct LoopSettings: Equatable { preMealTargetRange: ClosedRange? = nil, legacyWorkoutTargetRange: ClosedRange? = nil, overridePresets: [TemporaryScheduleOverridePreset]? = nil, - scheduleOverride: TemporaryScheduleOverride? = nil, - preMealOverride: TemporaryScheduleOverride? = nil, maximumBasalRatePerHour: Double? = nil, maximumBolus: Double? = nil, suspendThreshold: GlucoseThreshold? = nil, @@ -104,8 +73,6 @@ public struct LoopSettings: Equatable { self.preMealTargetRange = preMealTargetRange self.legacyWorkoutTargetRange = legacyWorkoutTargetRange self.overridePresets = overridePresets ?? [] - self.scheduleOverride = scheduleOverride - self.preMealOverride = preMealOverride self.maximumBasalRatePerHour = maximumBasalRatePerHour self.maximumBolus = maximumBolus self.suspendThreshold = suspendThreshold @@ -114,105 +81,6 @@ public struct LoopSettings: Equatable { } } -extension LoopSettings { - public func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool = false) -> GlucoseRangeSchedule? { - - let preMealOverride = presumingMealEntry ? nil : self.preMealOverride - - let currentEffectiveOverride: TemporaryScheduleOverride? - switch (preMealOverride, scheduleOverride) { - case (let preMealOverride?, nil): - currentEffectiveOverride = preMealOverride - case (nil, let scheduleOverride?): - currentEffectiveOverride = scheduleOverride - case (let preMealOverride?, let scheduleOverride?): - currentEffectiveOverride = preMealOverride.scheduledEndDate > Date() - ? preMealOverride - : scheduleOverride - case (nil, nil): - currentEffectiveOverride = nil - } - - if let effectiveOverride = currentEffectiveOverride { - return glucoseTargetRangeSchedule?.applyingOverride(effectiveOverride) - } else { - return glucoseTargetRangeSchedule - } - } - - public func scheduleOverrideEnabled(at date: Date = Date()) -> Bool { - return scheduleOverride?.isActive(at: date) == true - } - - public func nonPreMealOverrideEnabled(at date: Date = Date()) -> Bool { - return scheduleOverride?.isActive(at: date) == true - } - - public func preMealTargetEnabled(at date: Date = Date()) -> Bool { - return preMealOverride?.isActive(at: date) == true - } - - public func futureOverrideEnabled(relativeTo date: Date = Date()) -> Bool { - guard let scheduleOverride = scheduleOverride else { return false } - return scheduleOverride.startDate > date - } - - public mutating func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { - preMealOverride = makePreMealOverride(beginningAt: date, for: duration) - } - - private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { - guard let preMealTargetRange = preMealTargetRange else { - return nil - } - return TemporaryScheduleOverride( - context: .preMeal, - settings: TemporaryScheduleOverrideSettings(targetRange: preMealTargetRange), - startDate: date, - duration: .finite(duration), - enactTrigger: .local, - syncIdentifier: UUID() - ) - } - - public mutating func enableLegacyWorkoutOverride(at date: Date = Date(), for duration: TimeInterval) { - scheduleOverride = legacyWorkoutOverride(beginningAt: date, for: duration) - preMealOverride = nil - } - - public mutating func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { - guard let legacyWorkoutTargetRange = legacyWorkoutTargetRange else { - return nil - } - - return TemporaryScheduleOverride( - context: .legacyWorkout, - settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), - startDate: date, - duration: duration.isInfinite ? .indefinite : .finite(duration), - enactTrigger: .local, - syncIdentifier: UUID() - ) - } - - public mutating func clearOverride(matching context: TemporaryScheduleOverride.Context? = nil) { - if context == .preMeal { - preMealOverride = nil - return - } - - guard let scheduleOverride = scheduleOverride else { return } - - if let context = context { - if scheduleOverride.context == context { - self.scheduleOverride = nil - } - } else { - self.scheduleOverride = nil - } - } -} - extension LoopSettings: RawRepresentable { public typealias RawValue = [String: Any] private static let version = 1 @@ -256,14 +124,6 @@ extension LoopSettings: RawRepresentable { self.overridePresets = rawPresets.compactMap(TemporaryScheduleOverridePreset.init(rawValue:)) } - if let rawPreMealOverride = rawValue["preMealOverride"] as? TemporaryScheduleOverride.RawValue { - self.preMealOverride = TemporaryScheduleOverride(rawValue: rawPreMealOverride) - } - - if let rawOverride = rawValue["scheduleOverride"] as? TemporaryScheduleOverride.RawValue { - self.scheduleOverride = TemporaryScheduleOverride(rawValue: rawOverride) - } - self.maximumBasalRatePerHour = rawValue["maximumBasalRatePerHour"] as? Double self.maximumBolus = rawValue["maximumBolus"] as? Double @@ -289,8 +149,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/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/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/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/Managers/Alerts/AlertManagerTests.swift b/LoopTests/Managers/Alerts/AlertManagerTests.swift index 5eeca9cebd..2250c1a16c 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) @@ -531,39 +387,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/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift new file mode 100644 index 0000000000..6872bf9590 --- /dev/null +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -0,0 +1,200 @@ +// +// DeviceDataManagerTests.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit +import LoopKitUI +@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 setUpWithError() throws { + let mockUserNotificationCenter = MockUserNotificationCenter() + let mockBluetoothProvider = MockBluetoothProvider() + let alertPresenter = MockPresenter() + let automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) + + let alertManager = AlertManager( + alertPresenter: alertPresenter, + userNotificationAlertScheduler: MockUserNotificationAlertScheduler(userNotificationCenter: mockUserNotificationCenter), + bluetoothProvider: mockBluetoothProvider, + analyticsServicesManager: AnalyticsServicesManager() + ) + + let persistenceController = PersistenceController.mock() + + let healthStore = HKHealthStore() + + let carbAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) + + let carbStore = CarbStore( + cacheStore: persistenceController, + cacheLength: .days(1), + defaultAbsorptionTimes: carbAbsorptionTimes + ) + + let doseStore = DoseStore( + cacheStore: persistenceController, + insulinModelProvider: PresetInsulinModelProvider(defaultRapidActingModel: nil) + ) + + let glucoseStore = 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(), + alertManager: alertManager, + settingsManager: settingsManager, + healthStore: healthStore, + carbStore: carbStore, + doseStore: doseStore, + glucoseStore: glucoseStore, + cgmEventStore: cgmEventStore, + uploadEventListener: uploadEventListener, + crashRecoveryManager: CrashRecoveryManager(alertIssuer: MockAlertIssuer()), + loopControl: loopControlMock, + analyticsServicesManager: AnalyticsServicesManager(), + activeServicesProvider: self, + activeStatefulPluginsProvider: self, + bluetoothProvider: mockBluetoothProvider, + alertPresenter: alertPresenter, + automaticDosingStatus: automaticDosingStatus, + cacheStore: persistenceController, + localCacheDuration: .days(1), + displayGlucosePreference: DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter), + displayGlucoseUnitBroadcaster: self + ) + + deviceDataManager.pumpManager = pumpManager + deviceDataManager.cgmManager = cgmManager + } + + func testValidateMaxTempBasalDoesntCancelTempBasalIfHigher() async throws { + let dose = DoseEntry( + type: .tempBasal, + startDate: Date(), + value: 3.0, + unit: .unitsPerHour, + automatic: true + ) + pumpManager.status.basalDeliveryState = .tempBasal(dose) + + let newLimits = DeliveryLimits( + maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: 5), + maximumBolus: nil + ) + let limits = try await deviceDataManager.syncDeliveryLimits(deliveryLimits: newLimits) + + XCTAssertNil(loopControlMock.lastCancelActiveTempBasalReason) + XCTAssertTrue(mockDecisionStore.dosingDecisions.isEmpty) + XCTAssertEqual(limits.maximumBasalRate, newLimits.maximumBasalRate) + } + + func testValidateMaxTempBasalCancelsTempBasalIfLower() async throws { + let dose = DoseEntry( + type: .tempBasal, + startDate: Date(), + endDate: nil, + value: 5.0, + unit: .unitsPerHour + ) + pumpManager.status.basalDeliveryState = .tempBasal(dose) + + let newLimits = DeliveryLimits( + maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: 3), + maximumBolus: nil + ) + let limits = try await deviceDataManager.syncDeliveryLimits(deliveryLimits: newLimits) + + XCTAssertEqual(.maximumBasalRateChanged, loopControlMock.lastCancelActiveTempBasalReason) + XCTAssertEqual(limits.maximumBasalRate, newLimits.maximumBasalRate) + } + + func testReceivedUnreliableCGMReadingCancelsTempBasal() { + let dose = DoseEntry( + type: .tempBasal, + startDate: Date(), + value: 5.0, + unit: .unitsPerHour + ) + pumpManager.status.basalDeliveryState = .tempBasal(dose) + + settingsManager.mutateLoopSettings { settings in + settings.basalRateSchedule = BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 3.0)]) + } + + loopControlMock.cancelExpectation = expectation(description: "Temp basal cancel") + + if let deviceManager = self.deviceDataManager { + cgmManager.delegateQueue.async { + deviceManager.cgmManager(self.cgmManager, hasNew: .unreliableData) + } + } + + wait(for: [loopControlMock.cancelExpectation!], timeout: 1) + + XCTAssertEqual(loopControlMock.lastCancelActiveTempBasalReason, .unreliableCGMData) + } + + func testUploadEventListener() { + let alertStore = AlertStore() + deviceDataManager.alertStoreHasUpdatedAlertData(alertStore) + XCTAssertEqual(uploadEventListener.lastUploadTriggeringType, .alert) + } + +} + +extension DeviceDataManagerTests: ActiveServicesProvider { + var activeServices: [LoopKit.Service] { + return [] + } + + +} + +extension DeviceDataManagerTests: ActiveStatefulPluginsProvider { + var activeStatefulPlugins: [LoopKit.StatefulPluggable] { + return [] + } +} + +extension DeviceDataManagerTests: DisplayGlucoseUnitBroadcaster { + func addDisplayGlucoseUnitObserver(_ observer: LoopKitUI.DisplayGlucoseUnitObserver) { + } + + func removeDisplayGlucoseUnitObserver(_ observer: LoopKitUI.DisplayGlucoseUnitObserver) { + } + + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { + } +} diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index 4820ecc869..eddfac1a9a 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -21,136 +21,9 @@ extension MockPumpManagerError: LocalizedError { } -class MockPumpManager: PumpManager { - - var enactBolusCalled: ((Double, BolusActivationType) -> Void)? - - var enactTempBasalCalled: ((Double, TimeInterval) -> Void)? - - var enactTempBasalError: PumpManagerError? - - init() { - - } - - // PumpManager implementation - static var onboardingMaximumBasalScheduleEntryCount: Int = 24 - - static var onboardingSupportedBasalRates: [Double] = [1,2,3] - - static var onboardingSupportedBolusVolumes: [Double] = [1,2,3] - - static var onboardingSupportedMaximumBolusVolumes: [Double] = [1,2,3] - - let deliveryUnitsPerMinute = 1.5 - - var supportedBasalRates: [Double] = [1,2,3] - - var supportedBolusVolumes: [Double] = [1,2,3] - - var supportedMaximumBolusVolumes: [Double] = [1,2,3] - - var maximumBasalScheduleEntryCount: Int = 24 - - var minimumBasalScheduleEntryDuration: TimeInterval = .minutes(30) - - var pumpManagerDelegate: PumpManagerDelegate? - - var pumpRecordsBasalProfileStartEvents: Bool = false - - var pumpReservoirCapacity: Double = 50 - - var lastSync: Date? - - var status: PumpManagerStatus = - PumpManagerStatus( - timeZone: TimeZone.current, - device: HKDevice(name: "MockPumpManager", manufacturer: nil, model: nil, hardwareVersion: nil, firmwareVersion: nil, softwareVersion: nil, localIdentifier: nil, udiDeviceIdentifier: nil), - pumpBatteryChargeRemaining: nil, - basalDeliveryState: nil, - bolusState: .noBolus, - insulinType: .novolog) - - func addStatusObserver(_ observer: PumpManagerStatusObserver, queue: DispatchQueue) { - } - - func removeStatusObserver(_ observer: PumpManagerStatusObserver) { - } - - func ensureCurrentPumpData(completion: ((Date?) -> Void)?) { - completion?(Date()) - } - - func setMustProvideBLEHeartbeat(_ mustProvideBLEHeartbeat: Bool) { - } - - func createBolusProgressReporter(reportingOn dispatchQueue: DispatchQueue) -> DoseProgressReporter? { - return nil - } - - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (PumpManagerError?) -> Void) { - enactBolusCalled?(units, activationType) - completion(nil) - } - - func cancelBolus(completion: @escaping (PumpManagerResult) -> Void) { - completion(.success(nil)) - } - - func enactTempBasal(unitsPerHour: Double, for duration: TimeInterval, completion: @escaping (PumpManagerError?) -> Void) { - enactTempBasalCalled?(unitsPerHour, duration) - completion(enactTempBasalError) - } - - func suspendDelivery(completion: @escaping (Error?) -> Void) { - completion(nil) - } - - func resumeDelivery(completion: @escaping (Error?) -> Void) { - completion(nil) - } - - func syncBasalRateSchedule(items scheduleItems: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { - } - - func syncDeliveryLimits(limits deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { - - } - - func estimatedDuration(toBolus units: Double) -> TimeInterval { - .minutes(units / deliveryUnitsPerMinute) - } - - var pluginIdentifier: String = "MockPumpManager" - - var localizedTitle: String = "MockPumpManager" - - var delegateQueue: DispatchQueue! - - required init?(rawState: RawStateValue) { - - } - - var rawState: RawStateValue = [:] - - var isOnboarded: Bool = true - - var debugDescription: String = "MockPumpManager" - - func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - } - - func getSoundBaseURL() -> URL? { - return nil - } - - func getSounds() -> [Alert.Sound] { - return [.sound(name: "doesntExist")] - } -} class DoseEnactorTests: XCTestCase { - func testBasalAndBolusDosedSerially() { + func testBasalAndBolusDosedSerially() async throws { let enactor = DoseEnactor() let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 0, duration: 0) // Cancel let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 1.5) @@ -165,15 +38,13 @@ class DoseEnactorTests: XCTestCase { pumpManager.enactBolusCalled = { (amount, automatic) in bolusExpectation.fulfill() } - - enactor.enact(recommendation: recommendation, with: pumpManager) { error in - XCTAssertNil(error) - } - - wait(for: [tempBasalExpectation, bolusExpectation], timeout: 5, enforceOrder: true) + + try await enactor.enact(recommendation: recommendation, with: pumpManager) + + await fulfillment(of: [tempBasalExpectation, bolusExpectation], timeout: 5, enforceOrder: true) } - func testBolusDoesNotIssueIfTempBasalAdjustmentFailed() { + func testBolusDoesNotIssueIfTempBasalAdjustmentFailed() async throws { let enactor = DoseEnactor() let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 0, duration: 0) // Cancel let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 1.5) @@ -190,14 +61,16 @@ class DoseEnactorTests: XCTestCase { pumpManager.enactTempBasalError = .configuration(MockPumpManagerError.failed) - enactor.enact(recommendation: recommendation, with: pumpManager) { error in - XCTAssertNotNil(error) + do { + try await enactor.enact(recommendation: recommendation, with: pumpManager) + XCTFail("Expected enact to throw error on failure.") + } catch { } - - waitForExpectations(timeout: 2) + + await fulfillment(of: [tempBasalExpectation]) } - func testTempBasalOnly() { + func testTempBasalOnly() async throws { let enactor = DoseEnactor() let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 1.2, duration: .minutes(30)) // Cancel let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 0) @@ -213,13 +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(recommendation: recommendation, with: pumpManager) + + await fulfillment(of: [tempBasalExpectation]) } diff --git a/LoopTests/Managers/LoopAlgorithmTests.swift b/LoopTests/Managers/LoopAlgorithmTests.swift index 084a72a3cf..e63f86bb46 100644 --- a/LoopTests/Managers/LoopAlgorithmTests.swift +++ b/LoopTests/Managers/LoopAlgorithmTests.swift @@ -60,6 +60,7 @@ final class LoopAlgorithmTests: XCTestCase { let input = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) let prediction = LoopAlgorithm.generatePrediction( + start: input.glucoseHistory.last?.startDate ?? Date(), glucoseHistory: input.glucoseHistory, doses: input.doses, carbEntries: input.carbEntries, @@ -80,4 +81,144 @@ final class LoopAlgorithmTests: XCTestCase { XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) } } + + func testAutoBolusMaxIOBClamping() async { + let now = ISO8601DateFormatter().date(from: "2020-03-11T12:13:14-0700")! + + var input = LoopAlgorithmInput.mock(for: now) + input.recommendationType = .automaticBolus + + // 8U bolus on board, and 100g carbs; CR = 10, so that should be 10U to cover the carbs + input.doses = [DoseEntry(type: .bolus, startDate: now.addingTimeInterval(-.minutes(5)), value: 8, unit: .units)] + input.carbEntries = [ + StoredCarbEntry(startDate: now.addingTimeInterval(.minutes(-5)), quantity: .carbs(value: 100)) + ] + + // Max activeInsulin = 2 x maxBolus = 16U + input.maxBolus = 8 + var output = LoopAlgorithm.run(input: input) + var recommendedBolus = output.recommendation!.automatic?.bolusUnits + var activeInsulin = output.activeInsulin! + XCTAssertEqual(activeInsulin, 8.0) + XCTAssertEqual(recommendedBolus!, 1.71, accuracy: 0.01) + + // Now try with maxBolus of 4; should not recommend any more insulin, as we're at our max iob + input.maxBolus = 4 + output = LoopAlgorithm.run(input: input) + recommendedBolus = output.recommendation!.automatic?.bolusUnits + activeInsulin = output.activeInsulin! + XCTAssertEqual(activeInsulin, 8.0) + XCTAssertEqual(recommendedBolus!, 0, accuracy: 0.01) + } + + func testTempBasalMaxIOBClamping() { + let now = ISO8601DateFormatter().date(from: "2020-03-11T12:13:14-0700")! + + var input = LoopAlgorithmInput.mock(for: now) + input.recommendationType = .tempBasal + + // 8U bolus on board, and 100g carbs; CR = 10, so that should be 10U to cover the carbs + input.doses = [DoseEntry(type: .bolus, startDate: now.addingTimeInterval(-.minutes(5)), value: 8, unit: .units)] + input.carbEntries = [ + StoredCarbEntry(startDate: now.addingTimeInterval(.minutes(-5)), quantity: .carbs(value: 100)) + ] + + // Max activeInsulin = 2 x maxBolus = 16U + input.maxBolus = 8 + var output = LoopAlgorithm.run(input: input) + var recommendedRate = output.recommendation!.automatic!.basalAdjustment!.unitsPerHour + var activeInsulin = output.activeInsulin! + XCTAssertEqual(activeInsulin, 8.0) + XCTAssertEqual(recommendedRate, 8.0, accuracy: 0.01) + + // Now try with maxBolus of 4; should only recommend scheduled basal (1U/hr), as we're at our max iob + input.maxBolus = 4 + output = LoopAlgorithm.run(input: input) + recommendedRate = output.recommendation!.automatic!.basalAdjustment!.unitsPerHour + activeInsulin = output.activeInsulin! + XCTAssertEqual(activeInsulin, 8.0) + XCTAssertEqual(recommendedRate, 1.0, accuracy: 0.01) + } +} + + +extension LoopAlgorithmInput { + static func mock(for date: Date, glucose: [Double] = [100, 120, 140, 160]) -> LoopAlgorithmInput { + + func d(_ interval: TimeInterval) -> Date { + return date.addingTimeInterval(interval) + } + + var input = LoopAlgorithmInput( + predictionStart: date, + glucoseHistory: [], + doses: [], + carbEntries: [], + basal: [], + sensitivity: [], + carbRatio: [], + target: [], + suspendThreshold: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 65), + maxBolus: 6, + maxBasalRate: 8, + recommendationInsulinType: .novolog, + recommendationType: .automaticBolus + ) + + for (idx, value) in glucose.enumerated() { + let entry = StoredGlucoseSample(startDate: d(.minutes(Double(-(glucose.count - idx)*5)) + .minutes(1)), quantity: .glucose(value: value)) + input.glucoseHistory.append(entry) + } + + input.doses = [ + DoseEntry(type: .bolus, startDate: d(.minutes(-3)), value: 1.0, unit: .units) + ] + + input.carbEntries = [ + StoredCarbEntry(startDate: d(.minutes(-4)), quantity: .carbs(value: 20)) + ] + + let forecastEndTime = date.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(.minutes(GlucoseMath.defaultDelta)) + let dosesStart = date.addingTimeInterval(-(CarbMath.maximumAbsorptionTimeInterval + InsulinMath.defaultInsulinActivityDuration)) + let carbsStart = date.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) + + + let basalRateSchedule = BasalRateSchedule( + dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: 1), + ], + timeZone: .utcTimeZone + )! + input.basal = basalRateSchedule.between(start: dosesStart, end: date) + + let insulinSensitivitySchedule = InsulinSensitivitySchedule( + unit: .milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: 45), + RepeatingScheduleValue(startTime: 32400, value: 55) + ], + timeZone: .utcTimeZone + )! + input.sensitivity = insulinSensitivitySchedule.quantitiesBetween(start: dosesStart, end: forecastEndTime) + + let carbRatioSchedule = CarbRatioSchedule( + unit: .gram(), + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: 10.0), + ], + timeZone: .utcTimeZone + )! + input.carbRatio = carbRatioSchedule.between(start: carbsStart, end: date) + + let targetSchedule = GlucoseRangeSchedule( + unit: .milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100, maxValue: 110)), + ], + timeZone: .utcTimeZone + )! + input.target = targetSchedule.quantityBetween(start: date, end: forecastEndTime) + return input + } } + diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift deleted file mode 100644 index 9cdb1f43cd..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.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 - )! - - let settings = LoopSettings( - dosingEnabled: false, - glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, - insulinSensitivitySchedule: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - carbRatioSchedule: carbRatioSchedule, - maximumBasalRatePerHour: 10, - maximumBolus: 5, - suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 65), - 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..2380ba701b 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -9,69 +9,12 @@ import XCTest import HealthKit import LoopKit +import HealthKit @testable import LoopCore @testable import Loop public typealias JSONDictionary = [String: Any] -enum DosingTestScenario { - case liveCapture // Includes actual dosing history, bg history, etc. - case flatAndStable - case highAndStable - case highAndRisingWithCOB - case lowAndFallingWithCOB - case lowWithLowTreatment - case highAndFalling - - var fixturePrefix: String { - switch self { - case .liveCapture: - return "live_capture_" - case .flatAndStable: - return "flat_and_stable_" - case .highAndStable: - return "high_and_stable_" - case .highAndRisingWithCOB: - return "high_rising_with_cob_" - case .lowAndFallingWithCOB: - return "low_and_falling_with_cob_" - case .lowWithLowTreatment: - return "low_with_low_treatment_" - case .highAndFalling: - return "high_and_falling_" - } - } - - static let localDateFormatter = ISO8601DateFormatter.localTimeDate() - - static var dateFormatter: ISO8601DateFormatter = { - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withInternetDateTime] - return dateFormatter - }() - - - var currentDate: Date { - switch self { - case .liveCapture: - return Self.dateFormatter.date(from: "2023-07-29T19:21:00Z")! - case .flatAndStable: - return Self.localDateFormatter.date(from: "2020-08-11T20:45:02")! - case .highAndStable: - return Self.localDateFormatter.date(from: "2020-08-12T12:39:22")! - case .highAndRisingWithCOB: - return Self.localDateFormatter.date(from: "2020-08-11T21:48:17")! - case .lowAndFallingWithCOB: - return Self.localDateFormatter.date(from: "2020-08-11T22:06:06")! - case .lowWithLowTreatment: - return Self.localDateFormatter.date(from: "2020-08-11T22:23:55")! - case .highAndFalling: - return Self.localDateFormatter.date(from: "2020-08-11T22:59:45")! - } - } - -} - extension TimeZone { static var fixtureTimeZone: TimeZone { return TimeZone(secondsFromGMT: 25200)! @@ -94,6 +37,7 @@ extension ISO8601DateFormatter { } } +@MainActor class LoopDataManagerTests: XCTestCase { // MARK: Constants for testing let retrospectiveCorrectionEffectDuration = TimeInterval(hours: 1) @@ -117,18 +61,23 @@ class LoopDataManagerTests: XCTestCase { ], timeZone: .utcTimeZone)! } - // MARK: Mock stores + // MARK: Stores var now: Date! + let persistenceController = PersistenceController.mock() + var doseStore = MockDoseStore() + var glucoseStore = MockGlucoseStore() + var carbStore = MockCarbStore() var dosingDecisionStore: MockDosingDecisionStore! var automaticDosingStatus: AutomaticDosingStatus! var loopDataManager: LoopDataManager! - - func setUp(for test: DosingTestScenario, - basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil, - maxBolus: Double = 10, - maxBasalRate: Double = 5.0, - dosingStrategy: AutomaticDosingStrategy = .tempBasalOnly) - { + var deliveryDelegate: MockDeliveryDelegate! + var settingsProvider: MockSettingsProvider! + + func d(_ interval: TimeInterval) -> Date { + return now.addingTimeInterval(interval) + } + + override func setUp() async throws { let basalRateSchedule = loadBasalRateScheduleFixture("basal_profile") let insulinSensitivitySchedule = InsulinSensitivitySchedule( unit: .milligramsPerDeciliter, @@ -146,54 +95,318 @@ class LoopDataManagerTests: XCTestCase { timeZone: .utcTimeZone )! - let settings = LoopSettings( + let settings = StoredSettings( dosingEnabled: false, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, - insulinSensitivitySchedule: insulinSensitivitySchedule, + maximumBasalRatePerHour: 6, + maximumBolus: 5, + suspendThreshold: suspendThreshold, basalRateSchedule: basalRateSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule, carbRatioSchedule: carbRatioSchedule, - maximumBasalRatePerHour: maxBasalRate, - maximumBolus: maxBolus, - suspendThreshold: suspendThreshold, - automaticDosingStrategy: dosingStrategy + automaticDosingStrategy: .automaticBolus ) - - let doseStore = MockDoseStore(for: test) - doseStore.basalProfile = basalRateSchedule - doseStore.basalProfileApplyingOverrideHistory = doseStore.basalProfile - doseStore.sensitivitySchedule = insulinSensitivitySchedule - let glucoseStore = MockGlucoseStore(for: test) - let carbStore = MockCarbStore(for: test) - carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule - carbStore.carbRatioSchedule = carbRatioSchedule - - let currentDate = glucoseStore.latestGlucose!.startDate - now = currentDate - + + settingsProvider = MockSettingsProvider(settings: settings) + + now = dateFormatter.date(from: "2023-07-29T19:21:00Z")! + dosingDecisionStore = MockDosingDecisionStore() automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) + + let temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsProvider) + loopDataManager = LoopDataManager( - lastLoopCompleted: currentDate, - basalDeliveryState: basalDeliveryState ?? .active(currentDate), - settings: settings, - overrideHistory: TemporaryScheduleOverrideHistory(), - analyticsServicesManager: AnalyticsServicesManager(), - localCacheDuration: .days(1), + lastLoopCompleted: now, + temporaryPresetsManager: temporaryPresetsManager, + settingsProvider: settingsProvider, doseStore: doseStore, glucoseStore: glucoseStore, carbStore: carbStore, dosingDecisionStore: dosingDecisionStore, - latestStoredSettingsProvider: MockLatestStoredSettingsProvider(), - now: { currentDate }, - pumpInsulinType: .novolog, + now: { [weak self] in self?.now ?? Date() }, automaticDosingStatus: automaticDosingStatus, - trustedTimeOffset: { 0 } + trustedTimeOffset: { 0 }, + analyticsServicesManager: nil, + carbAbsorptionModel: .piecewiseLinear ) + + deliveryDelegate = MockDeliveryDelegate() + loopDataManager.deliveryDelegate = deliveryDelegate + + deliveryDelegate.basalDeliveryState = .active(now.addingTimeInterval(-.hours(2))) } - + override func tearDownWithError() throws { loopDataManager = nil } + + // MARK: Functions to load fixtures + func loadLocalDateGlucoseEffect(_ name: String) -> [GlucoseEffect] { + let fixture: [JSONDictionary] = loadFixture(name) + let localDateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return GlucoseEffect(startDate: localDateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) + } + } + + func loadPredictedGlucoseFixture(_ name: String) -> [PredictedGlucoseValue] { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let url = bundle.url(forResource: name, withExtension: "json")! + return try! decoder.decode([PredictedGlucoseValue].self, from: try! Data(contentsOf: url)) + } + + // MARK: Tests + func testForecastFromLiveCaptureInputData() async { + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let url = bundle.url(forResource: "live_capture_input", withExtension: "json")! + let predictionInput = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) + + // Therapy settings in the "live capture" input only have one value, so we can fake some schedules + // from the first entry of each therapy setting's history. + let basalRateSchedule = BasalRateSchedule(dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: predictionInput.basal.first!.value) + ]) + let insulinSensitivitySchedule = InsulinSensitivitySchedule( + unit: .milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: predictionInput.sensitivity.first!.value.doubleValue(for: .milligramsPerDeciliter)) + ], + timeZone: .utcTimeZone + )! + let carbRatioSchedule = CarbRatioSchedule( + unit: .gram(), + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: predictionInput.carbRatio.first!.value) + ], + timeZone: .utcTimeZone + )! + + settingsProvider.settings = StoredSettings( + dosingEnabled: false, + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + maximumBasalRatePerHour: 10, + maximumBolus: 5, + suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 65), + basalRateSchedule: basalRateSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule, + carbRatioSchedule: carbRatioSchedule, + automaticDosingStrategy: .automaticBolus + ) + + glucoseStore.storedGlucose = predictionInput.glucoseHistory + + let currentDate = glucoseStore.latestGlucose!.startDate + now = currentDate + + doseStore.doseHistory = predictionInput.doses + doseStore.lastAddedPumpData = predictionInput.doses.last!.startDate + carbStore.carbHistory = predictionInput.carbEntries + + let expectedPredictedGlucose = loadPredictedGlucoseFixture("live_capture_predicted_glucose") + + await loopDataManager.updateDisplayState() + + let predictedGlucose = loopDataManager.displayState.output?.predictedGlucose + + XCTAssertNotNil(predictedGlucose) + + XCTAssertEqual(expectedPredictedGlucose.count, predictedGlucose!.count) + + for (expected, calculated) in zip(expectedPredictedGlucose, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + await loopDataManager.loop() + + XCTAssertEqual(0, deliveryDelegate.lastEnact?.bolusUnits) + XCTAssertEqual(0, deliveryDelegate.lastEnact?.basalAdjustment?.unitsPerHour) + } + + + func testHighAndStable() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 120)), + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(120, loopDataManager.eventualBG) + XCTAssert(loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + XCTAssertEqual(0.2, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + } + + + func testHighAndFalling() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 200)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 190)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 180)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 170)), + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(150, loopDataManager.eventualBG) + XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + // Should correct high. + XCTAssertEqual(0.4, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + } + + func testHighAndRisingWithCOB() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 200)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 210)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 220)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 230)), + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(250, loopDataManager.eventualBG) + XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + // Should correct high. + XCTAssertEqual(1.15, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + } + + func testLowAndFalling() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 100)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 95)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 90)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 85)), + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(75, loopDataManager.eventualBG!, accuracy: 1.0) + XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + // Should not bolus, and should low temp. + XCTAssertEqual(0, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + XCTAssertEqual(0, deliveryDelegate.lastEnact!.basalAdjustment!.unitsPerHour, accuracy: defaultAccuracy) + } + + + func testLowAndFallingWithCOB() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 100)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 95)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 90)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 85)), + ] + + carbStore.carbHistory = [ + StoredCarbEntry(startDate: d(.minutes(-5)), quantity: .carbs(value: 20)) + ] + + await loopDataManager.updateDisplayState() + + XCTAssertEqual(185, loopDataManager.eventualBG!, accuracy: 1.0) + XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) + + await loopDataManager.loop() + + // Because eventual is high, but mid-term is low, stay neutral in delivery. + XCTAssertEqual(0, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + XCTAssertNil(deliveryDelegate.lastEnact!.basalAdjustment) + } + + func testOpenLoopCancelsTempBasal() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)), + ] + + let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 1.0, unit: .unitsPerHour) + deliveryDelegate.basalDeliveryState = .tempBasal(dose) + + dosingDecisionStore.storeExpectation = expectation(description: #function) + + automaticDosingStatus.automaticDosingEnabled = false + + await fulfillment(of: [dosingDecisionStore.storeExpectation!], timeout: 1.0) + + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) + XCTAssertEqual(deliveryDelegate.lastEnact, expectedAutomaticDoseRecommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "automaticDosingDisabled") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + } + + func testLoopEnactsTempBasalWithoutManualBolusRecommendation() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)), + ] + + settingsProvider.settings.automaticDosingStrategy = .tempBasalOnly + + await loopDataManager.loop() + + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 3.0, duration: .minutes(30))) + XCTAssertEqual(deliveryDelegate.lastEnact, expectedAutomaticDoseRecommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + if dosingDecisionStore.dosingDecisions.count == 1 { + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) + } + } + + func testLoopRecommendsTempBasalWithoutEnactingIfOpenLoop() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)), + ] + automaticDosingStatus.automaticDosingEnabled = false + settingsProvider.settings.automaticDosingStrategy = .tempBasalOnly + + await loopDataManager.loop() + + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 3.0, duration: .minutes(30))) + XCTAssertNil(deliveryDelegate.lastEnact) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) + } + + func testLoopGetStateRecommendsManualBolusWithoutMomentum() async { + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 100)), + StoredGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 130)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 160)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 190)), + ] + + loopDataManager.usePositiveMomentumAndRCForManualBoluses = true + var recommendation = try! await loopDataManager.recommendManualBolus()! + XCTAssertEqual(recommendation.amount, 2.46, accuracy: 0.01) + + loopDataManager.usePositiveMomentumAndRCForManualBoluses = false + recommendation = try! await loopDataManager.recommendManualBolus()! + XCTAssertEqual(recommendation.amount, 1.73, accuracy: 0.01) + + } + + } extension LoopDataManagerTests { @@ -216,3 +429,20 @@ extension LoopDataManagerTests { return BasalRateSchedule(dailyItems: items, timeZone: .utcTimeZone)! } } + +extension HKQuantity { + static func glucose(value: Double) -> HKQuantity { + return .init(unit: .milligramsPerDeciliter, doubleValue: value) + } + + static func carbs(value: Double) -> HKQuantity { + return .init(unit: .gram(), doubleValue: value) + } + +} + +extension LoopDataManager { + var eventualBG: Double? { + displayState.output?.predictedGlucose.last?.quantity.doubleValue(for: .milligramsPerDeciliter) + } +} diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index 3db48cc7eb..2148821f54 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -180,65 +180,100 @@ 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: LoopAlgorithmInput! + var algorithmOutput: LoopAlgorithmOutput! + + 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 = LoopAlgorithmInput( + predictionStart: date, + glucoseHistory: [StoredGlucoseSample(startDate: date, quantity: .init(unit: .milligramsPerDeciliter, doubleValue: 100))], + doses: [], + carbEntries: testType.carbEntries.map { $0.asStoredCarbEntry }, + basal: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 1.0)])!.between(start: historyStart, end: date), + sensitivity: testType.insulinSensitivitySchedule.quantitiesBetween(start: historyStart, end: date), + carbRatio: testType.carbSchedule.between(start: historyStart, end: date), + target: glucoseTarget!.quantityBetween(start: historyStart, end: date), + suspendThreshold: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 65), + maxBolus: maximumBolus!, + maxBasalRate: maximumBasalRatePerHour, + recommendationInsulinType: .novolog, + 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 = LoopAlgorithmOutput( + recommendationResult: .success(.init()), + predictedGlucose: [], + effects: effects, + dosesRelativeToBasal: [] + ) + mealDetectionManager = MealDetectionManager( - carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory, - insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory, - maximumBolus: 5, - test_currentDate: testType.currentDate + algorithmStateProvider: self, + settingsProvider: self, + bolusStateProvider: self ) - - glucoseSamples = [MockGlucoseSample(startDate: now)] - - bolusDurationEstimator = { units in - self.bolusUnits = units - return self.pumpManager.estimatedDuration(toBolus: units) - } - - // Fetch & return the counteraction effects for the test - return counteractionEffects(for: testType) + mealDetectionManager.test_currentDate = testType.currentDate + } private func counteractionEffects(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] { @@ -253,27 +288,6 @@ class MealDetectionManagerTests: XCTestCase { } } - private func mealDetectionCarbEffects(using insulinCounteractionEffects: [GlucoseEffectVelocity]) -> [GlucoseEffect] { - let carbEffectStart = now.addingTimeInterval(-MissedMealSettings.maxRecency) - - var carbEffects: [GlucoseEffect] = [] - - let updateGroup = DispatchGroup() - updateGroup.enter() - carbStore.getGlucoseEffects(start: carbEffectStart, end: now, effectVelocities: insulinCounteractionEffects) { result in - defer { updateGroup.leave() } - - guard case .success((_, let effects)) = result else { - XCTFail("Failed to fetch glucose effects to check for missed meal") - return - } - carbEffects = effects - } - _ = updateGroup.wait(timeout: .now() + .seconds(5)) - - return carbEffects - } - override func tearDown() { mealDetectionManager.lastMissedMealNotification = nil mealDetectionManager = nil @@ -282,104 +296,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 +426,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 +441,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 +452,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 +466,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 +476,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 +487,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 +497,21 @@ class MealDetectionManagerTests: XCTestCase { func testMissedMealLongPendingBolus() { setUp(for: .notificationTest) UserDefaults.standard.missedMealNotificationsEnabled = true - + + bolusState = .inProgress( + DoseEntry( + type: .bolus, + startDate: now.addingTimeInterval(-.seconds(10)), + endDate: now.addingTimeInterval(.minutes(10)), + value: 20, + unit: .units, + automatic: true + ) + ) + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) - mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 10, bolusDurationEstimator: bolusDurationEstimator) - - XCTAssertEqual(bolusUnits, 10) + mealDetectionManager.manageMealNotifications(at: now, for: status) + /// There shouldn't be a delay in delivering notification, since the autobolus will take the length of the notification window to deliver XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) @@ -468,61 +520,104 @@ class MealDetectionManagerTests: XCTestCase { func testNoMissedMealShortPendingBolus_DelaysNotificationTime() { setUp(for: .notificationTest) UserDefaults.standard.missedMealNotificationsEnabled = true - + + bolusState = .inProgress( + DoseEntry( + type: .bolus, + startDate: now.addingTimeInterval(-.seconds(10)), + endDate: now.addingTimeInterval(20), + value: 2, + unit: .units, + automatic: true + ) + ) + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 30) - mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 2, bolusDurationEstimator: bolusDurationEstimator) - - let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(80)) - XCTAssertEqual(bolusUnits, 2) + mealDetectionManager.manageMealNotifications(at: now, for: status) + + let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(20)) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime) - + + bolusState = .inProgress( + DoseEntry( + type: .bolus, + startDate: now.addingTimeInterval(-.seconds(10)), + endDate: now.addingTimeInterval(.minutes(3)), + value: 4.5, + unit: .units, + automatic: true + ) + ) + mealDetectionManager.lastMissedMealNotification = nil - mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 4.5, bolusDurationEstimator: bolusDurationEstimator) - + mealDetectionManager.manageMealNotifications(at: now, for: status) + let expectedDeliveryTime2 = now.addingTimeInterval(TimeInterval(minutes: 3)) - XCTAssertEqual(bolusUnits, 4.5) XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime2) } func testHasCalibrationPoints_NoNotification() { let testType = MissedMealTestType.manyMeals - let counteractionEffects = setUp(for: testType) + setUp(for: testType) let calibratedGlucoseSamples = [MockGlucoseSample(startDate: now), MockGlucoseSample(startDate: now, isDisplayOnly: true)] - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: calibratedGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() - + var status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: calibratedGlucoseSamples, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .noMissedMeal) + let manualGlucoseSamples = [MockGlucoseSample(startDate: now), MockGlucoseSample(startDate: now, wasUserEntered: true)] - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: manualGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .noMissedMeal) - updateGroup.leave() - } - updateGroup.wait() + + status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: manualGlucoseSamples, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .noMissedMeal) } func testHasTooOldCalibrationPoint_NoImpactOnNotificationDelivery() { let testType = MissedMealTestType.manyMeals - let counteractionEffects = setUp(for: testType) + setUp(for: testType) let tooOldCalibratedGlucoseSamples = [MockGlucoseSample(startDate: now, isDisplayOnly: false), MockGlucoseSample(startDate: now.addingTimeInterval(-MissedMealSettings.maxRecency-1), isDisplayOnly: true)] - let updateGroup = DispatchGroup() - updateGroup.enter() - mealDetectionManager.hasMissedMeal(glucoseSamples: tooOldCalibratedGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in - XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) - updateGroup.leave() + let status = mealDetectionManager.hasMissedMeal( + at: now, + glucoseSamples: tooOldCalibratedGlucoseSamples, + insulinCounteractionEffects: algorithmOutput.effects.insulinCounteraction, + carbEffects: algorithmOutput.effects.carbs, + sensitivitySchedule: insulinSensitivityScheduleApplyingOverrideHistory!, + carbRatioSchedule: carbRatioSchedule! + ) + + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) + } +} + +extension MealDetectionManagerTests: AlgorithmDisplayStateProvider { + var algorithmState: AlgorithmDisplayState { + get async { + return mockAlgorithmState } - updateGroup.wait() } } +extension MealDetectionManagerTests: BolusStateProvider { } + +extension MealDetectionManagerTests: SettingsWithOverridesProvider { } + extension MealDetectionManagerTests { public var bundle: Bundle { return Bundle(for: type(of: self)) diff --git a/LoopTests/Managers/SettingsManagerTests.swift b/LoopTests/Managers/SettingsManagerTests.swift new file mode 100644 index 0000000000..a4768bcd28 --- /dev/null +++ b/LoopTests/Managers/SettingsManagerTests.swift @@ -0,0 +1,35 @@ +// +// SettingsManager.swift +// LoopTests +// +// Created by Pete Schwamb on 12/1/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +@testable import Loop + +@MainActor +final class SettingsManagerTests: XCTestCase { + + + func testChangingMaxBasalUpdatesLoopData() async { + + let persistenceController = PersistenceController.mock() + + let settingsManager = SettingsManager(cacheStore: persistenceController, expireAfter: .days(1), alertMuter: AlertMuter()) + + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in + exp.fulfill() + } + + settingsManager.mutateLoopSettings { $0.maximumBasalRatePerHour = 2.0 } + + await fulfillment(of: [exp], timeout: 1.0) + NotificationCenter.default.removeObserver(observer) + } + + +} diff --git a/LoopTests/Managers/SupportManagerTests.swift b/LoopTests/Managers/SupportManagerTests.swift index 8106b33005..54471521ae 100644 --- a/LoopTests/Managers/SupportManagerTests.swift +++ b/LoopTests/Managers/SupportManagerTests.swift @@ -12,6 +12,7 @@ import LoopKitUI import SwiftUI @testable import Loop +@MainActor class SupportManagerTests: XCTestCase { enum MockError: Error { case nothing } @@ -66,14 +67,15 @@ class SupportManagerTests: XCTestCase { } class MockDeviceSupportDelegate: DeviceSupportDelegate { + var availableSupports: [LoopKitUI.SupportUI] = [] var pumpManagerStatus: LoopKit.PumpManagerStatus? var cgmManagerStatus: LoopKit.CGMManagerStatus? - func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { - completion("Mock Issue Report") + func generateDiagnosticReport() async -> String { + "Mock Issue Report" } } diff --git a/LoopTests/LoopSettingsTests.swift b/LoopTests/Managers/TemporaryPresetsManagerTests.swift similarity index 64% rename from LoopTests/LoopSettingsTests.swift rename to LoopTests/Managers/TemporaryPresetsManagerTests.swift index a0ad8f4503..60da1a21c2 100644 --- a/LoopTests/LoopSettingsTests.swift +++ b/LoopTests/Managers/TemporaryPresetsManagerTests.swift @@ -1,22 +1,22 @@ // -// LoopSettingsTests.swift +// TemporaryPresetsManagerTests.swift // LoopTests // -// Created by Michael Pangburn on 3/1/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. +// Created by Pete Schwamb on 12/11/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. // import XCTest -import LoopCore import LoopKit +@testable import Loop -class LoopSettingsTests: XCTestCase { +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: LoopSettings = { - var settings = LoopSettings() + + private lazy var settings: StoredSettings = { + var settings = StoredSettings() settings.preMealTargetRange = preMealRange settings.glucoseTargetRangeSchedule = GlucoseRangeSchedule( unit: .milligramsPerDeciliter, @@ -24,20 +24,27 @@ class LoopSettingsTests: XCTestCase { ) return settings }() - + + var manager: TemporaryPresetsManager! + + override func setUp() async throws { + let settingsProvider = MockSettingsProvider(settings: settings) + manager = TemporaryPresetsManager(settingsProvider: settingsProvider) + } + 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)) + manager.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) + let actualPreMealRange = manager.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) XCTAssertEqual(preMealRange, actualPreMealRange) } - + func testPreMealOverrideWithPotentialCarbEntry() { 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)) + manager.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) + let actualRange = manager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: true)?.value(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) XCTAssertEqual(targetRange, actualRange) } @@ -56,15 +63,15 @@ class LoopSettingsTests: XCTestCase { enactTrigger: .local, syncIdentifier: UUID() ) - settings.scheduleOverride = override - let actualOverrideRange = settings.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(30 /* minutes */ * 60)) + manager.scheduleOverride = override + let actualOverrideRange = manager.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) + manager.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) let overrideStart = Date() let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) @@ -79,19 +86,19 @@ class LoopSettingsTests: XCTestCase { enactTrigger: .local, syncIdentifier: UUID() ) - settings.scheduleOverride = override + manager.scheduleOverride = override - let actualPreMealRange = settings.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) + let actualPreMealRange = manager.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)) + let preMealRangeDuringOverride = manager.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(2 /* hours */ * 60 * 60)) XCTAssertEqual(preMealRangeDuringOverride, preMealRange) } func testScheduleOverrideWithExpiredPreMealOverride() { var settings = self.settings - settings.preMealOverride = TemporaryScheduleOverride( + manager.preMealOverride = TemporaryScheduleOverride( context: .preMeal, settings: TemporaryScheduleOverrideSettings(targetRange: preMealRange), startDate: Date(timeIntervalSinceNow: -2 /* hours */ * 60 * 60), @@ -113,9 +120,9 @@ class LoopSettingsTests: XCTestCase { enactTrigger: .local, syncIdentifier: UUID() ) - settings.scheduleOverride = override + manager.scheduleOverride = override - let actualOverrideRange = settings.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(2 /* hours */ * 60 * 60)) + let actualOverrideRange = manager.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(2 /* hours */ * 60 * 60)) XCTAssertEqual(actualOverrideRange, overrideTargetRange) } } diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 4a5c016eb5..83ef9dc4d4 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -8,170 +8,38 @@ import HealthKit import LoopKit +import LoopCore @testable import Loop class MockCarbStore: CarbStoreProtocol { - var carbHistory: [StoredCarbEntry]? + var defaultAbsorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes - init(for scenario: DosingTestScenario = .flatAndStable) { - self.scenario = scenario // The store returns different effect values based on the scenario - self.carbHistory = loadHistoricCarbEntries(scenario: scenario) - } - - var scenario: DosingTestScenario - - var sampleType: HKSampleType = HKSampleType.quantityType(forIdentifier: HKQuantityTypeIdentifier.dietaryCarbohydrates)! - - var preferredUnit: HKUnit! = .gram() - - var delegate: CarbStoreDelegate? - - var carbRatioSchedule: CarbRatioSchedule? - - var insulinSensitivitySchedule: InsulinSensitivitySchedule? - - var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? = InsulinSensitivitySchedule( - unit: HKUnit.milligramsPerDeciliter, - dailyItems: [ - RepeatingScheduleValue(startTime: 0.0, value: 45.0), - RepeatingScheduleValue(startTime: 32400.0, value: 55.0) - ], - timeZone: .utcTimeZone - )! - - var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? = CarbRatioSchedule( - unit: .gram(), - dailyItems: [ - RepeatingScheduleValue(startTime: 0.0, value: 10.0), - RepeatingScheduleValue(startTime: 32400.0, value: 12.0) - ], - timeZone: .utcTimeZone - )! - - var maximumAbsorptionTimeInterval: TimeInterval { - return defaultAbsorptionTimes.slow * 2 - } - - var delta: TimeInterval = .minutes(5) - - var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) - - var authorizationRequired: Bool = false - - var sharingDenied: Bool = false - - func authorize(toShare: Bool, read: Bool, _ completion: @escaping (HealthKitSampleStoreResult) -> Void) { - completion(.success(true)) - } - - func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbStatus]>) -> Void) { - completion(.failure(.notConfigured)) - } - - func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { - completion("") - } - - func glucoseEffects(of samples: [Sample], startingAt start: Date, endingAt end: Date?, effectVelocities: [LoopKit.GlucoseEffectVelocity]) throws -> [LoopKit.GlucoseEffect] where Sample : LoopKit.CarbEntry { - return [] - } - - func getCarbsOnBoardValues(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbValue]>) -> Void) { - completion(.success([])) - } - - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func getTotalCarbs(since start: Date, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (CarbStoreResult) -> Void) { - completion(.failure(.notConfigured)) - } - - func getGlucoseEffects(start: Date, end: Date?, effectVelocities: [LoopKit.GlucoseEffectVelocity], completion: @escaping (LoopKit.CarbStoreResult<(entries: [LoopKit.StoredCarbEntry], effects: [LoopKit.GlucoseEffect])>) -> Void) - { - if let carbHistory, let carbRatioScheduleApplyingOverrideHistory, let insulinSensitivityScheduleApplyingOverrideHistory { - let foodStart = start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) - let samples = carbHistory.filterDateRange(foodStart, end) - let carbDates = samples.map { $0.startDate } - let maxCarbDate = carbDates.max()! - let minCarbDate = carbDates.min()! - let carbRatio = carbRatioScheduleApplyingOverrideHistory.between(start: minCarbDate, end: maxCarbDate) - let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory.quantitiesBetween(start: minCarbDate, end: maxCarbDate) - let effects = samples.map( - to: effectVelocities, - carbRatio: carbRatio, - insulinSensitivity: insulinSensitivity - ).dynamicGlucoseEffects( - from: start, - to: end, - carbRatios: carbRatio, - insulinSensitivities: insulinSensitivity - ) - completion(.success((entries: samples, effects: effects))) + var carbHistory: [StoredCarbEntry] = [] - } else { - let fixture: [JSONDictionary] = loadFixture(fixtureToLoad) - - let dateFormatter = ISO8601DateFormatter.localTimeDate() - - return completion(.success(([], fixture.map { - return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) - }))) - } + func getCarbEntries(start: Date?, end: Date?) 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..985ac687fe 100644 --- a/LoopTests/Mock Stores/MockDoseStore.swift +++ b/LoopTests/Mock Stores/MockDoseStore.swift @@ -11,161 +11,26 @@ import LoopKit @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 getDoses(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 addedDoses: [DoseEntry] = [] - var insulinSensitivitySchedule: InsulinSensitivitySchedule? - - var sampleType: HKSampleType = HKQuantityType.quantityType(forIdentifier: .insulinDelivery)! - - var authorizationRequired: Bool = false - - var sharingDenied: Bool = false - - var lastReservoirValue: ReservoirValue? - - var lastAddedPumpData: Date - - 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 -> LoopKit.InsulinValue { + return InsulinValue(startDate: lastAddedPumpData, value: 0) } - var fixtureToLoad: String { - switch scenario { - case .liveCapture: - fatalError("live capture scenario computes effects from doses, does not used pre-canned effects") - case .flatAndStable: - return "flat_and_stable_insulin_effect" - case .highAndStable: - return "high_and_stable_insulin_effect" - case .highAndRisingWithCOB: - return "high_and_rising_with_cob_insulin_effect" - case .lowAndFallingWithCOB: - return "low_and_falling_insulin_effect" - case .lowWithLowTreatment: - return "low_with_low_treatment_insulin_effect" - case .highAndFalling: - return "high_and_falling_insulin_effect" - } - } + var lastAddedPumpData = Date.distantPast - public func loadHistoricDoses(scenario: DosingTestScenario) -> [DoseEntry]? { - if let url = bundle.url(forResource: scenario.fixturePrefix + "doses", withExtension: "json"), - let data = try? Data(contentsOf: url) - { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try? decoder.decode([DoseEntry].self, from: data) - } else { - return nil - } - } + var doseHistory: [DoseEntry]? + static let dateFormatter = ISO8601DateFormatter.localTimeDate() + } diff --git a/LoopTests/Mock Stores/MockDosingDecisionStore.swift b/LoopTests/Mock Stores/MockDosingDecisionStore.swift index f8e4191d8e..f13734326a 100644 --- a/LoopTests/Mock Stores/MockDosingDecisionStore.swift +++ b/LoopTests/Mock Stores/MockDosingDecisionStore.swift @@ -7,13 +7,34 @@ // import LoopKit +import XCTest @testable import Loop class MockDosingDecisionStore: DosingDecisionStoreProtocol { + var delegate: LoopKit.DosingDecisionStoreDelegate? + + var exportName: String = "MockDosingDecision" + + func exportProgressTotalUnitCount(startDate: Date, endDate: Date?) -> Result { + return .success(1) + } + + func export(startDate: Date, endDate: Date, to stream: LoopKit.DataOutputStream, progress: Progress) -> Error? { + return nil + } + var dosingDecisions: [StoredDosingDecision] = [] - func storeDosingDecision(_ dosingDecision: StoredDosingDecision, completion: @escaping () -> Void) { + var storeExpectation: XCTestExpectation? + + func storeDosingDecision(_ dosingDecision: StoredDosingDecision) async { dosingDecisions.append(dosingDecision) - completion() + storeExpectation?.fulfill() + } + + func executeDosingDecisionQuery(fromQueryAnchor queryAnchor: LoopKit.DosingDecisionStore.QueryAnchor?, limit: Int, completion: @escaping (LoopKit.DosingDecisionStore.DosingDecisionQueryResult) -> Void) { + if let queryAnchor { + completion(.success(queryAnchor, [])) + } } } diff --git a/LoopTests/Mock Stores/MockGlucoseStore.swift b/LoopTests/Mock Stores/MockGlucoseStore.swift index 19a6bc22e8..064f3c0fba 100644 --- a/LoopTests/Mock Stores/MockGlucoseStore.swift +++ b/LoopTests/Mock Stores/MockGlucoseStore.swift @@ -11,105 +11,22 @@ import LoopKit @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..d13c0663db --- /dev/null +++ b/LoopTests/Mocks/AlertMocks.swift @@ -0,0 +1,192 @@ +// +// AlertMocks.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopKit +@testable import Loop + +class MockBluetoothProvider: BluetoothProvider { + var bluetoothAuthorization: BluetoothAuthorization = .authorized + + var bluetoothState: BluetoothState = .poweredOn + + func authorizeBluetooth(_ completion: @escaping (BluetoothAuthorization) -> Void) { + completion(bluetoothAuthorization) + } + + func addBluetoothObserver(_ observer: BluetoothObserver, queue: DispatchQueue) { + } + + func removeBluetoothObserver(_ observer: BluetoothObserver) { + } +} + +class MockModalAlertScheduler: InAppModalAlertScheduler { + var scheduledAlert: Alert? + override func scheduleAlert(_ alert: Alert) { + scheduledAlert = alert + } + var unscheduledAlertIdentifier: Alert.Identifier? + override func unscheduleAlert(identifier: Alert.Identifier) { + unscheduledAlertIdentifier = identifier + } +} + +class MockUserNotificationAlertScheduler: UserNotificationAlertScheduler { + var scheduledAlert: Alert? + var muted: Bool? + + override func scheduleAlert(_ alert: Alert, muted: Bool) { + scheduledAlert = alert + self.muted = muted + } + var unscheduledAlertIdentifier: Alert.Identifier? + override func unscheduleAlert(identifier: Alert.Identifier) { + unscheduledAlertIdentifier = identifier + } +} + +class MockResponder: AlertResponder { + var acknowledged: [Alert.AlertIdentifier: Bool] = [:] + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + completion(nil) + acknowledged[alertIdentifier] = true + } +} + +class MockFileManager: FileManager { + + var fileExists = true + let newer = Date() + let older = Date.distantPast + + var createdDirURL: URL? + override func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { + createdDirURL = url + } + override func fileExists(atPath path: String) -> Bool { + return !path.contains("doesntExist") + } + override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey : Any] { + return path.contains("Sounds") ? path.contains("existsNewer") ? [.creationDate: newer] : [.creationDate: older] : + [.creationDate: newer] + } + var removedURLs = [URL]() + override func removeItem(at URL: URL) throws { + removedURLs.append(URL) + } + var copiedSrcURLs = [URL]() + var copiedDstURLs = [URL]() + override func copyItem(at srcURL: URL, to dstURL: URL) throws { + copiedSrcURLs.append(srcURL) + copiedDstURLs.append(dstURL) + } + override func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] { + return [] + } +} + +class MockPresenter: AlertPresenter { + func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { completion?() } + func dismissTopMost(animated: Bool, completion: (() -> Void)?) { completion?() } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { completion?() } +} + +class MockAlertManagerResponder: AlertManagerResponder { + func acknowledgeAlert(identifier: LoopKit.Alert.Identifier) { } +} + +class MockSoundVendor: AlertSoundVendor { + func getSoundBaseURL() -> URL? { + // Hm. It's not easy to make a "fake" URL, so we'll use this one: + return Bundle.main.resourceURL + } + + func getSounds() -> [Alert.Sound] { + return [.sound(name: "doesntExist"), .sound(name: "existsNewer"), .sound(name: "existsOlder")] + } +} + +class MockAlertStore: AlertStore { + + var issuedAlert: Alert? + override public func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { + issuedAlert = alert + completion?(.success) + } + + var retractedAlert: Alert? + var retractedAlertDate: Date? + override public func recordRetractedAlert(_ alert: Alert, at date: Date, completion: ((Result) -> Void)? = nil) { + retractedAlert = alert + retractedAlertDate = date + completion?(.success) + } + + var acknowledgedAlertIdentifier: Alert.Identifier? + var acknowledgedAlertDate: Date? + override public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date(), + completion: ((Result) -> Void)? = nil) { + acknowledgedAlertIdentifier = identifier + acknowledgedAlertDate = date + completion?(.success) + } + + var retractededAlertIdentifier: Alert.Identifier? + override public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date(), + completion: ((Result) -> Void)? = nil) { + retractededAlertIdentifier = identifier + retractedAlertDate = date + completion?(.success) + } + + var storedAlerts = [StoredAlert]() + override public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + completion(.success(storedAlerts)) + } + + override public func lookupAllUnretracted(managerIdentifier: String?, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + completion(.success(storedAlerts)) + } +} + +class MockUserNotificationCenter: UserNotificationCenter { + + var pendingRequests = [UNNotificationRequest]() + var deliveredRequests = [UNNotificationRequest]() + + func add(_ request: UNNotificationRequest, withCompletionHandler completionHandler: ((Error?) -> Void)? = nil) { + pendingRequests.append(request) + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + identifiers.forEach { identifier in + pendingRequests.removeAll { $0.identifier == identifier } + } + } + + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { + identifiers.forEach { identifier in + deliveredRequests.removeAll { $0.identifier == identifier } + } + } + + func deliverAll() { + deliveredRequests = pendingRequests + pendingRequests = [] + } + + func getDeliveredNotifications(completionHandler: @escaping ([UNNotification]) -> Void) { + // Sadly, we can't create UNNotifications. + completionHandler([]) + } + + func getPendingNotificationRequests(completionHandler: @escaping ([UNNotificationRequest]) -> Void) { + completionHandler(pendingRequests) + } +} diff --git a/LoopTests/Mocks/LoopControlMock.swift b/LoopTests/Mocks/LoopControlMock.swift new file mode 100644 index 0000000000..29be4a17bb --- /dev/null +++ b/LoopTests/Mocks/LoopControlMock.swift @@ -0,0 +1,28 @@ +// +// LoopControlMock.swift +// LoopTests +// +// Created by Pete Schwamb on 11/30/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import Foundation +@testable import Loop + + +class LoopControlMock: LoopControl { + var lastLoopCompleted: Date? + + var lastCancelActiveTempBasalReason: CancelActiveTempBasalReason? + + var cancelExpectation: XCTestExpectation? + + func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async { + lastCancelActiveTempBasalReason = reason + cancelExpectation?.fulfill() + } + + func loop() async { + } +} diff --git a/LoopTests/Mocks/MockCGMManager.swift b/LoopTests/Mocks/MockCGMManager.swift new file mode 100644 index 0000000000..38e6d6a140 --- /dev/null +++ b/LoopTests/Mocks/MockCGMManager.swift @@ -0,0 +1,63 @@ +// +// MockCGMManager.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +class MockCGMManager: CGMManager { + var cgmManagerDelegate: LoopKit.CGMManagerDelegate? + + var providesBLEHeartbeat: Bool = false + + var managedDataInterval: TimeInterval? + + var shouldSyncToRemoteService: Bool = true + + var glucoseDisplay: LoopKit.GlucoseDisplayable? + + var cgmManagerStatus: LoopKit.CGMManagerStatus { + return CGMManagerStatus(hasValidSensorSession: true, device: nil) + } + + var delegateQueue: DispatchQueue! + + func fetchNewDataIfNeeded(_ completion: @escaping (LoopKit.CGMReadingResult) -> Void) { + completion(.noData) + } + + var localizedTitle: String = "MockCGMManager" + + init() { + } + + required init?(rawState: RawStateValue) { + } + + var rawState: RawStateValue { + return [:] + } + + var isOnboarded: Bool = true + + var debugDescription: String = "MockCGMManager" + + func acknowledgeAlert(alertIdentifier: LoopKit.Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func getSoundBaseURL() -> URL? { + return nil + } + + func getSounds() -> [LoopKit.Alert.Sound] { + return [] + } + + var pluginIdentifier: String = "MockCGMManager" + +} diff --git a/LoopTests/Mocks/MockDeliveryDelegate.swift b/LoopTests/Mocks/MockDeliveryDelegate.swift new file mode 100644 index 0000000000..bc14f03f00 --- /dev/null +++ b/LoopTests/Mocks/MockDeliveryDelegate.swift @@ -0,0 +1,45 @@ +// +// MockDeliveryDelegate.swift +// LoopTests +// +// Created by Pete Schwamb on 12/1/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +@testable import Loop + +class MockDeliveryDelegate: DeliveryDelegate { + var isSuspended: Bool = false + + var pumpInsulinType: InsulinType? + + var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? + + var isPumpConfigured: Bool = true + + var lastEnact: AutomaticDoseRecommendation? + + func enact(_ recommendation: AutomaticDoseRecommendation) async throws { + lastEnact = recommendation + } + + var lastBolus: Double? + var lastBolusActivationType: BolusActivationType? + + func enactBolus(units: Double, activationType: BolusActivationType) async throws { + lastBolus = units + lastBolusActivationType = activationType + } + + func roundBasalRate(unitsPerHour: Double) -> Double { + (unitsPerHour * 20).rounded() / 20.0 + } + + func roundBolusVolume(units: Double) -> Double { + (units * 20).rounded() / 20.0 + } + + +} diff --git a/LoopTests/Mocks/MockPumpManager.swift b/LoopTests/Mocks/MockPumpManager.swift new file mode 100644 index 0000000000..70131ab674 --- /dev/null +++ b/LoopTests/Mocks/MockPumpManager.swift @@ -0,0 +1,141 @@ +// +// MockPumpManager.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopKitUI +import HealthKit +@testable import Loop + +class MockPumpManager: PumpManager { + + var enactBolusCalled: ((Double, BolusActivationType) -> Void)? + + var enactTempBasalCalled: ((Double, TimeInterval) -> Void)? + + var enactTempBasalError: PumpManagerError? + + init() { + + } + + // PumpManager implementation + static var onboardingMaximumBasalScheduleEntryCount: Int = 24 + + static var onboardingSupportedBasalRates: [Double] = [1,2,3] + + static var onboardingSupportedBolusVolumes: [Double] = [1,2,3] + + static var onboardingSupportedMaximumBolusVolumes: [Double] = [1,2,3] + + let deliveryUnitsPerMinute = 1.5 + + var supportedBasalRates: [Double] = [1,2,3] + + var supportedBolusVolumes: [Double] = [1,2,3] + + var supportedMaximumBolusVolumes: [Double] = [1,2,3] + + var maximumBasalScheduleEntryCount: Int = 24 + + var minimumBasalScheduleEntryDuration: TimeInterval = .minutes(30) + + var pumpManagerDelegate: PumpManagerDelegate? + + var pumpRecordsBasalProfileStartEvents: Bool = false + + var pumpReservoirCapacity: Double = 50 + + var lastSync: Date? + + var status: PumpManagerStatus = + PumpManagerStatus( + timeZone: TimeZone.current, + device: HKDevice(name: "MockPumpManager", manufacturer: nil, model: nil, hardwareVersion: nil, firmwareVersion: nil, softwareVersion: nil, localIdentifier: nil, udiDeviceIdentifier: nil), + pumpBatteryChargeRemaining: nil, + basalDeliveryState: nil, + bolusState: .noBolus, + insulinType: .novolog) + + func addStatusObserver(_ observer: PumpManagerStatusObserver, queue: DispatchQueue) { + } + + func removeStatusObserver(_ observer: PumpManagerStatusObserver) { + } + + func ensureCurrentPumpData(completion: ((Date?) -> Void)?) { + completion?(Date()) + } + + func setMustProvideBLEHeartbeat(_ mustProvideBLEHeartbeat: Bool) { + } + + func createBolusProgressReporter(reportingOn dispatchQueue: DispatchQueue) -> DoseProgressReporter? { + return nil + } + + func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (PumpManagerError?) -> Void) { + enactBolusCalled?(units, activationType) + completion(nil) + } + + func cancelBolus(completion: @escaping (PumpManagerResult) -> Void) { + completion(.success(nil)) + } + + func enactTempBasal(unitsPerHour: Double, for duration: TimeInterval, completion: @escaping (PumpManagerError?) -> Void) { + enactTempBasalCalled?(unitsPerHour, duration) + completion(enactTempBasalError) + } + + func suspendDelivery(completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func resumeDelivery(completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func syncBasalRateSchedule(items scheduleItems: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { + } + + func syncDeliveryLimits(limits deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { + completion(.success(deliveryLimits)) + } + + func estimatedDuration(toBolus units: Double) -> TimeInterval { + .minutes(units / deliveryUnitsPerMinute) + } + + var pluginIdentifier: String = "MockPumpManager" + + var localizedTitle: String = "MockPumpManager" + + var delegateQueue: DispatchQueue! + + required init?(rawState: RawStateValue) { + + } + + var rawState: RawStateValue = [:] + + var isOnboarded: Bool = true + + var debugDescription: String = "MockPumpManager" + + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + } + + func getSoundBaseURL() -> URL? { + return nil + } + + func getSounds() -> [Alert.Sound] { + return [.sound(name: "doesntExist")] + } +} diff --git a/LoopTests/Mocks/MockSettingsProvider.swift b/LoopTests/Mocks/MockSettingsProvider.swift new file mode 100644 index 0000000000..150608a1fe --- /dev/null +++ b/LoopTests/Mocks/MockSettingsProvider.swift @@ -0,0 +1,49 @@ +// +// MockSettingsProvider.swift +// LoopTests +// +// Created by Pete Schwamb on 11/28/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import HealthKit +@testable import Loop + +class MockSettingsProvider: SettingsProvider { + + var basalHistory: [AbsoluteScheduleValue]? + func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + return basalHistory ?? settings.basalRateSchedule?.between(start: startDate, end: endDate) ?? [] + } + + var carbRatioHistory: [AbsoluteScheduleValue]? + func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + return carbRatioHistory ?? settings.carbRatioSchedule?.between(start: startDate, end: endDate) ?? [] + } + + var insulinSensitivityHistory: [AbsoluteScheduleValue]? + func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + return insulinSensitivityHistory ?? settings.insulinSensitivitySchedule?.quantitiesBetween(start: startDate, end: endDate) ?? [] + } + + var targetRangeHistory: [AbsoluteScheduleValue>]? + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] { + return targetRangeHistory ?? settings.glucoseTargetRangeSchedule?.quantityBetween(start: startDate, end: endDate) ?? [] + } + + func getDosingLimits(at date: Date) async throws -> DosingLimits { + return DosingLimits( + suspendThreshold: settings.suspendThreshold?.quantity, + maxBolus: settings.maximumBolus, + maxBasalRate: settings.maximumBasalRatePerHour + ) + } + + var settings: StoredSettings + + init(settings: StoredSettings) { + self.settings = settings + } +} diff --git a/LoopTests/Mocks/MockTrustedTimeChecker.swift b/LoopTests/Mocks/MockTrustedTimeChecker.swift new file mode 100644 index 0000000000..137de2eede --- /dev/null +++ b/LoopTests/Mocks/MockTrustedTimeChecker.swift @@ -0,0 +1,14 @@ +// +// MockTrustedTimeChecker.swift +// LoopTests +// +// Created by Pete Schwamb on 11/1/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +@testable import Loop + +class MockTrustedTimeChecker: TrustedTimeChecker { + var detectedSystemTimeOffset: TimeInterval = 0 +} diff --git a/LoopTests/Mocks/MockUploadEventListener.swift b/LoopTests/Mocks/MockUploadEventListener.swift new file mode 100644 index 0000000000..75de952dd6 --- /dev/null +++ b/LoopTests/Mocks/MockUploadEventListener.swift @@ -0,0 +1,17 @@ +// +// MockUploadEventListener.swift +// LoopTests +// +// Created by Pete Schwamb on 11/30/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +@testable import Loop + +class MockUploadEventListener: UploadEventListener { + var lastUploadTriggeringType: RemoteDataType? + func triggerUpload(for triggeringType: RemoteDataType) { + self.lastUploadTriggeringType = triggeringType + } +} diff --git a/LoopTests/Mocks/PersistenceController.swift b/LoopTests/Mocks/PersistenceController.swift new file mode 100644 index 0000000000..43fca07c60 --- /dev/null +++ b/LoopTests/Mocks/PersistenceController.swift @@ -0,0 +1,16 @@ +// +// PersistenceController.swift +// LoopTests +// +// Created by Pete Schwamb on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +extension PersistenceController { + static func mock() -> PersistenceController { + return PersistenceController(directoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString, isDirectory: true)) + } +} diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 8b65faa377..790a3bcd0a 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -57,7 +57,7 @@ class BolusEntryViewModelTests: XCTestCase { var bolusEntryViewModel: BolusEntryViewModel! fileprivate var delegate: MockBolusEntryViewModelDelegate! var now: Date = BolusEntryViewModelTests.now - + let mockOriginalCarbEntry = StoredCarbEntry( startDate: BolusEntryViewModelTests.exampleStartDate, quantity: BolusEntryViewModelTests.exampleCarbQuantity, @@ -87,6 +87,8 @@ class BolusEntryViewModelTests: XCTestCase { let queue = DispatchQueue(label: "BolusEntryViewModelTests") var saveAndDeliverSuccess = false + var mockDeliveryDelegate = MockDeliveryDelegate() + override func setUp(completion: @escaping (Error?) -> Void) { now = Self.now delegate = MockBolusEntryViewModelDelegate() @@ -113,6 +115,8 @@ class BolusEntryViewModelTests: XCTestCase { bolusEntryViewModel.maximumBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 10) + bolusEntryViewModel.deliveryDelegate = mockDeliveryDelegate + await bolusEntryViewModel.generateRecommendationAndStartObserving() } @@ -166,7 +170,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 +180,10 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateGlucoseValuesWithManual() async throws { XCTAssertEqual(0, bolusEntryViewModel.glucoseValues.count) - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - delegate.getGlucoseSamplesResponse = [StoredGlucoseSample(sample: Self.exampleCGMGlucoseSample)] + bolusEntryViewModel.manualGlucoseQuantity = .glucose(value: 123) + delegate.loopStateInput.glucoseHistory = [.mock(100, at: now.addingTimeInterval(-.minutes(5)))] await bolusEntryViewModel.update() - XCTAssertEqual([100.4, 123.4], bolusEntryViewModel.glucoseValues.map { + XCTAssertEqual([100, 123], bolusEntryViewModel.glucoseValues.map { return $0.quantity.doubleValue(for: .milligramsPerDeciliter) }) } @@ -191,22 +195,26 @@ class BolusEntryViewModelTests: XCTestCase { } func testUpdatePredictedGlucoseValues() async throws { - let prediction = [PredictedGlucoseValue(startDate: Self.exampleStartDate, quantity: Self.exampleCGMGlucoseQuantity)] - delegate.loopState.predictGlucoseValueResult = prediction - await bolusEntryViewModel.update() - XCTAssertEqual(prediction, bolusEntryViewModel.predictedGlucoseValues.map { PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) }) + do { + let input = try await delegate.fetchData(for: Self.exampleStartDate, disablingPreMeal: false) + let prediction = try input.predictGlucose() + await bolusEntryViewModel.update() + XCTAssertEqual(prediction, bolusEntryViewModel.predictedGlucoseValues.map { PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) }) + } catch { + XCTFail("Unable to generate prediction") + } } func testUpdatePredictedGlucoseValuesWithManual() async throws { - let prediction = [PredictedGlucoseValue(startDate: Self.exampleStartDate, quantity: Self.exampleCGMGlucoseQuantity)] - delegate.loopState.predictGlucoseValueResult = prediction - await bolusEntryViewModel.update() - - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - XCTAssertEqual(prediction, - bolusEntryViewModel.predictedGlucoseValues.map { - PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) - }) + do { + let input = try await delegate.fetchData(for: Self.exampleStartDate, disablingPreMeal: false) + 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 +226,20 @@ class BolusEntryViewModelTests: XCTestCase { RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) ], timeZone: .utcTimeZone)! - var newSettings = LoopSettings(dosingEnabled: true, + let newSettings = StoredSettings(dosingEnabled: true, glucoseTargetRangeSchedule: newGlucoseTargetRangeSchedule, maximumBasalRatePerHour: 1.0, maximumBolus: 10.0, suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 100.0)) let settings = TemporaryScheduleOverrideSettings(unit: .millimolesPerLiter, targetRange: nil, insulinNeedsScaleFactor: nil) - newSettings.preMealOverride = TemporaryScheduleOverride(context: .preMeal, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) - newSettings.scheduleOverride = TemporaryScheduleOverride(context: .custom, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) + delegate.preMealOverride = TemporaryScheduleOverride(context: .preMeal, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) + delegate.scheduleOverride = TemporaryScheduleOverride(context: .custom, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) delegate.settings = newSettings bolusEntryViewModel.updateSettings() await bolusEntryViewModel.update() - XCTAssertEqual(newSettings.preMealOverride, bolusEntryViewModel.preMealOverride) - XCTAssertEqual(newSettings.scheduleOverride, bolusEntryViewModel.scheduleOverride) + XCTAssertEqual(delegate.preMealOverride, bolusEntryViewModel.preMealOverride) + XCTAssertEqual(delegate.scheduleOverride, bolusEntryViewModel.scheduleOverride) XCTAssertEqual(newGlucoseTargetRangeSchedule, bolusEntryViewModel.targetGlucoseSchedule) } @@ -245,78 +253,85 @@ class BolusEntryViewModelTests: XCTestCase { RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) ], timeZone: .utcTimeZone)! - var newSettings = LoopSettings(dosingEnabled: true, + let newSettings = StoredSettings(dosingEnabled: true, glucoseTargetRangeSchedule: newGlucoseTargetRangeSchedule, maximumBasalRatePerHour: 1.0, maximumBolus: 10.0, suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 100.0)) - newSettings.preMealOverride = Self.examplePreMealOverride - newSettings.scheduleOverride = Self.exampleCustomScheduleOverride + delegate.preMealOverride = Self.examplePreMealOverride + delegate.scheduleOverride = Self.exampleCustomScheduleOverride delegate.settings = newSettings bolusEntryViewModel.updateSettings() // Pre-meal override should be ignored if we have carbs (LOOP-1964), and cleared in settings - XCTAssertEqual(newSettings.scheduleOverride, bolusEntryViewModel.scheduleOverride) + XCTAssertEqual(delegate.scheduleOverride, bolusEntryViewModel.scheduleOverride) XCTAssertEqual(newGlucoseTargetRangeSchedule, bolusEntryViewModel.targetGlucoseSchedule) // ... but restored if we cancel without bolusing bolusEntryViewModel = nil } - func testManualGlucoseChangesPredictedGlucoseValues() async throws { - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - let prediction = [PredictedGlucoseValue(startDate: Self.exampleStartDate, quantity: Self.exampleCGMGlucoseQuantity)] - delegate.loopState.predictGlucoseValueResult = prediction + func testManualGlucoseIncludedInAlgorithmRun() async throws { + bolusEntryViewModel.manualGlucoseQuantity = .glucose(value: 123) await bolusEntryViewModel.update() - XCTAssertEqual(prediction, - bolusEntryViewModel.predictedGlucoseValues.map { - PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) - }) + XCTAssertEqual(123, delegate.manualGlucoseSampleForBolusRecommendation?.quantity.doubleValue(for: .milligramsPerDeciliter)) } func testUpdateInsulinOnBoard() async throws { - delegate.insulinOnBoardResult = .success(InsulinValue(startDate: Self.exampleStartDate, value: 1.5)) + delegate.activeInsulin = InsulinValue(startDate: Self.exampleStartDate, value: 1.5) XCTAssertNil(bolusEntryViewModel.activeInsulin) await bolusEntryViewModel.update() XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 1.5), bolusEntryViewModel.activeInsulin) } func testUpdateCarbsOnBoard() async throws { - delegate.carbsOnBoardResult = .success(CarbValue(startDate: Self.exampleStartDate, endDate: Self.exampleEndDate, value: Self.exampleCarbQuantity.doubleValue(for: .gram()))) + delegate.activeCarbs = CarbValue(startDate: Self.exampleStartDate, endDate: Self.exampleEndDate, value: Self.exampleCarbQuantity.doubleValue(for: .gram())) XCTAssertNil(bolusEntryViewModel.activeCarbs) await bolusEntryViewModel.update() XCTAssertEqual(Self.exampleCarbQuantity, bolusEntryViewModel.activeCarbs) } func testUpdateCarbsOnBoardFailure() async throws { - delegate.carbsOnBoardResult = .failure(CarbStore.CarbStoreError.notConfigured) + delegate.activeCarbs = nil await bolusEntryViewModel.update() XCTAssertNil(bolusEntryViewModel.activeCarbs) } func testUpdateRecommendedBolusNoNotice() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + let originalCarbEntry = StoredCarbEntry.mock(50, at: now.addingTimeInterval(-.minutes(5))) + let editedCarbEntry = NewCarbEntry.mock(40, at: now.addingTimeInterval(-.minutes(5))) + + delegate.loopStateInput.carbEntries = [originalCarbEntry] + + await setUpViewModel(originalCarbEntry: originalCarbEntry, potentialCarbEntry: editedCarbEntry) + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendation = ManualBolusRecommendation(amount: 1.25) - delegate.loopState.bolusRecommendationResult = recommendation + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) + await bolusEntryViewModel.update() + XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus XCTAssertNotNil(recommendedBolus) XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) - let consideringPotentialCarbEntryPassed = try XCTUnwrap(delegate.loopState.consideringPotentialCarbEntryPassed) - XCTAssertEqual(mockPotentialCarbEntry, consideringPotentialCarbEntryPassed) - let replacingCarbEntryPassed = try XCTUnwrap(delegate.loopState.replacingCarbEntryPassed) - XCTAssertEqual(mockOriginalCarbEntry, replacingCarbEntryPassed) + + XCTAssertEqual(delegate.originalCarbEntryForBolusRecommendation?.quantity, originalCarbEntry.quantity) + XCTAssertEqual(delegate.potentialCarbEntryForBolusRecommendation?.quantity, editedCarbEntry.quantity) + XCTAssertNil(delegate.manualGlucoseSampleForBolusRecommendation) + XCTAssertNil(bolusEntryViewModel.activeNotice) } func testUpdateRecommendedBolusWithNotice() async throws { delegate.settings.suspendThreshold = GlucoseThreshold(unit: .milligramsPerDeciliter, value: Self.exampleCGMGlucoseQuantity.doubleValue(for: .milligramsPerDeciliter)) XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - let recommendation = ManualBolusRecommendation(amount: 1.25, 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 @@ -329,7 +344,7 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) delegate.settings.suspendThreshold = nil let recommendation = ManualBolusRecommendation(amount: 1.25, notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue)) - delegate.loopState.bolusRecommendationResult = recommendation + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -341,7 +356,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusWithOtherNotice() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendation = ManualBolusRecommendation(amount: 1.25, notice: BolusRecommendationNotice.currentGlucoseBelowTarget(glucose: Self.exampleGlucoseValue)) - delegate.loopState.bolusRecommendationResult = recommendation + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) await bolusEntryViewModel.update() XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -352,7 +367,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdateRecommendedBolusThrowsMissingDataError() async throws { XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) - delegate.loopState.bolusRecommendationError = LoopError.missingDataError(.glucose) + delegate.algorithmOutput.recommendationResult = .failure(LoopError.missingDataError(.glucose)) await bolusEntryViewModel.update() XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus @@ -362,7 +377,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 +387,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 +397,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 +407,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 +416,31 @@ class BolusEntryViewModelTests: XCTestCase { } func testUpdateRecommendedBolusWithManual() async throws { - await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) - bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + let originalCarbEntry = StoredCarbEntry.mock(50, at: now.addingTimeInterval(-.minutes(5))) + let editedCarbEntry = NewCarbEntry.mock(40, at: now.addingTimeInterval(-.minutes(5))) + + delegate.loopStateInput.carbEntries = [originalCarbEntry] + + await setUpViewModel(originalCarbEntry: originalCarbEntry, potentialCarbEntry: editedCarbEntry) + + let manualGlucoseQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123) + + bolusEntryViewModel.manualGlucoseQuantity = manualGlucoseQuantity XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + let recommendation = ManualBolusRecommendation(amount: 1.25) - delegate.loopState.bolusRecommendationResult = recommendation + delegate.algorithmOutput.recommendationResult = .success(.init(manual: recommendation)) await bolusEntryViewModel.update() + XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus XCTAssertNotNil(recommendedBolus) XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) - let consideringPotentialCarbEntryPassed = try XCTUnwrap(delegate.loopState.consideringPotentialCarbEntryPassed) - XCTAssertEqual(mockPotentialCarbEntry, consideringPotentialCarbEntryPassed) - let replacingCarbEntryPassed = try XCTUnwrap(delegate.loopState.replacingCarbEntryPassed) - XCTAssertEqual(mockOriginalCarbEntry, replacingCarbEntryPassed) + + XCTAssertEqual(delegate.potentialCarbEntryForBolusRecommendation, editedCarbEntry) + XCTAssertEqual(delegate.originalCarbEntryForBolusRecommendation, originalCarbEntry) + XCTAssertEqual(delegate.manualGlucoseSampleForBolusRecommendation?.quantity, manualGlucoseQuantity) + XCTAssertNil(bolusEntryViewModel.activeNotice) } @@ -508,8 +534,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 +558,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 +580,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 +630,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 +655,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 +819,149 @@ 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) + 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 updateRemoteRecommendation() { - } + var scheduleOverride: LoopKit.TemporaryScheduleOverride? + + var preMealOverride: LoopKit.TemporaryScheduleOverride? + + var pumpInsulinType: LoopKit.InsulinType? + + var mostRecentGlucoseDataDate: Date? + + var mostRecentPumpDataDate: Date? - 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 loopStateInput = LoopAlgorithmInput( + predictionStart: Date(), + glucoseHistory: [], + doses: [], + carbEntries: [], + basal: [], + sensitivity: [], + carbRatio: [], + target: [], + suspendThreshold: nil, + maxBolus: 3, + maxBasalRate: 6, + carbAbsorptionModel: .piecewiseLinear, + recommendationInsulinType: .novolog, + recommendationType: .manualBolus, + automaticBolusApplicationFactor: 0.4 + ) + + func fetchData(for baseTime: Date, disablingPreMeal: Bool) async throws -> LoopAlgorithmInput { + loopStateInput.predictionStart = baseTime + return loopStateInput + } + + func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? { + return nil } - + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { - return .hours(6) + .minutes(10) + return InsulinMath.defaultInsulinActivityDuration } - var pumpInsulinType: InsulinType? - - var displayGlucosePreference: DisplayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) - - func withLoopState(do block: @escaping (LoopState) -> Void) { - dataAccessQueue.async { - block(self.loopState) + var carbEntriesAdded = [(NewCarbEntry, StoredCarbEntry?)]() + var addCarbEntryResult: Result = .failure(BolusEntryViewTestError.responseUndefined) + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { + carbEntriesAdded.append((carbEntry, replacingEntry)) + switch addCarbEntryResult { + case .success(let success): + return success + case .failure(let failure): + throw failure } } - func saveGlucose(sample: LoopKit.NewGlucoseSample) async -> LoopKit.StoredGlucoseSample? { - glucoseSamplesAdded.append(sample) - return StoredGlucoseSample(sample: sample.quantitySample) - } - 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) - } - - var carbEntriesAdded = [(NewCarbEntry, StoredCarbEntry?)]() - var addCarbEntryResult: Result = .failure(BolusEntryViewTestError.responseUndefined) - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { - carbEntriesAdded.append((carbEntry, replacingEntry)) - completion(addCarbEntryResult) + var saveGlucoseError: Error? + func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample { + glucoseSamplesAdded.append(sample) + if let saveGlucoseError { + throw saveGlucoseError + } else { + return sample.asStoredGlucoseStample + } } var bolusDosingDecisionsAdded = [(BolusDosingDecision, Date)]() - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async { bolusDosingDecisionsAdded.append((bolusDosingDecision, date)) } - + var enactedBolusUnits: Double? var enactedBolusActivationType: BolusActivationType? - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (Error?) -> Void) { + func enactBolus(units: Double, activationType: BolusActivationType) async throws { enactedBolusUnits = units enactedBolusActivationType = activationType } - - var getGlucoseSamplesResponse: [StoredGlucoseSample] = [] - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - completion(.success(getGlucoseSamplesResponse)) - } - - var insulinOnBoardResult: DoseStoreResult? - func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { - if let insulinOnBoardResult = insulinOnBoardResult { - completion(insulinOnBoardResult) - } else { - completion(.failure(.configurationError)) - } + + var activeInsulin: InsulinValue? + + var activeCarbs: CarbValue? + + var prediction: [PredictedGlucoseValue] = [] + var lastGeneratePredictionInput: LoopAlgorithmInput? + + func generatePrediction(input: LoopAlgorithmInput) throws -> [PredictedGlucoseValue] { + lastGeneratePredictionInput = input + return prediction } - - var carbsOnBoardResult: CarbStoreResult? - func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { - if let carbsOnBoardResult = carbsOnBoardResult { - completion(carbsOnBoardResult) + + var algorithmOutput: LoopAlgorithmOutput = LoopAlgorithmOutput( + recommendationResult: .success(.init()), + predictedGlucose: [], + effects: LoopAlgorithmEffects.emptyMock, + dosesRelativeToBasal: [], + activeInsulin: nil, + activeCarbs: nil + ) + + var manualGlucoseSampleForBolusRecommendation: NewGlucoseSample? + var potentialCarbEntryForBolusRecommendation: NewCarbEntry? + var originalCarbEntryForBolusRecommendation: StoredCarbEntry? + + func recommendManualBolus( + manualGlucoseSample: NewGlucoseSample?, + potentialCarbEntry: NewCarbEntry?, + originalCarbEntry: StoredCarbEntry? + ) async throws -> ManualBolusRecommendation? { + + manualGlucoseSampleForBolusRecommendation = manualGlucoseSample + potentialCarbEntryForBolusRecommendation = potentialCarbEntry + originalCarbEntryForBolusRecommendation = originalCarbEntry + + switch algorithmOutput.recommendationResult { + case .success(let recommendation): + return recommendation.manual + case .failure(let error): + throw error } } - - var ensureCurrentPumpDataCompletion: ((Date?) -> Void)? - func ensureCurrentPumpData(completion: @escaping (Date?) -> Void) { - ensureCurrentPumpDataCompletion = completion - } - - var mostRecentGlucoseDataDate: Date? - - var mostRecentPumpDataDate: Date? - - var isPumpConfigured: Bool = true - - var preferredGlucoseUnit: HKUnit = .milligramsPerDeciliter - - var insulinModel: InsulinModel? = MockInsulinModel() - - var settings: LoopSettings = LoopSettings( - dosingEnabled: true, - glucoseTargetRangeSchedule: BolusEntryViewModelTests.exampleGlucoseRangeSchedule, - maximumBasalRatePerHour: 3.0, - maximumBolus: 10.0, - suspendThreshold: GlucoseThreshold(unit: .internationalUnit(), value: 75)) { - didSet { - NotificationCenter.default.post(name: .LoopDataUpdated, object: nil, userInfo: [ - LoopDataManager.LoopUpdateContextKey: LoopDataManager.LoopUpdateContext.preferences.rawValue - ]) - } - } - } fileprivate struct MockInsulinModel: InsulinModel { @@ -1012,3 +1009,40 @@ extension ManualBolusRecommendationWithDate: Equatable { return lhs.recommendation == rhs.recommendation && lhs.date == rhs.date } } + +extension LoopAlgorithmEffects { + public static var emptyMock: LoopAlgorithmEffects { + return LoopAlgorithmEffects( + insulin: [], + carbs: [], + carbStatus: [], + retrospectiveCorrection: [], + momentum: [], + insulinCounteraction: [], + retrospectiveGlucoseDiscrepancies: [] + ) + } +} + +extension NewCarbEntry { + static func mock(_ grams: Double, at date: Date) -> NewCarbEntry { + NewCarbEntry( + quantity: .init(unit: .gram(), doubleValue: grams), + startDate: date, + foodType: nil, + absorptionTime: nil + ) + } +} + +extension StoredCarbEntry { + static func mock(_ grams: Double, at date: Date) -> StoredCarbEntry { + StoredCarbEntry(startDate: date, quantity: .init(unit: .gram(), doubleValue: grams)) + } +} + +extension StoredGlucoseSample { + static func mock(_ value: Double, at date: Date) -> StoredGlucoseSample { + StoredGlucoseSample(startDate: date, quantity: .glucose(value: value)) + } +} diff --git a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift index 55104e5a1b..46cb1e75a3 100644 --- a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift +++ b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift @@ -12,6 +12,7 @@ import LoopKit import XCTest @testable import Loop +@MainActor class ManualEntryDoseViewModelTests: XCTestCase { static let now = Date.distantFuture @@ -24,13 +25,6 @@ class ManualEntryDoseViewModelTests: XCTestCase { static let noBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) - var authenticateOverrideCompletion: ((Swift.Result) -> Void)? - private func authenticateOverride(_ message: String, _ completion: @escaping (Swift.Result) -> Void) { - authenticateOverrideCompletion = completion - } - - var saveAndDeliverSuccess = false - fileprivate var delegate: MockManualEntryDoseViewModelDelegate! static let mockUUID = UUID() @@ -39,100 +33,67 @@ class ManualEntryDoseViewModelTests: XCTestCase { override func setUpWithError() throws { now = Self.now delegate = MockManualEntryDoseViewModelDelegate() - delegate.mostRecentGlucoseDataDate = now - delegate.mostRecentPumpDataDate = now - saveAndDeliverSuccess = false setUpViewModel() } func setUpViewModel() { manualEntryDoseViewModel = ManualEntryDoseViewModel(delegate: delegate, now: { self.now }, - screenWidth: 512, debounceIntervalMilliseconds: 0, uuidProvider: { self.mockUUID }, timeZone: TimeZone(abbreviation: "GMT")!) - manualEntryDoseViewModel.authenticate = authenticateOverride + manualEntryDoseViewModel.authenticationHandler = { _ in return true } } - func testDoseLogging() throws { + func testDoseLogging() async throws { XCTAssertEqual(.novolog, manualEntryDoseViewModel.selectedInsulinType) manualEntryDoseViewModel.enteredBolus = Self.exampleBolusQuantity - try saveAndDeliver(ManualEntryDoseViewModelTests.exampleBolusQuantity) + try await manualEntryDoseViewModel.saveManualDose() + XCTAssertEqual(delegate.manualEntryBolusUnits, Self.exampleBolusQuantity.doubleValue(for: .internationalUnit())) XCTAssertEqual(delegate.manuallyEnteredDoseInsulinType, .novolog) } - - private func saveAndDeliver(_ bolus: HKQuantity, file: StaticString = #file, line: UInt = #line) throws { - manualEntryDoseViewModel.enteredBolus = bolus - manualEntryDoseViewModel.saveManualDose { self.saveAndDeliverSuccess = true } - if bolus != ManualEntryDoseViewModelTests.noBolus { - let authenticateOverrideCompletion = try XCTUnwrap(self.authenticateOverrideCompletion, file: file, line: line) - authenticateOverrideCompletion(.success(())) - } + + func testDoseNotSavedIfNotAuthenticated() async throws { + XCTAssertEqual(.novolog, manualEntryDoseViewModel.selectedInsulinType) + manualEntryDoseViewModel.enteredBolus = Self.exampleBolusQuantity + + manualEntryDoseViewModel.authenticationHandler = { _ in return false } + + do { + try await manualEntryDoseViewModel.saveManualDose() + XCTFail("Saving should fail if not authenticated.") + } catch { } + + XCTAssertNil(delegate.manualEntryBolusUnits) + XCTAssertNil(delegate.manuallyEnteredDoseInsulinType) } + } fileprivate class MockManualEntryDoseViewModelDelegate: ManualDoseViewModelDelegate { - - func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { - return .hours(6) + .minutes(10) - } - - var pumpInsulinType: InsulinType? - + var pumpInsulinType: LoopKit.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 + func insulinActivityDuration(for type: LoopKit.InsulinType?) -> TimeInterval { + return InsulinMath.defaultInsulinActivityDuration } - 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) - } - } - - var ensureCurrentPumpDataCompletion: (() -> Void)? - func ensureCurrentPumpData(completion: @escaping () -> Void) { - ensureCurrentPumpDataCompletion = completion - } - - var mostRecentGlucoseDataDate: Date? - - var mostRecentPumpDataDate: Date? - - var isPumpConfigured: Bool = true - - var preferredGlucoseUnit: HKUnit = .milligramsPerDeciliter - - var settings: LoopSettings = LoopSettings() + var algorithmDisplayState = AlgorithmDisplayState() + + var settings = StoredSettings() + + var scheduleOverride: TemporaryScheduleOverride? + } diff --git a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift index 92d7de8b7e..b46077c9bf 100644 --- a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift +++ b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift @@ -14,6 +14,7 @@ import LoopCore @testable import Loop +@MainActor class SimpleBolusViewModelTests: XCTestCase { enum MockError: Error { @@ -37,44 +38,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 +71,8 @@ class SimpleBolusViewModelTests: XCTestCase { } - func testMealCarbsAndManualGlucoseWithRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + func testMealCarbsAndManualGlucoseWithRecommendation() async { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } @@ -94,13 +82,7 @@ class SimpleBolusViewModelTests: XCTestCase { viewModel.enteredCarbString = "20" viewModel.manualGlucoseString = "180" - let saveExpectation = expectation(description: "Save completion callback") - - viewModel.saveAndDeliver { (success) in - saveExpectation.fulfill() - } - - waitForExpectations(timeout: 2) + let _ = await viewModel.saveAndDeliver() XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram())) XCTAssertEqual(180, addedGlucose.first?.quantity.doubleValue(for: .milligramsPerDeciliter)) @@ -111,8 +93,8 @@ class SimpleBolusViewModelTests: XCTestCase { XCTAssertEqual(storedBolusDecision?.carbEntry?.quantity, addedCarbEntry?.quantity) } - func testMealCarbsWithUserOverridingRecommendation() { - let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + func testMealCarbsWithUserOverridingRecommendation() async { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false, displayGlucosePreference: displayGlucosePreference) viewModel.authenticate = { (description, completion) in completion(.success) } @@ -127,13 +109,7 @@ class SimpleBolusViewModelTests: XCTestCase { viewModel.enteredBolusString = "0.1" - let saveExpectation = expectation(description: "Save completion callback") - - viewModel.saveAndDeliver { (success) in - saveExpectation.fulfill() - } - - waitForExpectations(timeout: 2) + let _ = await viewModel.saveAndDeliver() XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram())) @@ -145,7 +121,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) } @@ -164,7 +140,7 @@ class SimpleBolusViewModelTests: XCTestCase { } 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) } @@ -183,7 +159,7 @@ class SimpleBolusViewModelTests: XCTestCase { } 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 +177,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 +197,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 +228,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 +261,12 @@ class SimpleBolusViewModelTests: XCTestCase { } extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { - func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { - addedGlucose = samples - completion(.success([])) + func saveGlucose(sample: LoopKit.NewGlucoseSample) async throws -> StoredGlucoseSample { + addedGlucose.append(sample) + return sample.asStoredGlucoseStample } - - func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { - + + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { addedCarbEntry = carbEntry let storedCarbEntry = StoredCarbEntry( startDate: carbEntry.startDate, @@ -305,35 +280,38 @@ extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { createdByCurrentApp: true, userCreatedDate: Date(), userUpdatedDate: nil) - completion(.success(storedCarbEntry)) + return storedCarbEntry } - func enactBolus(units: Double, activationType: BolusActivationType) { + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async { + storedBolusDecision = bolusDosingDecision + } + + + func enactBolus(units: Double, activationType: BolusActivationType) async throws { enactedBolus = (units: units, activationType: activationType) } - - func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { - completion(.success(currentIOB)) + + + func insulinOnBoard(at date: Date) async -> InsulinValue? { + return currentIOB } - + + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { - var decision = BolusDosingDecision(for: .simpleBolus) decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: currentRecommendation, notice: .none), date: date) decision.insulinOnBoard = currentIOB return decision } - - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { - storedBolusDecision = bolusDosingDecision - } - var maximumBolus: Double { + + var maximumBolus: Double? { return 3.0 } - var suspendThreshold: HKQuantity { + var suspendThreshold: HKQuantity? { return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) } } diff --git a/WatchApp Extension/Controllers/ActionHUDController.swift b/WatchApp Extension/Controllers/ActionHUDController.swift index dce285b9d9..bb2df24563 100644 --- a/WatchApp Extension/Controllers/ActionHUDController.swift +++ b/WatchApp Extension/Controllers/ActionHUDController.swift @@ -60,13 +60,13 @@ final class ActionHUDController: HUDInterfaceController { super.update() let activeOverrideContext: TemporaryScheduleOverride.Context? - if let override = loopManager.settings.scheduleOverride, override.isActive() { + if let override = loopManager.watchInfo.scheduleOverride, override.isActive() { activeOverrideContext = override.context } else { activeOverrideContext = nil } - updateForPreMeal(enabled: loopManager.settings.preMealOverride?.isActive() == true) + updateForPreMeal(enabled: loopManager.watchInfo.preMealOverride?.isActive() == true) updateForOverrideContext(activeOverrideContext) let isClosedLoop = loopManager.activeContext?.isClosedLoop ?? false @@ -80,7 +80,7 @@ final class ActionHUDController: HUDInterfaceController { carbsButtonGroup.state = .off bolusButtonGroup.state = .off - if loopManager.settings.preMealTargetRange == nil { + if loopManager.watchInfo.loopSettings.preMealTargetRange == nil { preMealButtonGroup.state = .disabled } else if preMealButtonGroup.state == .disabled { preMealButtonGroup.state = .off @@ -98,9 +98,9 @@ final class ActionHUDController: HUDInterfaceController { private var canEnableOverride: Bool { if FeatureFlags.sensitivityOverridesEnabled { - return !loopManager.settings.overridePresets.isEmpty + return !loopManager.watchInfo.loopSettings.overridePresets.isEmpty } else { - return loopManager.settings.legacyWorkoutTargetRange != nil + return loopManager.watchInfo.loopSettings.legacyWorkoutTargetRange != nil } } @@ -133,11 +133,11 @@ final class ActionHUDController: HUDInterfaceController { private let glucoseFormatter = QuantityFormatter(for: .milligramsPerDeciliter) @IBAction func togglePreMealMode() { - guard let range = loopManager.settings.preMealTargetRange else { + guard let range = loopManager.watchInfo.loopSettings.preMealTargetRange else { return } - let buttonToSelect = loopManager.settings.preMealOverride?.isActive() == true ? SelectedButton.on : SelectedButton.off + let buttonToSelect = loopManager.watchInfo.preMealOverride?.isActive() == true ? SelectedButton.on : SelectedButton.off let viewModel = OnOffSelectionViewModel( title: NSLocalizedString("Pre-Meal", comment: "Title for sheet to enable/disable pre-meal on watch"), message: formattedGlucoseRangeString(from: range), @@ -152,30 +152,29 @@ final class ActionHUDController: HUDInterfaceController { updateForPreMeal(enabled: isPreMealEnabled) pendingMessageResponses += 1 - var settings = loopManager.settings - let overrideContext = settings.scheduleOverride?.context + var watchInfo = loopManager.watchInfo + let overrideContext = watchInfo.scheduleOverride?.context if isPreMealEnabled { - settings.enablePreMealOverride(for: .hours(1)) + watchInfo.enablePreMealOverride(for: .hours(1)) if !FeatureFlags.sensitivityOverridesEnabled { - settings.clearOverride(matching: .legacyWorkout) + watchInfo.clearOverride(matching: .legacyWorkout) updateForOverrideContext(nil) } } else { - settings.clearOverride(matching: .preMeal) + watchInfo.clearOverride(matching: .preMeal) } - let userInfo = LoopSettingsUserInfo(settings: settings) do { - try WCSession.default.sendSettingsUpdateMessage(userInfo, completionHandler: { (result) in + try WCSession.default.sendSettingsUpdateMessage(watchInfo, completionHandler: { (result) in DispatchQueue.main.async { self.pendingMessageResponses -= 1 switch result { case .success(let context): if self.pendingMessageResponses == 0 { - self.loopManager.settings.preMealOverride = settings.preMealOverride - self.loopManager.settings.scheduleOverride = settings.scheduleOverride + self.loopManager.watchInfo.preMealOverride = watchInfo.preMealOverride + self.loopManager.watchInfo.scheduleOverride = watchInfo.scheduleOverride } ExtensionDelegate.shared().loopManager.updateContext(context) @@ -208,14 +207,14 @@ final class ActionHUDController: HUDInterfaceController { overrideButtonGroup.state == .on ? sendOverride(nil) : presentController(withName: OverrideSelectionController.className, context: self as OverrideSelectionControllerDelegate) - } else if let range = loopManager.settings.legacyWorkoutTargetRange { - let buttonToSelect = loopManager.settings.nonPreMealOverrideEnabled() == true ? SelectedButton.on : SelectedButton.off - + } else if let range = loopManager.watchInfo.loopSettings.legacyWorkoutTargetRange { + let buttonToSelect = loopManager.watchInfo.nonPreMealOverrideEnabled() == true ? SelectedButton.on : SelectedButton.off + let viewModel = OnOffSelectionViewModel( title: NSLocalizedString("Workout", comment: "Title for sheet to enable/disable workout mode on watch"), message: formattedGlucoseRangeString(from: range), onSelection: { isWorkoutEnabled in - let override = isWorkoutEnabled ? self.loopManager.settings.legacyWorkoutOverride(for: .infinity) : nil + let override = isWorkoutEnabled ? self.loopManager.watchInfo.legacyWorkoutOverride(for: .infinity) : nil self.sendOverride(override) }, selectedButton: buttonToSelect, @@ -244,24 +243,23 @@ final class ActionHUDController: HUDInterfaceController { updateForOverrideContext(override?.context) pendingMessageResponses += 1 - var settings = loopManager.settings - let isPreMealEnabled = settings.preMealOverride?.isActive() == true + var watchInfo = loopManager.watchInfo + let isPreMealEnabled = watchInfo.preMealOverride?.isActive() == true if override?.context == .legacyWorkout { - settings.preMealOverride = nil + watchInfo.preMealOverride = nil } - settings.scheduleOverride = override + watchInfo.scheduleOverride = override - let userInfo = LoopSettingsUserInfo(settings: settings) do { - try WCSession.default.sendSettingsUpdateMessage(userInfo, completionHandler: { (result) in + try WCSession.default.sendSettingsUpdateMessage(watchInfo, completionHandler: { (result) in DispatchQueue.main.async { self.pendingMessageResponses -= 1 switch result { case .success(let context): if self.pendingMessageResponses == 0 { - self.loopManager.settings.scheduleOverride = override - self.loopManager.settings.preMealOverride = settings.preMealOverride + self.loopManager.watchInfo.scheduleOverride = override + self.loopManager.watchInfo.preMealOverride = watchInfo.preMealOverride } ExtensionDelegate.shared().loopManager.updateContext(context) diff --git a/WatchApp Extension/Controllers/OverrideSelectionController.swift b/WatchApp Extension/Controllers/OverrideSelectionController.swift index ba79776138..93537cd987 100644 --- a/WatchApp Extension/Controllers/OverrideSelectionController.swift +++ b/WatchApp Extension/Controllers/OverrideSelectionController.swift @@ -23,7 +23,7 @@ final class OverrideSelectionController: WKInterfaceController, IdentifiableClas @IBOutlet private var table: WKInterfaceTable! private let loopManager = ExtensionDelegate.shared().loopManager - private lazy var presets = loopManager.settings.overridePresets + private lazy var presets = loopManager.watchInfo.loopSettings.overridePresets weak var delegate: OverrideSelectionControllerDelegate? diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 1ef1d13d75..946669adf4 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -219,9 +219,9 @@ extension ExtensionDelegate: WCSessionDelegate { switch name { case LoopSettingsUserInfo.name: - if let settings = LoopSettingsUserInfo(rawValue: userInfo)?.settings { + if let loopSettings = LoopSettingsUserInfo(rawValue: userInfo) { DispatchQueue.main.async { - self.loopManager.settings = settings + self.loopManager.watchInfo = loopSettings } } else { log.error("Could not decode LoopSettingsUserInfo: %{public}@", userInfo) diff --git a/WatchApp Extension/Extensions/WCSession.swift b/WatchApp Extension/Extensions/WCSession.swift index 246eff2b2c..6eb309309f 100644 --- a/WatchApp Extension/Extensions/WCSession.swift +++ b/WatchApp Extension/Extensions/WCSession.swift @@ -73,7 +73,7 @@ extension WCSession { ) } - func sendSettingsUpdateMessage(_ userInfo: LoopSettingsUserInfo, completionHandler: @escaping (Result) -> Void) throws { + func sendSettingsUpdateMessage(_ userInfo: LoopSettingsUserInfo, completionHandler: @escaping (Result) -> Void) throws { guard activationState == .activated else { throw MessageError.activation } @@ -159,7 +159,7 @@ extension WCSession { ) } - func sendContextRequestMessage(_ userInfo: WatchContextRequestUserInfo, completionHandler: @escaping (Result) -> Void) throws { + func sendContextRequestMessage(_ userInfo: WatchContextRequestUserInfo, completionHandler: @escaping (Result) -> Void) throws { guard activationState == .activated else { throw MessageError.activation } diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 579b6a2148..1fcbdbd30c 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. @@ -20,14 +20,14 @@ class LoopDataManager { let glucoseStore: GlucoseStore @PersistedProperty(key: "Settings") - private var rawSettings: LoopSettings.RawValue? + private var rawWatchInfo: LoopSettingsUserInfo.RawValue? // Main queue only - var settings: LoopSettings { + var watchInfo: LoopSettingsUserInfo { didSet { needsDidUpdateContextNotification = true sendDidUpdateContextNotificationIfNecessary() - rawSettings = settings.rawValue + rawWatchInfo = watchInfo.rawValue } } @@ -40,7 +40,7 @@ class LoopDataManager { } } - private let log = OSLog(category: "LoopDataManager") + private let log = OSLog(category: "LoopDosingManager") // Main queue only private(set) var activeContext: WatchContext? { @@ -67,19 +67,21 @@ class LoopDataManager { 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 + cacheLength: .hours(4) ) - settings = LoopSettings() + self.watchInfo = LoopSettingsUserInfo( + loopSettings: LoopSettings(), + scheduleOverride: nil, + preMealOverride: nil + ) - if let rawSettings = rawSettings, let storedSettings = LoopSettings(rawValue: rawSettings) { - self.settings = storedSettings + if let rawWatchInfo = rawWatchInfo, let watchInfo = LoopSettingsUserInfo(rawValue: rawWatchInfo) { + self.watchInfo = watchInfo } } } @@ -207,9 +209,9 @@ extension LoopDataManager { } let chartData = GlucoseChartData( unit: activeContext.displayGlucoseUnit, - correctionRange: self.settings.glucoseTargetRangeSchedule, - preMealOverride: self.settings.preMealOverride, - scheduleOverride: self.settings.scheduleOverride, + correctionRange: self.watchInfo.loopSettings.glucoseTargetRangeSchedule, + preMealOverride: self.watchInfo.preMealOverride, + scheduleOverride: self.watchInfo.scheduleOverride, historicalGlucose: historicalGlucose, predictedGlucose: (activeContext.isClosedLoop ?? false) ? activeContext.predictedGlucose?.values : nil ) diff --git a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift index 396da33e6b..8241fab62a 100644 --- a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift +++ b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift @@ -59,7 +59,7 @@ final class CarbAndBolusFlowViewModel: ObservableObject { self._bolusPickerValues = Published( initialValue: BolusPickerValues( supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, - maxBolus: loopManager.settings.maximumBolus ?? Self.defaultMaxBolus + maxBolus: loopManager.watchInfo.loopSettings.maximumBolus ?? Self.defaultMaxBolus ) ) @@ -80,7 +80,7 @@ final class CarbAndBolusFlowViewModel: ObservableObject { self.bolusPickerValues = BolusPickerValues( supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, - maxBolus: loopManager.settings.maximumBolus ?? Self.defaultMaxBolus + maxBolus: loopManager.watchInfo.loopSettings.maximumBolus ?? Self.defaultMaxBolus ) switch self.configuration { From 8f9932824def9f2159795031bb56a967bd72c07a Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 12 Jan 2024 15:30:27 -0800 Subject: [PATCH 011/184] Temporarily Disable Favorite Foods --- Loop/Views/SettingsView.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index c3ec98b8dd..63171e1a3f 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -82,9 +82,10 @@ public struct SettingsView: View { configurationSection } deviceSettingsSection - if FeatureFlags.allowExperimentalFeatures { - favoriteFoodsSection - } + // Disables for Coastal HF study +// if FeatureFlags.allowExperimentalFeatures { +// favoriteFoodsSection +// } if (viewModel.pumpManagerSettingsViewModel.isTestingDevice || viewModel.cgmManagerSettingsViewModel.isTestingDevice) && viewModel.showDeleteTestData { deleteDataSection } From 192654903bee90fb8861d5879021f0287c50c761 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 16 Jan 2024 11:37:39 -0800 Subject: [PATCH 012/184] [LOOP-4716] iOS 17 Widget Fixes --- .../Helpers/ContentMargin.swift | 20 +++++++++++++++++++ .../Helpers/WidgetBackground.swift | 8 +++++++- .../Widgets/SystemStatusWidget.swift | 1 + Loop.xcodeproj/project.pbxproj | 4 ++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 Loop Widget Extension/Helpers/ContentMargin.swift diff --git a/Loop Widget Extension/Helpers/ContentMargin.swift b/Loop Widget Extension/Helpers/ContentMargin.swift new file mode 100644 index 0000000000..dffb63d615 --- /dev/null +++ b/Loop Widget Extension/Helpers/ContentMargin.swift @@ -0,0 +1,20 @@ +// +// ContentMargin.swift +// Loop Widget Extension +// +// Created by Cameron Ingham on 1/16/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import WidgetKit + +extension WidgetConfiguration { + func contentMarginsDisabledIfAvailable() -> some WidgetConfiguration { + if #available(iOSApplicationExtension 17.0, *) { + return self.contentMarginsDisabled() + } else { + return self + } + } +} diff --git a/Loop Widget Extension/Helpers/WidgetBackground.swift b/Loop Widget Extension/Helpers/WidgetBackground.swift index 9883f4917a..6bc0fec968 100644 --- a/Loop Widget Extension/Helpers/WidgetBackground.swift +++ b/Loop Widget Extension/Helpers/WidgetBackground.swift @@ -11,6 +11,12 @@ import SwiftUI extension View { @ViewBuilder func widgetBackground() -> some View { - self.background { Color("WidgetBackground") } + if #available(iOSApplicationExtension 17.0, *) { + containerBackground(for: .widget) { + background { Color("WidgetBackground") } + } + } else { + background { Color("WidgetBackground") } + } } } diff --git a/Loop Widget Extension/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift index 8546409b5c..a64096d2ad 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -76,5 +76,6 @@ struct SystemStatusWidget: Widget { .configurationDisplayName("Loop Status Widget") .description("See your current blood glucose and insulin delivery.") .supportedFamilies([.systemSmall, .systemMedium]) + .contentMarginsDisabledIfAvailable() } } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index ab382ca1de..32667d60ba 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -242,6 +242,7 @@ 7D70765E1FE06EE3004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076601FE06EE3004AC8EA /* Localizable.strings */; }; 7D7076631FE06EE4004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076651FE06EE4004AC8EA /* Localizable.strings */; }; 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */; }; + 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */; }; 84AA81D82A4A2910000B658B /* StatusWidgetTimelimeEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */; }; @@ -1149,6 +1150,7 @@ 7DD382791F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Interface.strings; sourceTree = ""; }; 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetLoopManager.swift; sourceTree = ""; }; 80F864E52433BF5D0026EC26 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; + 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; 84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelimeEntry.swift; sourceTree = ""; }; @@ -2487,6 +2489,7 @@ children = ( 84AA81DA2A4A2973000B658B /* Date.swift */, 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */, + 8496F7302B5711C4003E672C /* ContentMargin.swift */, ); path = Helpers; sourceTree = ""; @@ -3503,6 +3506,7 @@ 14B1737328AEDBF6006CCD7C /* SystemStatusWidget.swift in Sources */, 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */, 14B1737528AEDBF6006CCD7C /* LoopCircleView.swift in Sources */, + 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 97d5e101b5f39fad0431f25e2227e3332a37df58 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 16 Jan 2024 13:52:52 -0800 Subject: [PATCH 013/184] [LOOP-4788] Fix Unit Tests for iOS 17 --- Loop/Managers/Alerts/StoredAlert.swift | 16 ++++-- .../Managers/Alerts/AlertManagerTests.swift | 17 ++----- .../Managers/Alerts/AlertStoreTests.swift | 19 ++++--- .../Managers/Alerts/StoredAlertTests.swift | 50 +++++++++---------- .../ViewModels/BolusEntryViewModelTests.swift | 4 +- 5 files changed, 52 insertions(+), 54 deletions(-) diff --git a/Loop/Managers/Alerts/StoredAlert.swift b/Loop/Managers/Alerts/StoredAlert.swift index fb5b431074..db8805380a 100644 --- a/Loop/Managers/Alerts/StoredAlert.swift +++ b/Loop/Managers/Alerts/StoredAlert.swift @@ -12,9 +12,19 @@ import UIKit extension StoredAlert { - static var encoder = JSONEncoder() - static var decoder = JSONDecoder() - + static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + 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: diff --git a/LoopTests/Managers/Alerts/AlertManagerTests.swift b/LoopTests/Managers/Alerts/AlertManagerTests.swift index 2250c1a16c..44403da913 100644 --- a/LoopTests/Managers/Alerts/AlertManagerTests.swift +++ b/LoopTests/Managers/Alerts/AlertManagerTests.swift @@ -361,20 +361,9 @@ class AlertManagerTests: XCTestCase { } wait(for: [testExpectation], timeout: 1) - if #available(iOS 15.0, *) { - XCTAssertNil(loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .timeSensitive })?.content.sound) - if let request = loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .critical }) { - XCTAssertEqual(request.content.sound, .defaultCriticalSound(withAudioVolume: 0)) - } - } else if FeatureFlags.criticalAlertsEnabled { - for request in loopNotRunningRequests { - let sound = request.content.sound - XCTAssertTrue(sound == nil || sound == .defaultCriticalSound(withAudioVolume: 0.0)) - } - } else { - for request in loopNotRunningRequests { - XCTAssertNil(request.content.sound) - } + XCTAssertNil(loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .timeSensitive })?.content.sound) + if let request = loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .critical }) { + XCTAssertEqual(request.content.sound, .defaultCriticalSound(withAudioVolume: 0)) } } } diff --git a/LoopTests/Managers/Alerts/AlertStoreTests.swift b/LoopTests/Managers/Alerts/AlertStoreTests.swift index 3f6286cf17..81ba581e0c 100644 --- a/LoopTests/Managers/Alerts/AlertStoreTests.swift +++ b/LoopTests/Managers/Alerts/AlertStoreTests.swift @@ -72,8 +72,8 @@ class AlertStoreTests: XCTestCase { let object = StoredAlert(from: alert2, context: alertStore.managedObjectContext, issuedDate: Self.historicDate) XCTAssertNil(object.acknowledgedDate) XCTAssertNil(object.retractedDate) - XCTAssertEqual("{\"title\":\"title\",\"acknowledgeActionButtonLabel\":\"label\",\"body\":\"body\"}", object.backgroundContent) - XCTAssertEqual("{\"title\":\"title\",\"acknowledgeActionButtonLabel\":\"label\",\"body\":\"body\"}", object.foregroundContent) + XCTAssertEqual("{\"acknowledgeActionButtonLabel\":\"label\",\"body\":\"body\",\"title\":\"title\"}", object.backgroundContent) + XCTAssertEqual("{\"acknowledgeActionButtonLabel\":\"label\",\"body\":\"body\",\"title\":\"title\"}", object.foregroundContent) XCTAssertEqual("managerIdentifier2.alertIdentifier2", object.identifier.value) XCTAssertEqual(Self.historicDate, object.issuedDate) XCTAssertEqual(1, object.modificationCounter) @@ -870,14 +870,13 @@ class AlertStoreLogCriticalEventLogTests: XCTestCase { endDate: dateFormatter.date(from: "2100-01-02T03:09:00Z")!, to: outputStream, progress: progress)) - XCTAssertEqual(outputStream.string, """ -[ -{"acknowledgedDate":"2100-01-02T03:08:00.000Z","alertIdentifier":"a1","backgroundContent":"{\\\"title\\\":\\\"BACKGROUND\\\",\\\"acknowledgeActionButtonLabel\\\":\\\"OK\\\",\\\"body\\\":\\\"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":"{\\\"title\\\":\\\"BACKGROUND\\\",\\\"acknowledgeActionButtonLabel\\\":\\\"OK\\\",\\\"body\\\":\\\"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":"{\\\"title\\\":\\\"BACKGROUND\\\",\\\"acknowledgeActionButtonLabel\\\":\\\"OK\\\",\\\"body\\\":\\\"background\\\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:06:00.000Z","managerIdentifier":"m4","modificationCounter":4,"syncIdentifier":"4B3109BD-DE11-42BD-A777-D4783459C483","triggerType":0} -] -""" - ) + 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) } diff --git a/LoopTests/Managers/Alerts/StoredAlertTests.swift b/LoopTests/Managers/Alerts/StoredAlertTests.swift index 504a672fae..63bb93b3f3 100644 --- a/LoopTests/Managers/Alerts/StoredAlertTests.swift +++ b/LoopTests/Managers/Alerts/StoredAlertTests.swift @@ -45,34 +45,34 @@ class StoredAlertEncodableTests: XCTestCase { let storedAlert = StoredAlert(from: alert, context: managedObjectContext, syncIdentifier: UUID(uuidString: "A7073F28-0322-4506-A733-CF6E0687BAF7")!) XCTAssertEqual(.active, storedAlert.interruptionLevel) storedAlert.issuedDate = dateFormatter.date(from: "2020-05-14T21:00:12Z")! - try! assertStoredAlertEncodable(storedAlert, encodesJSON: """ - { - "alertIdentifier" : "bar", - "backgroundContent" : "{\\\"title\\\":\\\"BACKGROUND\\\",\\\"acknowledgeActionButtonLabel\\\":\\\"OK\\\",\\\"body\\\":\\\"background\\\"}", - "interruptionLevel" : "active", - "issuedDate" : "2020-05-14T21:00:12Z", - "managerIdentifier" : "foo", - "modificationCounter" : 1, - "syncIdentifier" : "A7073F28-0322-4506-A733-CF6E0687BAF7", - "triggerType" : 0 - } - """ + 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 + } + """# ) - + storedAlert.interruptionLevel = .critical XCTAssertEqual(.critical, storedAlert.interruptionLevel) - try! assertStoredAlertEncodable(storedAlert, encodesJSON: """ - { - "alertIdentifier" : "bar", - "backgroundContent" : "{\\\"title\\\":\\\"BACKGROUND\\\",\\\"acknowledgeActionButtonLabel\\\":\\\"OK\\\",\\\"body\\\":\\\"background\\\"}", - "interruptionLevel" : "critical", - "issuedDate" : "2020-05-14T21:00:12Z", - "managerIdentifier" : "foo", - "modificationCounter" : 1, - "syncIdentifier" : "A7073F28-0322-4506-A733-CF6E0687BAF7", - "triggerType" : 0 - } - """ + 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 + } + """# ) } } diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 790a3bcd0a..f5667f2857 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -723,14 +723,14 @@ class BolusEntryViewModelTests: XCTestCase { func testCarbEntryDateAndAbsorptionTimeString() async throws { await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) - XCTAssertEqual("12:00 PM + 0m", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + XCTAssertEqual("12:00 PM + 0m", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) } func testCarbEntryDateAndAbsorptionTimeString2() async throws { let potentialCarbEntry = NewCarbEntry(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: Self.exampleStartDate, foodType: nil, absorptionTime: nil) await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: potentialCarbEntry) - XCTAssertEqual("12:00 PM", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + XCTAssertEqual("12:00 PM", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) } func testIsManualGlucosePromptVisible() throws { From de382e6d40c2afd435ea726f929817fa81fb3634 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 22 Jan 2024 14:15:21 -0400 Subject: [PATCH 014/184] revert change for experimental features (#612) --- Loop/Views/SettingsView.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 63171e1a3f..c3ec98b8dd 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -82,10 +82,9 @@ public struct SettingsView: View { configurationSection } deviceSettingsSection - // Disables for Coastal HF study -// if FeatureFlags.allowExperimentalFeatures { -// favoriteFoodsSection -// } + if FeatureFlags.allowExperimentalFeatures { + favoriteFoodsSection + } if (viewModel.pumpManagerSettingsViewModel.isTestingDevice || viewModel.cgmManagerSettingsViewModel.isTestingDevice) && viewModel.showDeleteTestData { deleteDataSection } From 2d5a3bc5c269460db905b5b93c93bdb317910e77 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 22 Jan 2024 11:22:40 -0800 Subject: [PATCH 015/184] [LOOP-4762] Fix Alert Management Icon Alignment --- .../hardware.imageset/Contents.json | 2 +- .../hardware.imageset/Group 3403.pdf | Bin 9522 -> 0 bytes .../hardware.imageset/hardware.pdf | Bin 0 -> 7297 bytes .../phone.imageset/Contents.json | 2 +- .../phone.imageset/Group 3405.pdf | Bin 1891 -> 0 bytes .../phone.imageset/phone.pdf | Bin 0 -> 1889 bytes Loop/Views/AlertManagementView.swift | 13 +++++++------ 7 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf create mode 100644 Loop/DefaultAssets.xcassets/hardware.imageset/hardware.pdf delete mode 100644 Loop/DefaultAssets.xcassets/phone.imageset/Group 3405.pdf create mode 100644 Loop/DefaultAssets.xcassets/phone.imageset/phone.pdf 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 14057221edafce035c4c3188bdadf5d3fa536df1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9522 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 Mb9(md)nC5{EHtUem z3?OM6xEB)V6nP)#KFDG{d;RkJZ)DBOBF38IKmNHG^X<3h>ec)64{y%9^WpNBYya(c z#;(!o&pdyQnU8)~ujF%PK7X$35BG0X;o?5f{HMeHyYtV+1H9H*r}M*Mceh-2Y9>q)dw#jnE^V^OKXa$#ApUYA^U|OvD<-lx!DW_o7g^GhSdFR@iy&t z3E`sOkC|ccaWOoa0nrY~<2*+c0teb%^JY>)uAbs=zx+HH9KpDQ%g&dWlNlgGSUsg-wg$ zO7}#2%DdC&l{g*pb#v`Y9AX*5qiwwqghvFK{kIFlC{KcupF@;9iOwKBc@kt(LYv^2 zgBhG`qNJy=VOvY1FUcZQ=2m-D@dd22B<>a(MohbE7f#tz5>4|)!Z3@tVr&N3>pm9e zX?`<0B>c2N_!P2D804C4F2C&t*!6$g4K#bq|J80VN`j^4Scso4iP81g2=5kvF*;X* zAt)@fvWAeeBr~AVt(O_>GKRF#H;3AR?H7p`+0hIXSTFgy88t)wSzFu3!$67~Zjc&6 zdYln%4RjMM5KJ`7Uu=kma)h)Kf-!DeL2z{FA&q+zwYH_*kG$y`(rEP1N(f779l_zl zkVdyAt)O6|nf`!*%>rVy*l!LJq1fp>jB|$xJHu~9Oo17q9|L3( z3~9rM$G&P7@CEbgmHX6d**IC?K+*4w@UjqNa#_r$j~0kRTjkWp%c7QXf{U6e14K-7 zwGq1n=7kt+HGq`(+URj1yNa_bK*>&`Z=4meVvsh~qkI)|QY`FJNJ^{$P^dnO)1?5d z)TCfeSQ1o)zz(0=R-RnJfu^p$m6PIKfQl`i3T@nKezbrq=!z9rRBM+;I)_$58e|-JaY$(|RO5&4%_=Nkkbb+G2K%8Q|TZK|_>SS0Q{zr(h6sMVz5?Bsv zDApq^x>Q4^hUAos8Ui7?go@j*wZNj7E?V*Smqas4Y|T;(eDF~Uj;$ELaD-9Mpfwg` z0Em|scW4Cx$roP~EO4jIOFr7DbOvXO$8qK{!;N?WQ9j`P6L)=LQ*}x_?q_j#2xCy3 z$tUD8AY3O#hv35xj|^4_i64^Q1*U-5NA&}DnaEb$W#T;Ptiw~?ui%>1n?Q$@#_J3q zfl`D=)>=>mnzmJ|z$FM?i%w4lRc3=LpaMFf+XD$zJXmk$NYH@Q)rA$`3v^lGZHQ{VNU1SnRxXII8mhJfK|}Y^qcYKQ;c3e))n|j~ z-Cl~QN2^wWY+U4mn^-F%Ti1kVaLYlTAfOj&Y(ZzhW#ejrCa?->B&FaQ`>6It4NSaT#Nawv3$LIymx4jNgH8@M19Pwx z6x$WqiF8y1u7W&^>`dq~x-L3gRI4GNs(Kg)Pmz$w5JB=KTa-ogLF9^t1_9MJfwV<& zHQ&KM+KR3d%}e-+W>O%d4MGk&NHJGP!oVdpSoP_PTT!84+*K45yg@rw0f@`R&Kh09 zn;#g-F|=}mHwA<>HDZxq@FswLG#KnM8D)ZR0LVeFH3~a3YFO>mdz%=1sQ_a}H?c;c zfILAvTT2ymeO>txqslW1ELByH5{T$l7Cfi5AcW{SYEZ?Vfxe1K73~5BX-n4kvf!$A zZv8M2KmqJP`vQqh%|`Md@w8Wzf_(!B_gt~qM|7P{-I|3s-qupF5i<9x&c4-fS>6z)9>%^kLQ#5@$Z+9NmqaS@4pV_>hi) zX!)u;#8X4CWH_zTv`Bl}N4CP@hRBuO1SQ6avfb;n=q&b!;=yAPMX ze>nWKSd*8F`}(f|rPxFO_a6$djm-T9p)G#|rHoHgAI?Im)Q_R6wWvSJ(xU*O^TY1u z_VBRiN5Vh61RYjBocPkFnCb8{!7=$B{D JUj60!{{hO1ihKY7 literal 0 HcmV?d00001 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 fc12ec3959bd91f291a6080775c7782ca31f7b5c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1891 zcma)-O>fjN5Qgvm6?3W79_rX0f5cK%iEb%EfLONNDh?r=wu`n2Y*JMC^^A9uacB?V zL*;#9&%86X-ySV5uTHfLLTFIXef=f?&d%WcTr}-E{S;=-i}y`?H{1gfT%}e0uxqLZ zQCv1ZH+8dma{-I%`LDVcKZKTQKR`{BQ=Yy2#-Hj3p(J`6l&76GSf>^6`o3=I^B6ct zZC%tJ(w31KY0(E0fhOszg)yr&kk%U){0!B?I9WPd;+$>;D(P&{M$gtFzDSZ0YpF$s zPD>lDEw3O$Y|g1fW|AFQ7R=$ zN6e@hMJ?&HFc8aGGGjT^ai%Ps8qN?UMi)yBISUn_EGnDYJ<4H}x;+YKM&YLVn9=0W zEsoEGWz^KwThWS7|N9`EbU~FifVI*aU!sP8zX&6PQ_ANn9MZu>Nr&Yr!1MwFot#m! z)MfByMvbN?;6gCzouMyFdOeI%Y-iB{9UVg_eHolKDImijt#XlWK+aK0YfX#@SqvkJ zH_2v}ZWbwsh`@~RFUM!XGG-T=^=|Ve=dw6@ZV8LXC?8t#!_n*}XG|t|a7=BSV#BnI z$w-S=ZQBh!+ZXTe z-K#O&qHBkyrHsk9C%CM3#K4Vc=I#lx>lg1fYmy47f)z}87TqK5)i3n-L}ro5qi2y) zfjuY8g)ge1+IDw;Z++S|w?aV>2j@fjN5Qgvm6?3W79_sk}L#is#Eky_rWy`JN5VCH&Xq!NiqQb9dyqkNRC3Rl1OeUm9}M8^49?G4YB%{WQ%hcbOzr*n02<*s&l<)))sL*Y zOux5HTEDx1)y?u>Gq7Jwa%tm(6sn^ zitM>GHrfDr$;1>l+IcSl2l=fj`SWyTeGWTKZkVz>SQGRD?wA(b8ZS`{&D45cLkS8Z z9StkcN^YW2DgXwg)z)z(vE5vi z+%KMy1z_eVgpx8x!97$8P0Ngv8XSoZA^T96Ab0e_N7D|r5jnP6!C}Id< zyjeD}e6;ooG!UZ$O!0}3jKzs2y}NSCsVs_K8bu-sD!Z25aI(7D7qbN({8B0t$S%=j zij7up+O``9xc!EE@F{J!^+op#x5Z#eS!Q+`M{JI?PDA(9H)()u z-0Laas%yv8V#e&+GrX>QWMC(>@b-+z^Q#Zr4N8Sv!5Zc~tL_Qc%2)aaB#TIBnu|!e zz=0B$!dLZJ@4EY!OP~96#{_tGaN_(6&RT5&@${hY??-@^KRtLjUm}TK`U*MhPJU>i zw67OBQ5KGUz1^jr;YYA~e~Ill9=gYL0>|6U$)PFLy6Z;B1@9YN-E99RRKNci*8R9I OM>^-((b3z_SN{Mni;Cj_ literal 0 HcmV?d00001 diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index e9a38e72a0..e8568ba4d7 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -75,11 +75,11 @@ struct AlertManagementView: View { private var footerView: some View { VStack(alignment: .leading, spacing: 24) { - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .top, spacing: 16) { Image("phone") .resizable() .aspectRatio(contentMode: .fit) - .frame(maxWidth: 64, maxHeight: 64) + .frame(width: 54) VStack(alignment: .leading, spacing: 4) { Text( @@ -104,11 +104,11 @@ struct AlertManagementView: View { } } - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .top, spacing: 16) { Image("hardware") .resizable() .aspectRatio(contentMode: .fit) - .frame(maxWidth: 64, maxHeight: 64) + .frame(width: 54) VStack(alignment: .leading, spacing: 4) { Text("HARDWARE SOUNDS") @@ -117,12 +117,13 @@ struct AlertManagementView: View { } } - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .top, spacing: 16) { Image(systemName: "moon.fill") .resizable() .aspectRatio(contentMode: .fit) - .frame(maxWidth: 64, maxHeight: 48) + .frame(width: 44) .foregroundColor(.accentColor) + .padding(.horizontal, 5) VStack(alignment: .leading, spacing: 4) { Text("IOS FOCUS MODES") From d4b64205d14126a283d54468064b601624c7f70b Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 26 Jan 2024 12:57:38 -0400 Subject: [PATCH 016/184] [PAL-360] refactoring CancelTempBasalFailedError when .maximumBasalRateChanged (#614) * refactoring CancelTempBasalFailedError when .maximumBasalRateChanged * clean up * response to PR comment --- Loop/Managers/DeviceDataManager.swift | 27 ++++++++++++--------------- Loop/Managers/LoopDataManager.swift | 9 +++++++-- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 234dc4eed4..67746212f3 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -16,7 +16,7 @@ import Combine protocol LoopControl { var lastLoopCompleted: Date? { get } - func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async + func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async throws func loop() async } @@ -440,7 +440,7 @@ final class DeviceDataManager { } // Cancel active high temp basal - await loopControl.cancelActiveTempBasal(for: .unreliableCGMData) + try? await loopControl.cancelActiveTempBasal(for: .unreliableCGMData) } private func processCGMReadingResult(_ manager: CGMManager, readingResult: CGMReadingResult) async { @@ -1277,7 +1277,7 @@ extension GlucoseStore : CGMStalenessMonitorDelegate { } //MARK: TherapySettingsViewModelDelegate -struct CancelTempBasalFailedError: LocalizedError { +struct CancelTempBasalFailedMaximumBasalRateChangedError: LocalizedError { let reason: Error? var errorDescription: String? { @@ -1317,19 +1317,16 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits { - do { - // FIRST we need to check to make sure if we have to cancel temp basal first - 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 - await self.loopControl.cancelActiveTempBasal(for: .maximumBasalRateChanged) - } - return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits - } catch { - throw CancelTempBasalFailedError(reason: error) + // FIRST we need to check to make sure if we have to cancel temp basal first + 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) { diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 697007d76d..7afb2f7244 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -213,7 +213,7 @@ final class LoopDataManager { if !$0 { self.temporaryPresetsManager.clearOverride(matching: .preMeal) Task { - await self.cancelActiveTempBasal(for: .automaticDosingDisabled) + try? await self.cancelActiveTempBasal(for: .automaticDosingDisabled) } } else { Task { @@ -408,7 +408,7 @@ final class LoopDataManager { } /// Cancel the active temp basal if it was automatically issued - func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async { + 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)) @@ -423,6 +423,11 @@ final class LoopDataManager { try await deliveryDelegate?.enact(recommendation) } catch { dosingDecision.appendError(error as? LoopError ?? .unknownError(error)) + if reason == .maximumBasalRateChanged { + throw CancelTempBasalFailedMaximumBasalRateChangedError(reason: error) + } else { + throw error + } } await dosingDecisionStore.storeDosingDecision(dosingDecision) From e4130361f01395e8ad09a1974fc03950887a714c Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 29 Jan 2024 15:59:11 -0400 Subject: [PATCH 017/184] always load extensions (#615) --- Loop/Plugins/PluginManager.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Loop/Plugins/PluginManager.swift b/Loop/Plugins/PluginManager.swift index a254d26872..3128ec4c61 100644 --- a/Loop/Plugins/PluginManager.swift +++ b/Loop/Plugins/PluginManager.swift @@ -27,6 +27,12 @@ class PluginManager { log.debug("Found loop plugin: %{public}@", pluginURL.absoluteString) bundles.append(bundle) } + + // extensions are always instantiated + if bundle.isLoopExtension { + log.debug("Found loop extension: %{public}@", pluginURL.absoluteString) + _ = try? bundle.loadAndInstantiateExtension() + } } } } catch let error { @@ -36,8 +42,6 @@ class PluginManager { self.pluginBundles = bundles } - - func getPumpManagerTypeByIdentifier(_ identifier: String) -> PumpManagerUI.Type? { for bundle in pluginBundles { if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.pumpManagerIdentifier.rawValue) as? String, name == identifier { @@ -248,4 +252,14 @@ extension Bundle { var isLoopExtension: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.extensionIdentifier.rawValue) as? String != nil } var isSimulator: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.pluginIsSimulator.rawValue) as? Bool == true } + + fileprivate func loadAndInstantiateExtension() throws -> NSObject? { + try loadAndReturnError() + + guard let principalClass = principalClass as? NSObject.Type else { + return nil + } + + return principalClass.init() + } } From 04583c3464db9a99fb42d725db96ec083a84eeab Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 8 Feb 2024 14:10:38 -0800 Subject: [PATCH 018/184] [LOOP-4793] Beginning XCUI Tests --- Loop.xcodeproj/project.pbxproj | 246 +++++++++++++++++- .../StatusTableViewController.swift | 5 + Loop/Views/AlertManagementView.swift | 1 + Loop/Views/BolusEntryView.swift | 1 - ...icationsCriticalAlertPermissionsView.swift | 10 + Loop/Views/SettingsView.swift | 6 + LoopUI/Views/LoopCompletionHUDView.swift | 2 + LoopUI/Views/PumpStatusHUDView.swift | 16 ++ LoopUI/Views/StatusBarHUDView.swift | 3 + LoopUI/Views/StatusHighlightHUDView.swift | 2 + LoopUITests/Helpers/Common.swift | 33 +++ LoopUITests/LoopUITests.swift | 186 +++++++++++++ LoopUITests/Screens/BaseScreen.swift | 47 ++++ LoopUITests/Screens/HomeScreen.swift | 238 +++++++++++++++++ LoopUITests/Screens/OnboardingScreen.swift | 83 ++++++ LoopUITests/Screens/PumpSimulatorScreen.swift | 133 ++++++++++ LoopUITests/Screens/SettingsScreen.swift | 125 +++++++++ .../Screens/SystemSettingsScreen.swift | 62 +++++ 18 files changed, 1197 insertions(+), 2 deletions(-) create mode 100644 LoopUITests/Helpers/Common.swift create mode 100644 LoopUITests/LoopUITests.swift create mode 100644 LoopUITests/Screens/BaseScreen.swift create mode 100644 LoopUITests/Screens/HomeScreen.swift create mode 100644 LoopUITests/Screens/OnboardingScreen.swift create mode 100644 LoopUITests/Screens/PumpSimulatorScreen.swift create mode 100644 LoopUITests/Screens/SettingsScreen.swift create mode 100644 LoopUITests/Screens/SystemSettingsScreen.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 32667d60ba..249616f182 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -242,6 +242,12 @@ 7D70765E1FE06EE3004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076601FE06EE3004AC8EA /* Localizable.strings */; }; 7D7076631FE06EE4004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076651FE06EE4004AC8EA /* Localizable.strings */; }; 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */; }; + 8402E9022B72B9D200E3EB1F /* LoopUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8402E9012B72B9D200E3EB1F /* LoopUITests.swift */; }; + 8402E9042B72F19400E3EB1F /* PumpSimulatorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8402E9032B72F19400E3EB1F /* PumpSimulatorScreen.swift */; }; + 845DD3362B6AE07300B0E700 /* OnboardingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845DD32E2B6AE07300B0E700 /* OnboardingScreen.swift */; }; + 845DD3372B6AE07300B0E700 /* BaseScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845DD32F2B6AE07300B0E700 /* BaseScreen.swift */; }; + 845DD3382B6AE07300B0E700 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845DD3302B6AE07300B0E700 /* HomeScreen.swift */; }; + 845DD33A2B6AE07300B0E700 /* Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845DD3332B6AE07300B0E700 /* Common.swift */; }; 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */; }; @@ -251,6 +257,8 @@ 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 */; }; + 84FF23402B6DD10200C08A87 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FF233F2B6DD10200C08A87 /* SettingsScreen.swift */; }; + 84FF23422B6DDAE400C08A87 /* SystemSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FF23412B6DDAE400C08A87 /* SystemSettingsScreen.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 */; }; @@ -609,6 +617,13 @@ remoteGlobalIDString = 4F75288A1DFE1DC600C322D6; remoteInfo = LoopUI; }; + 845DD3272B6AE05900B0E700 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 43776F841B8022E90074EA36 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 43776F8B1B8022E90074EA36; + remoteInfo = Loop; + }; C117ED70232EDB3200DA57CD /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; @@ -1150,6 +1165,13 @@ 7DD382791F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Interface.strings; sourceTree = ""; }; 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetLoopManager.swift; sourceTree = ""; }; 80F864E52433BF5D0026EC26 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; + 8402E9012B72B9D200E3EB1F /* LoopUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopUITests.swift; sourceTree = ""; }; + 8402E9032B72F19400E3EB1F /* PumpSimulatorScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpSimulatorScreen.swift; sourceTree = ""; }; + 845DD3212B6AE05900B0E700 /* LoopUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoopUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 845DD32E2B6AE07300B0E700 /* OnboardingScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingScreen.swift; sourceTree = ""; }; + 845DD32F2B6AE07300B0E700 /* BaseScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseScreen.swift; sourceTree = ""; }; + 845DD3302B6AE07300B0E700 /* HomeScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; + 845DD3332B6AE07300B0E700 /* Common.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Common.swift; sourceTree = ""; }; 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; @@ -1159,6 +1181,8 @@ 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 = ""; }; + 84FF233F2B6DD10200C08A87 /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; + 84FF23412B6DDAE400C08A87 /* SystemSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSettingsScreen.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; }; @@ -1771,6 +1795,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 845DD31E2B6AE05900B0E700 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E9B07F79253BBA6500BAD8F8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1937,6 +1968,7 @@ E9B07F7D253BBA6500BAD8F8 /* Loop Intent Extension */, 14B1736128AED9EC006CCD7C /* Loop Widget Extension */, A900531928D60852000BC15B /* Shortcuts */, + 845DD3222B6AE05900B0E700 /* LoopUITests */, 968DCD53F724DE56FFE51920 /* Frameworks */, 43776F8D1B8022E90074EA36 /* Products */, 437D9BA11D7B5203007245E8 /* Loop.xcconfig */, @@ -1957,6 +1989,7 @@ 43D9002A21EB209400AF44BF /* LoopCore.framework */, E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */, 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */, + 845DD3212B6AE05900B0E700 /* LoopUITests.xctest */, ); name = Products; sourceTree = ""; @@ -2461,6 +2494,45 @@ path = Common; sourceTree = ""; }; + 845DD3222B6AE05900B0E700 /* LoopUITests */ = { + isa = PBXGroup; + children = ( + 8402E9012B72B9D200E3EB1F /* LoopUITests.swift */, + 845DD3322B6AE07300B0E700 /* Helpers */, + 845DD32D2B6AE07300B0E700 /* Screens */, + 845DD3352B6AE07300B0E700 /* TestPlans */, + ); + path = LoopUITests; + sourceTree = ""; + }; + 845DD32D2B6AE07300B0E700 /* Screens */ = { + isa = PBXGroup; + children = ( + 845DD32E2B6AE07300B0E700 /* OnboardingScreen.swift */, + 845DD32F2B6AE07300B0E700 /* BaseScreen.swift */, + 845DD3302B6AE07300B0E700 /* HomeScreen.swift */, + 84FF233F2B6DD10200C08A87 /* SettingsScreen.swift */, + 84FF23412B6DDAE400C08A87 /* SystemSettingsScreen.swift */, + 8402E9032B72F19400E3EB1F /* PumpSimulatorScreen.swift */, + ); + path = Screens; + sourceTree = ""; + }; + 845DD3322B6AE07300B0E700 /* Helpers */ = { + isa = PBXGroup; + children = ( + 845DD3332B6AE07300B0E700 /* Common.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 845DD3352B6AE07300B0E700 /* TestPlans */ = { + isa = PBXGroup; + children = ( + ); + path = TestPlans; + sourceTree = ""; + }; 84AA81D12A4A2778000B658B /* Components */ = { isa = PBXGroup; children = ( @@ -3061,6 +3133,24 @@ productReference = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; productType = "com.apple.product-type.framework"; }; + 845DD3202B6AE05900B0E700 /* LoopUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 845DD32C2B6AE05900B0E700 /* Build configuration list for PBXNativeTarget "LoopUITests" */; + buildPhases = ( + 845DD31D2B6AE05900B0E700 /* Sources */, + 845DD31E2B6AE05900B0E700 /* Frameworks */, + 845DD31F2B6AE05900B0E700 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 845DD3282B6AE05900B0E700 /* PBXTargetDependency */, + ); + name = LoopUITests; + productName = LoopUITests; + productReference = 845DD3212B6AE05900B0E700 /* LoopUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; E9B07F7B253BBA6500BAD8F8 /* Loop Intent Extension */ = { isa = PBXNativeTarget; buildConfigurationList = E9B07F9A253BBA6500BAD8F8 /* Build configuration list for PBXNativeTarget "Loop Intent Extension" */; @@ -3084,7 +3174,7 @@ 43776F841B8022E90074EA36 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1340; + LastSwiftUpdateCheck = 1520; LastUpgradeCheck = 1010; ORGANIZATIONNAME = "LoopKit Authors"; TargetAttributes = { @@ -3178,6 +3268,10 @@ LastSwiftMigration = 1020; ProvisioningStyle = Automatic; }; + 845DD3202B6AE05900B0E700 = { + CreatedOnToolsVersion = 15.2; + TestTargetID = 43776F8B1B8022E90074EA36; + }; E9B07F7B253BBA6500BAD8F8 = { ProvisioningStyle = Automatic; }; @@ -3233,6 +3327,7 @@ 43D9001A21EB209400AF44BF /* LoopCore-watchOS */, 4F75288A1DFE1DC600C322D6 /* LoopUI */, 43E2D90A1D20C581004DA55F /* LoopTests */, + 845DD3202B6AE05900B0E700 /* LoopUITests */, ); }; /* End PBXProject section */ @@ -3353,6 +3448,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 845DD31F2B6AE05900B0E700 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E9B07F7A253BBA6500BAD8F8 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -3961,6 +4063,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 845DD31D2B6AE05900B0E700 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 84FF23402B6DD10200C08A87 /* SettingsScreen.swift in Sources */, + 845DD3382B6AE07300B0E700 /* HomeScreen.swift in Sources */, + 84FF23422B6DDAE400C08A87 /* SystemSettingsScreen.swift in Sources */, + 8402E9022B72B9D200E3EB1F /* LoopUITests.swift in Sources */, + 845DD3372B6AE07300B0E700 /* BaseScreen.swift in Sources */, + 845DD3362B6AE07300B0E700 /* OnboardingScreen.swift in Sources */, + 8402E9042B72F19400E3EB1F /* PumpSimulatorScreen.swift in Sources */, + 845DD33A2B6AE07300B0E700 /* Common.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; E9B07F78253BBA6500BAD8F8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -4019,6 +4136,11 @@ target = 4F75288A1DFE1DC600C322D6 /* LoopUI */; targetProxy = 4F7528961DFE1ED400C322D6 /* PBXContainerItemProxy */; }; + 845DD3282B6AE05900B0E700 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 43776F8B1B8022E90074EA36 /* Loop */; + targetProxy = 845DD3272B6AE05900B0E700 /* PBXContainerItemProxy */; + }; C117ED71232EDB3200DA57CD /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 43D9001A21EB209400AF44BF /* LoopCore-watchOS */; @@ -5373,6 +5495,118 @@ }; name = Release; }; + 845DD3292B6AE05900B0E700 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 75U4X84TEG; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.LoopUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Loop; + }; + name = Debug; + }; + 845DD32A2B6AE05900B0E700 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 75U4X84TEG; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.LoopUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Loop; + }; + name = Testflight; + }; + 845DD32B2B6AE05900B0E700 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 75U4X84TEG; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.LoopUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Loop; + }; + name = Release; + }; B4E7CF912AD00A39009B4DF2 /* Testflight */ = { isa = XCBuildConfiguration; baseConfigurationReference = 437D9BA11D7B5203007245E8 /* Loop.xcconfig */; @@ -5912,6 +6146,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 845DD32C2B6AE05900B0E700 /* Build configuration list for PBXNativeTarget "LoopUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 845DD3292B6AE05900B0E700 /* Debug */, + 845DD32A2B6AE05900B0E700 /* Testflight */, + 845DD32B2B6AE05900B0E700 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; E9B07F9A253BBA6500BAD8F8 /* Build configuration list for PBXNativeTarget "Loop Intent Extension" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 41935ed1f2..b17f36c96c 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -299,6 +299,10 @@ final class StatusTableViewController: LoopChartsTableViewController { let bolus = UIBarButtonItem(image: UIImage(named: "bolus"), style: .plain, target: self, action: #selector(presentBolusScreen)) let settings = UIBarButtonItem(image: UIImage(named: "settings"), style: .plain, target: self, action: #selector(onSettingsTapped)) + carbs.accessibilityIdentifier = "statusTableViewControllerCarbsButton" + bolus.accessibilityIdentifier = "statusTableViewControllerBolusButton" + settings.accessibilityIdentifier = "statusTableViewControllerSettingsButton" + let preMeal = createPreMealButtonItem(selected: false, isEnabled: true) let workout = createWorkoutButtonItem(selected: false, isEnabled: true) toolbarItems = [ @@ -1415,6 +1419,7 @@ final class StatusTableViewController: LoopChartsTableViewController { item.tintColor = UIColor.carbTintColor item.isEnabled = isEnabled + item.accessibilityIdentifier = isEnabled ? "statusTableViewPreMealButtonEnabled" : "statusTableViewPreMealButtonDisabled" return item } diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index e8568ba4d7..b5ae1a374b 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -155,6 +155,7 @@ struct AlertManagementView: View { Spacer() Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.critical) + .accessibilityIdentifier("settingsViewAlertManagementAlertPermissionsAlertWarning") } } } diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 1d4d1e2c2a..5a45a83293 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -271,7 +271,6 @@ struct BolusEntryView: View { bolusUnitsLabel } } - .accessibilityElement(children: .combine) } private var bolusUnitsLabel: some View { diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index b9e1552036..117cc4deea 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -97,19 +97,29 @@ extension NotificationsCriticalAlertPermissionsView { .accentColor(.primary) } + private var notificationsStatusIdentifier: String { + !checker.notificationCenterSettings.notificationsDisabled ? "settingsViewAlertManagementAlertPermissionsNotificationsEnabled" : "settingsViewAlertManagementAlertPermissionsNotificationsDisabled" + } + private var notificationsEnabledStatus: some View { HStack { Text("Notifications", comment: "Notifications Status text") Spacer() onOff(!checker.notificationCenterSettings.notificationsDisabled) + .accessibilityIdentifier(notificationsStatusIdentifier) } } + + private var criticalAlertsStatusIdentifier: String { + !checker.notificationCenterSettings.criticalAlertsDisabled ? "settingsViewAlertManagementAlertPermissionsCriticalAlertsEnabled" : "settingsViewAlertManagementAlertPermissionsCriticalAlertsDisabled" + } private var criticalAlertsStatus: some View { HStack { Text("Critical Alerts", comment: "Critical Alerts Status text") Spacer() onOff(!checker.notificationCenterSettings.criticalAlertsDisabled) + .accessibilityIdentifier(criticalAlertsStatusIdentifier) } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index c3ec98b8dd..6967a81119 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -229,6 +229,7 @@ extension SettingsView { } .fixedSize(horizontal: false, vertical: true) } + .accessibilityIdentifier("settingsViewClosedLoopToggle") .disabled(!viewModel.isOnboardingComplete || !viewModel.isClosedLoopAllowed) } } @@ -260,6 +261,7 @@ 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) @@ -283,6 +285,7 @@ extension SettingsView { label: NSLocalizedString("Alert Management", comment: "Alert Permissions button text"), descriptiveText: NSLocalizedString("Alert Permissions and Mute Alerts", comment: "Alert Permissions descriptive text") ) + .accessibilityIdentifier("settingsViewAlertManagement") } } } @@ -316,7 +319,10 @@ extension SettingsView { private var deviceSettingsSection: some View { Section { pumpSection + .accessibilityIdentifier("settingsViewInsulinPump") + cgmSection + .accessibilityIdentifier("settingsViewCGM") } } diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index b0e6b1387b..5ecfaaad3a 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -191,8 +191,10 @@ public final class LoopCompletionHUDView: BaseHUDView { if loopIconClosed { accessibilityHint = LocalizedString("Closed loop", comment: "Accessibility hint describing completion HUD for a closed loop") + accessibilityIdentifier = "loopCompletionHUDLoopStatusClosed" } else { accessibilityHint = LocalizedString("Open loop", comment: "Accessbility hint describing completion HUD for an open loop") + accessibilityIdentifier = "loopCompletionHUDLoopStatusOpen" } } diff --git a/LoopUI/Views/PumpStatusHUDView.swift b/LoopUI/Views/PumpStatusHUDView.swift index fbe6a0bc58..754aa6e7fe 100644 --- a/LoopUI/Views/PumpStatusHUDView.swift +++ b/LoopUI/Views/PumpStatusHUDView.swift @@ -43,6 +43,10 @@ public final class PumpStatusHUDView: DeviceStatusHUDView, NibLoadable { } override public func presentStatusHighlight() { + defer { + accessibilityValue = statusHighlightView.messageLabel.text + } + guard !isStatusHighlightDisplayed else { return } @@ -60,6 +64,18 @@ public final class PumpStatusHUDView: DeviceStatusHUDView, NibLoadable { } override public func dismissStatusHighlight() { + defer { + var parts = [String]() + if let basalRateAccessibilityValue = basalRateHUD.accessibilityValue { + parts.append(basalRateAccessibilityValue) + } + + if let pumpManagerProvidedAccessibilityValue = pumpManagerProvidedHUD.accessibilityValue { + parts.append(pumpManagerProvidedAccessibilityValue) + } + accessibilityValue = parts.joined(separator: ", ") + } + guard statusStackView.arrangedSubviews.contains(statusHighlightView) else { return } 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/LoopUITests/Helpers/Common.swift b/LoopUITests/Helpers/Common.swift new file mode 100644 index 0000000000..724c283dd9 --- /dev/null +++ b/LoopUITests/Helpers/Common.swift @@ -0,0 +1,33 @@ +// +// Common.swift +// LoopUITests +// +// Created by Ginny Yadav on 10/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import XCTest + +@MainActor +class Common { + struct TestSettings { + static let elementTimeout: TimeInterval = 5 + } +} + +func waitForExistence(_ element: XCUIElement) { + XCTAssert(element.waitForExistence(timeout: Common.TestSettings.elementTimeout)) +} + +extension XCUIElement { + func forceTap() { + if self.isHittable { + self.tap() + } + else { + let coordinate: XCUICoordinate = self.coordinate(withNormalizedOffset: CGVector(dx:0.0, dy:0.0)) + coordinate.tap() + } + } +} diff --git a/LoopUITests/LoopUITests.swift b/LoopUITests/LoopUITests.swift new file mode 100644 index 0000000000..e23cbf9996 --- /dev/null +++ b/LoopUITests/LoopUITests.swift @@ -0,0 +1,186 @@ +// +// LoopUITests.swift +// LoopUITests +// +// Created by Cameron Ingham on 2/6/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import XCTest + +@MainActor +final class LoopUITests: XCTestCase { + var app: XCUIApplication! + var baseScreen: BaseScreen! + var onboardingScreen: OnboardingScreen! + var homeScreen: HomeScreen! + var settingsScreen: SettingsScreen! + var systemSettingsScreen: SystemSettingsScreen! + var pumpSimulatorScreen: PumpSimulatorScreen! + var common: Common! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launch() + baseScreen = BaseScreen(app: app) + onboardingScreen = OnboardingScreen(app: app) + homeScreen = HomeScreen(app: app) + settingsScreen = SettingsScreen(app: app) + systemSettingsScreen = SystemSettingsScreen() + pumpSimulatorScreen = PumpSimulatorScreen(app: app) + common = Common() + } + + func testSkippingOnboardingLeadsToHomepageWithSimulators() { + baseScreen.deleteApp() + app.launch() + onboardingScreen.skipAllOfOnboarding() + waitForExistence(homeScreen.hudStatusClosedLoop) + homeScreen.openSettings() + settingsScreen.openPumpManager() + waitForExistence(settingsScreen.pumpSimulatorTitle) + settingsScreen.closePumpSimulator() + settingsScreen.openCGMManager() + waitForExistence(settingsScreen.cgmSimulatorTitle) + settingsScreen.closeCGMSimulator() + settingsScreen.closeSettingsScreen() + waitForExistence(homeScreen.hudStatusClosedLoop) + } + + // https://tidepool.atlassian.net/browse/LOOP-1605 + func testAlertSettingsUI() { + onboardingScreen.skipAllOfOnboardingIfNeeded() + systemSettingsScreen.launchApp() + systemSettingsScreen.openAppSystemSettings() + systemSettingsScreen.openSystemNotificationSettings() + systemSettingsScreen.toggleAllowNotifications() + systemSettingsScreen.toggleCriticalAlerts() + homeScreen.openSettings() + waitForExistence(settingsScreen.alertManagementAlertWarning) + settingsScreen.openAlertManagement() + waitForExistence(settingsScreen.alertPermissionsWarning) + settingsScreen.openAlertPermissions() + waitForExistence(settingsScreen.alertPermissionsNotificationsDisabled) + waitForExistence(settingsScreen.alertPermissionsCriticalAlertsDisabled) + settingsScreen.openPermissionsInSettings() + systemSettingsScreen.app.activate() + systemSettingsScreen.toggleAllowNotifications() + app.activate() + waitForExistence(settingsScreen.alertPermissionsNotificationsEnabled) + systemSettingsScreen.app.activate() + systemSettingsScreen.toggleCriticalAlerts() + app.activate() + waitForExistence(settingsScreen.alertPermissionsCriticalAlertsEnabled) + } + + // https://tidepool.atlassian.net/browse/LOOP-1713 + func testConfigureClosedLoopManagement() { + onboardingScreen.skipAllOfOnboardingIfNeeded() + waitForExistence(homeScreen.hudStatusClosedLoop) + waitForExistence(homeScreen.preMealTabEnabled) + homeScreen.tapPreMealButton() + homeScreen.dismissPreMealConfirmationDialog() + homeScreen.openSettings() + settingsScreen.toggleClosedLoop() + settingsScreen.closeSettingsScreen() + waitForExistence(homeScreen.hudStatusOpenLoop) + waitForExistence(homeScreen.preMealTabDisabled) + homeScreen.tapLoopStatusOpen() + waitForExistence(homeScreen.closedLoopOffAlertTitle) + homeScreen.closeLoopStatusAlert() + homeScreen.tapBolusEntry() + waitForExistence(homeScreen.simpleBolusCalculatorTitle) + homeScreen.closeSimpleBolusEntry() + homeScreen.tapCarbEntry() + waitForExistence(homeScreen.simpleMealCalculatorTitle) + homeScreen.closeSimpleCarbEntry() + homeScreen.openSettings() + settingsScreen.toggleClosedLoop() + settingsScreen.closeSettingsScreen() + waitForExistence(homeScreen.hudStatusClosedLoop) + waitForExistence(homeScreen.preMealTabEnabled) + homeScreen.tapLoopStatusClosed() + waitForExistence(homeScreen.closedLoopOnAlertTitle) + homeScreen.closeLoopStatusAlert() + homeScreen.tapBolusEntry() + waitForExistence(homeScreen.bolusTitle) + homeScreen.closeBolusEntry() + homeScreen.tapCarbEntry() + waitForExistence(homeScreen.carbEntryTitle) + homeScreen.closeMealEntry() + } + + // https://tidepool.atlassian.net/browse/LOOP-1636 + func testPumpErrorAndStateHandlingStatusBarDisplay() { + onboardingScreen.skipAllOfOnboardingIfNeeded() + waitForExistence(homeScreen.hudStatusClosedLoop) + homeScreen.tapPumpPill() + pumpSimulatorScreen.tapSuspendInsulinButton() + waitForExistence(pumpSimulatorScreen.resumeInsulinButton) + pumpSimulatorScreen.closePumpSimulator() + XCTAssertEqual(homeScreen.hudPumpPill.value as? String, NSLocalizedString("Insulin Suspended", comment: "")) + homeScreen.tapPumpPill() + pumpSimulatorScreen.tapResumeInsulinButton() + waitForExistence(pumpSimulatorScreen.suspendInsulinButton) + pumpSimulatorScreen.openPumpSettings() + pumpSimulatorScreen.tapReservoirRemainingRow() + pumpSimulatorScreen.tapReservoirRemainingTextField() + pumpSimulatorScreen.clearReservoirRemainingTextField() + app.typeText("0") + pumpSimulatorScreen.closeReservoirRemainingScreen() + pumpSimulatorScreen.closePumpSettings() + pumpSimulatorScreen.closePumpSimulator() + XCTAssertEqual(homeScreen.hudPumpPill.value as? String, NSLocalizedString("No Insulin", comment: "")) + homeScreen.tapPumpPill() + pumpSimulatorScreen.openPumpSettings() + pumpSimulatorScreen.tapReservoirRemainingRow() + pumpSimulatorScreen.tapReservoirRemainingTextField() + pumpSimulatorScreen.clearReservoirRemainingTextField() + app.typeText("15") + pumpSimulatorScreen.closeReservoirRemainingScreen() + pumpSimulatorScreen.closePumpSettings() + pumpSimulatorScreen.closePumpSimulator() + XCTAssert((homeScreen.hudPumpPill.value as? String)?.contains("15 units remaining") == true) + homeScreen.tapPumpPill() + pumpSimulatorScreen.openPumpSettings() + pumpSimulatorScreen.tapReservoirRemainingRow() + pumpSimulatorScreen.tapReservoirRemainingTextField() + pumpSimulatorScreen.clearReservoirRemainingTextField() + app.typeText("45") + pumpSimulatorScreen.closeReservoirRemainingScreen() + pumpSimulatorScreen.closePumpSettings() + pumpSimulatorScreen.closePumpSimulator() + XCTAssert((homeScreen.hudPumpPill.value as? String)?.contains("45 units remaining") == true) + homeScreen.tapPumpPill() + pumpSimulatorScreen.openPumpSettings() + pumpSimulatorScreen.tapDetectOcclusionButton() + pumpSimulatorScreen.closePumpSettings() + pumpSimulatorScreen.closePumpSimulator() + XCTAssertEqual(homeScreen.hudPumpPill.value as? String, NSLocalizedString("Pump Occlusion", comment: "")) + homeScreen.tapBolusEntry() + homeScreen.tapBolusEntryTextField() + app.typeText("2") + homeScreen.closeKeyboard() + homeScreen.tapDeliverBolusButton() + homeScreen.enterPasscode() + homeScreen.verifyOcclusionAlert() + homeScreen.tapPumpPill() + pumpSimulatorScreen.openPumpSettings() + pumpSimulatorScreen.tapResolveOcclusionButton() + pumpSimulatorScreen.tapCausePumpErrorButton() + pumpSimulatorScreen.closePumpSettings() + pumpSimulatorScreen.closePumpSimulator() + XCTAssertEqual(homeScreen.hudPumpPill.value as? String, NSLocalizedString("Pump Error", comment: "")) + homeScreen.tapPumpPill() + pumpSimulatorScreen.openPumpSettings() + pumpSimulatorScreen.tapResolvePumpErrorButton() + pumpSimulatorScreen.tapReservoirRemainingRow() + pumpSimulatorScreen.tapReservoirRemainingTextField() + pumpSimulatorScreen.clearReservoirRemainingTextField() + app.typeText("165") + pumpSimulatorScreen.closeReservoirRemainingScreen() + pumpSimulatorScreen.closePumpSettings() + pumpSimulatorScreen.closePumpSimulator() + } +} diff --git a/LoopUITests/Screens/BaseScreen.swift b/LoopUITests/Screens/BaseScreen.swift new file mode 100644 index 0000000000..da4cd64e0c --- /dev/null +++ b/LoopUITests/Screens/BaseScreen.swift @@ -0,0 +1,47 @@ +// +// BaseScreen.swift +// LoopUITests +// +// Created by Ginny Yadav on 10/27/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest + +class BaseScreen { + var app: XCUIApplication + var springboardApp: XCUIApplication + var bundleIdentifier: String? + + init(app: XCUIApplication) { + self.app = app + self.springboardApp = XCUIApplication(bundleIdentifier:"com.apple.springboard") + self.bundleIdentifier = Bundle.main.bundleIdentifier + } + + func deleteApp() { + XCUIApplication().terminate() + + let icon = springboardApp.icons["Tidepool Loop"] + if icon.exists { + let iconFrame = icon.frame + let springboardFrame = springboardApp.frame + icon.press(forDuration: 5) + + // Tap the little "X" button at approximately where it is. The X is not exposed directly + springboardApp.coordinate(withNormalizedOffset: CGVector(dx: (iconFrame.minX + 3) / springboardFrame.maxX, dy: (iconFrame.minY + 3) / springboardFrame.maxY)).tap() + + springboardApp.alerts.buttons["Delete App"].tap() + + waitForExistence(springboardApp.alerts.buttons["Delete"]) + springboardApp.alerts.buttons["Delete"].tap() + + waitForExistence(springboardApp.alerts.buttons["OK"]) + springboardApp.alerts.buttons["OK"].tap() + } + } +} + + + + diff --git a/LoopUITests/Screens/HomeScreen.swift b/LoopUITests/Screens/HomeScreen.swift new file mode 100644 index 0000000000..0f3ec59685 --- /dev/null +++ b/LoopUITests/Screens/HomeScreen.swift @@ -0,0 +1,238 @@ +// +// OnboardingScreen.swift +// LoopUITests +// +// Created by Ginny Yadav on 10/27/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. + +// This is a page file. +// It's intention is to map out all the locators for a particular section of the app. +// If the locator uses a label please use the localization key +// If the locator uses an accesibility ID you don't need the localization key + +import XCTest + +class HomeScreen: BaseScreen { + + // MARK: Elements + + var hudStatusClosedLoop: XCUIElement { + app.descendants(matching: .any).matching(identifier: "loopCompletionHUDLoopStatusClosed").firstMatch + } + + var hudPumpPill: XCUIElement { + app.descendants(matching: .any).matching(identifier: "pumpHUDView").firstMatch + } + + var closedLoopOnAlertTitle: XCUIElement { + app.staticTexts["Closed Loop ON"] + } + + var hudStatusOpenLoop: XCUIElement { + app.descendants(matching: .any).matching(identifier: "loopCompletionHUDLoopStatusOpen").firstMatch + } + + var closedLoopOffAlertTitle: XCUIElement { + app.staticTexts["Closed Loop OFF"] + } + + var preMealTabEnabled: XCUIElement { + app.descendants(matching: .any).matching(identifier: "statusTableViewPreMealButtonEnabled").firstMatch + } + + var preMealTabDisabled: XCUIElement { + app.descendants(matching: .any).matching(identifier: "statusTableViewPreMealButtonDisabled").firstMatch + } + + var settingsTab: XCUIElement { + app.descendants(matching: .any).matching(identifier: "statusTableViewControllerSettingsButton").firstMatch + } + + var carbsTab: XCUIElement { + app.descendants(matching: .any).matching(identifier: "statusTableViewControllerCarbsButton").firstMatch + } + + var carbEntryTitle: XCUIElement { + app.navigationBars.staticTexts["Add Carb Entry"] + } + + var carbEntryCancelButton: XCUIElement { + app.navigationBars["Add Carb Entry"].buttons["Cancel"] + } + + var simpleMealCalculatorTitle: XCUIElement { + app.navigationBars.staticTexts["Simple Meal Calculator"] + } + + var simpleMealCalculatorCancelButton: XCUIElement { + app.navigationBars["Simple Meal Calculator"].buttons["Cancel"] + } + + var bolusTab: XCUIElement { + app.descendants(matching: .any).matching(identifier: "statusTableViewControllerBolusButton").firstMatch + } + + var bolusTitle: XCUIElement { + app.navigationBars.staticTexts["Bolus"] + } + + var bolusEntryViewBolusEntryRow: XCUIElement { + app.descendants(matching: .any).matching(identifier: "dismissibleKeyboardTextField").firstMatch + } + + var bolusCancelButton: XCUIElement { + app.navigationBars["Bolus"].buttons["Cancel"] + } + + var simpleBolusCalculatorTitle: XCUIElement { + app.navigationBars.staticTexts["Simple Bolus Calculator"] + } + + var simpleBolusCalculatorCancelButton: XCUIElement { + app.navigationBars["Simple Bolus Calculator"].buttons["Cancel"] + } + + var safetyNotificationsAlertTitle: XCUIElement { + app.alerts["\n\nWarning! Safety notifications are turned OFF"] + } + + var safetyNotificationsAlertCloseButton: XCUIElement { + app.alerts.firstMatch.buttons["Close"] + } + + var alertDismissButton: XCUIElement { + app.buttons["Dismiss"] + } + + var confirmationDialogCancelButton: XCUIElement { + app.buttons["Cancel"] + } + + var keyboardDoneButton: XCUIElement { + app.toolbars.firstMatch.buttons["Done"].firstMatch + } + + var deliverBolusButton: XCUIElement { + app.buttons["Deliver"] + } + + var notification: XCUIElement { + springboardApp.descendants(matching: .any).matching(identifier: "NotificationShortLookView").firstMatch + } + + var bolusIssueNotificationTitle: XCUIElement { + app.alerts["Bolus Issue"] + } + + var passcodeEntry: XCUIElement { + springboardApp.secureTextFields["Passcode field"] + } + + var springboardKeyboardDoneButton: XCUIElement { + springboardApp.keyboards.buttons["done"] + } + + // MARK: Actions + + func openSettings() { + waitForExistence(settingsTab) + settingsTab.tap() + } + + func tapSafetyNotificationAlertCloseButton() { + waitForExistence(safetyNotificationsAlertCloseButton) + safetyNotificationsAlertCloseButton.tap() + } + + func tapLoopStatusOpen() { + waitForExistence(hudStatusOpenLoop) + hudStatusOpenLoop.tap() + } + + func tapLoopStatusClosed() { + waitForExistence(hudStatusClosedLoop) + hudStatusClosedLoop.tap() + } + + func closeLoopStatusAlert() { + waitForExistence(alertDismissButton) + alertDismissButton.tap() + } + + func tapPreMealButton() { + waitForExistence(preMealTabEnabled) + preMealTabEnabled.tap() + } + + func dismissPreMealConfirmationDialog() { + waitForExistence(confirmationDialogCancelButton) + confirmationDialogCancelButton.tap() + } + + func tapCarbEntry() { + waitForExistence(carbsTab) + carbsTab.tap() + } + + func closeMealEntry() { + waitForExistence(carbEntryCancelButton) + carbEntryCancelButton.tap() + } + + func closeSimpleCarbEntry() { + waitForExistence(simpleMealCalculatorCancelButton) + simpleMealCalculatorCancelButton.tap() + } + + func tapBolusEntry() { + waitForExistence(bolusTab) + bolusTab.tap() + } + + func closeBolusEntry() { + waitForExistence(bolusCancelButton) + bolusCancelButton.tap() + } + + func closeSimpleBolusEntry() { + waitForExistence(simpleBolusCalculatorCancelButton) + simpleBolusCalculatorCancelButton.tap() + } + + func tapPumpPill() { + waitForExistence(hudPumpPill) + hudPumpPill.tap() + } + + func tapBolusEntryTextField() { + waitForExistence(bolusEntryViewBolusEntryRow) + bolusEntryViewBolusEntryRow.tap() + } + + func closeKeyboard() { + waitForExistence(keyboardDoneButton) + keyboardDoneButton.tap() + } + + func tapDeliverBolusButton() { + waitForExistence(deliverBolusButton) + deliverBolusButton.forceTap() + } + + func verifyOcclusionAlert() { +// waitForExistence(notification) +// notification.tap() +// waitForExistence(bolusIssueNotificationTitle) +// app.activate() + #warning("FIXME") + } + + func enterPasscode() { + waitForExistence(passcodeEntry) + passcodeEntry.tap() + springboardApp.typeText("1\n") +// sleep(1) +// waitForExistence(springboardKeyboardDoneButton) +// springboardKeyboardDoneButton.tap() + } +} diff --git a/LoopUITests/Screens/OnboardingScreen.swift b/LoopUITests/Screens/OnboardingScreen.swift new file mode 100644 index 0000000000..c9072e53f3 --- /dev/null +++ b/LoopUITests/Screens/OnboardingScreen.swift @@ -0,0 +1,83 @@ +// +// OnboardingScreen.swift +// LoopUITests +// +// Created by Ginny Yadav on 10/27/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. + +// This is a page file. +// It's intention is to map out all the locators for a particular section of the app. +// If the locator uses a label please use the localization key +// If the locator uses an accesibility ID you don't need the localization key + +import XCTest + +class OnboardingScreen: BaseScreen { + + // MARK: Elements + + var welcomeTitleText: XCUIElement { + app.staticTexts.element(matching: .staticText, identifier: "welcome data 0") + } + + var simulatorAlert: XCUIElement { + app.alerts["Are you sure you want to skip the rest of onboarding (and use simulators)?"] + } + + var useSimulatorConfirmationButton: XCUIElement { + app.buttons["Yes"] + } + + var alertAllowButton:XCUIElement { + springboardApp.buttons["Allow"] + } + + var turnOnAllHealthCategoriesText: XCUIElement { + app.tables.staticTexts["Turn On All"] + } + + var healthDoneButton: XCUIElement { + app.navigationBars["Health Access"].buttons["Allow"] + } + + // MARK: Actions + + func skipAllOfOnboardingIfNeeded() { + if welcomeTitleText.exists { + skipAllOfOnboarding() + } + } + + func skipAllOfOnboarding() { + skipOnboarding() + allowSimulatorAlert() + allowNotificationsAuthorization() + allowCriticalAlertsAuthorization() + allowHealthKitAuthorization() + } + + private func skipOnboarding() { + welcomeTitleText.press(forDuration: 2.5) + } + + private func allowSimulatorAlert() { + waitForExistence(simulatorAlert) + useSimulatorConfirmationButton.tap() + } + + private func allowNotificationsAuthorization() { + waitForExistence(alertAllowButton) + alertAllowButton.tap() + } + + private func allowCriticalAlertsAuthorization() { + waitForExistence(alertAllowButton) + alertAllowButton.tap() + } + + private func allowHealthKitAuthorization() { + waitForExistence(turnOnAllHealthCategoriesText) + turnOnAllHealthCategoriesText.tap() + healthDoneButton.tap() + } +} diff --git a/LoopUITests/Screens/PumpSimulatorScreen.swift b/LoopUITests/Screens/PumpSimulatorScreen.swift new file mode 100644 index 0000000000..de4d237524 --- /dev/null +++ b/LoopUITests/Screens/PumpSimulatorScreen.swift @@ -0,0 +1,133 @@ +// +// PumpSimulatorScreen.swift +// LoopUITests +// +// Created by Cameron Ingham on 2/6/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import XCTest + +final class PumpSimulatorScreen: BaseScreen { + + // MARK: Elements + + var suspendInsulinButton: XCUIElement { + app.descendants(matching: .any).buttons["Suspend Insulin Delivery"] + } + + var resumeInsulinButton: XCUIElement { + app.descendants(matching: .any).buttons["Tap to Resume Insulin Delivery"] + } + + var doneButton: XCUIElement { + app.navigationBars["Pump Simulator"].buttons["Done"] + } + + var pumpProgressView: XCUIElement { + app.descendants(matching: .any).matching(identifier: "mockPumpManagerProgressView").firstMatch + } + + var reservoirRemainingButton: XCUIElement { + app.descendants(matching: .any).matching(identifier: "mockPumpSettingsReservoirRemaining").firstMatch + } + + var reservoirRemainingTextField: XCUIElement { + app.descendants(matching: .any).textFields.firstMatch + } + + var pumpSettingsBackButton: XCUIElement { + app.navigationBars.firstMatch.buttons["Back"] + } + + var reservoirRemainingBackButton: XCUIElement { + app.navigationBars.firstMatch.buttons["Back"] + } + + var detectOcclusionButton: XCUIElement { + app.staticTexts["Detect Occlusion"] + } + + var resolveOcclusionButton: XCUIElement { + app.staticTexts["Resolve Occlusion"] + } + + var causePumpErrorButton: XCUIElement { + app.staticTexts["Cause Pump Error"] + } + + var resolvePumpErrorButton: XCUIElement { + app.staticTexts["Resolve Pump Error"] + } + + // MARK: Actions + + func tapSuspendInsulinButton() { + waitForExistence(suspendInsulinButton) + suspendInsulinButton.tap() + } + + func tapResumeInsulinButton() { + waitForExistence(resumeInsulinButton) + resumeInsulinButton.tap() + } + + func closePumpSimulator() { + waitForExistence(doneButton) + doneButton.tap() + } + + func openPumpSettings() { + waitForExistence(pumpProgressView) + pumpProgressView.press(forDuration: 10) + } + + func closePumpSettings() { + waitForExistence(pumpSettingsBackButton) + pumpSettingsBackButton.tap() + } + + func tapReservoirRemainingRow() { + waitForExistence(reservoirRemainingButton) + reservoirRemainingButton.tap() + } + + func tapReservoirRemainingTextField() { + waitForExistence(reservoirRemainingTextField) + reservoirRemainingTextField.tap() + } + + func clearReservoirRemainingTextField() { + guard let value = reservoirRemainingTextField.value as? String else { + XCTFail() + return + } + + app.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: value.count)) + } + + func closeReservoirRemainingScreen() { + waitForExistence(reservoirRemainingBackButton) + reservoirRemainingBackButton.tap() + } + + func tapDetectOcclusionButton() { + waitForExistence(detectOcclusionButton) + detectOcclusionButton.tap() + } + + func tapResolveOcclusionButton() { + waitForExistence(resolveOcclusionButton) + resolveOcclusionButton.tap() + } + + func tapCausePumpErrorButton() { + waitForExistence(causePumpErrorButton) + causePumpErrorButton.tap() + } + + func tapResolvePumpErrorButton() { + waitForExistence(resolvePumpErrorButton) + resolvePumpErrorButton.tap() + } +} diff --git a/LoopUITests/Screens/SettingsScreen.swift b/LoopUITests/Screens/SettingsScreen.swift new file mode 100644 index 0000000000..6c17ad273b --- /dev/null +++ b/LoopUITests/Screens/SettingsScreen.swift @@ -0,0 +1,125 @@ +// +// SettingsScreen.swift +// LoopUITests +// +// Created by Cameron Ingham on 2/2/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import XCTest + +final class SettingsScreen: BaseScreen { + + // MARK: Elements + + var insulinPump: XCUIElement { + app.descendants(matching: .any).matching(identifier: "settingsViewInsulinPump").firstMatch + } + + var pumpSimulatorTitle: XCUIElement { + app.navigationBars.staticTexts["Pump Simulator"] + } + + var pumpSimulatorDoneButton: XCUIElement { + app.navigationBars["Pump Simulator"].buttons["Done"] + } + + var cgm: XCUIElement { + app.descendants(matching: .any).matching(identifier: "settingsViewCGM").firstMatch + } + + var cgmSimulatorTitle: XCUIElement { + app.navigationBars.staticTexts["CGM Simulator"] + } + + var cgmSimulatorDoneButton: XCUIElement { + app.navigationBars["CGM Simulator"].buttons["Done"] + } + + var settingsDoneButton: XCUIElement { + app.navigationBars["Settings"].buttons["Done"] + } + + var alertManagementAlertWarning: XCUIElement { + app.descendants(matching: .any).matching(identifier: "settingsViewAlertManagementAlertWarning").firstMatch + } + + var alertManagement: XCUIElement { + app.descendants(matching: .any).matching(identifier: "settingsViewAlertManagement").firstMatch + } + + var alertPermissionsWarning: XCUIElement { + app.descendants(matching: .any).matching(identifier: "settingsViewAlertManagementAlertPermissionsAlertWarning").firstMatch + } + + var managePermissionsInSettings: XCUIElement { + app.descendants(matching: .any).buttons["Manage Permissions in Settings"] + } + + var alertPermissionsNotificationsEnabled: XCUIElement { + app.staticTexts["settingsViewAlertManagementAlertPermissionsNotificationsEnabled"] + } + + var alertPermissionsNotificationsDisabled: XCUIElement { + app.staticTexts["settingsViewAlertManagementAlertPermissionsNotificationsDisabled"] + } + + var alertPermissionsCriticalAlertsEnabled: XCUIElement { + app.staticTexts["settingsViewAlertManagementAlertPermissionsCriticalAlertsEnabled"] + } + + var alertPermissionsCriticalAlertsDisabled: XCUIElement { + app.staticTexts["settingsViewAlertManagementAlertPermissionsCriticalAlertsDisabled"] + } + + var closedLoopToggle: XCUIElement { + app.descendants(matching: .any).matching(identifier: "settingsViewClosedLoopToggle").switches.firstMatch + } + + // MARK: Actions + + func openPumpManager() { + waitForExistence(insulinPump) + insulinPump.tap() + } + + func closePumpSimulator() { + waitForExistence(pumpSimulatorDoneButton) + pumpSimulatorDoneButton.tap() + } + + func openCGMManager() { + waitForExistence(cgm) + cgm.tap() + } + + func closeCGMSimulator() { + waitForExistence(cgmSimulatorDoneButton) + cgmSimulatorDoneButton.tap() + } + + func closeSettingsScreen() { + waitForExistence(settingsDoneButton) + settingsDoneButton.tap() + } + + func openAlertManagement() { + waitForExistence(alertManagement) + alertManagement.tap() + } + + func openAlertPermissions() { + waitForExistence(alertPermissionsWarning) + alertPermissionsWarning.tap() + } + + func openPermissionsInSettings() { + waitForExistence(managePermissionsInSettings) + managePermissionsInSettings.tap() + } + + func toggleClosedLoop() { + waitForExistence(closedLoopToggle) + closedLoopToggle.tap() + } +} diff --git a/LoopUITests/Screens/SystemSettingsScreen.swift b/LoopUITests/Screens/SystemSettingsScreen.swift new file mode 100644 index 0000000000..1b998710d8 --- /dev/null +++ b/LoopUITests/Screens/SystemSettingsScreen.swift @@ -0,0 +1,62 @@ +// +// SystemSettingsScreen.swift +// LoopUITests +// +// Created by Cameron Ingham on 2/2/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import XCTest + +final class SystemSettingsScreen: BaseScreen { + + // MARK: Elements + + var loopCell: XCUIElement { + app.cells["Tidepool Loop"] + } + + var notificationsButton: XCUIElement { + app.descendants(matching: .any).element(matching: .button, identifier: "NOTIFICATIONS") + } + + var allowNotificationsToggle: XCUIElement { + app.switches["Allow Notifications"] + } + + var criticalAlertsToggle: XCUIElement { + app.switches["Critical Alerts"] + } + + // MARK: Initializers + + init() { + super.init(app: XCUIApplication(bundleIdentifier: "com.apple.Preferences")) + } + + // MARK: Actions + + func launchApp() { + app.launch() + } + + func openAppSystemSettings() { + waitForExistence(loopCell) + loopCell.tap() + } + + func openSystemNotificationSettings() { + waitForExistence(notificationsButton) + notificationsButton.tap() + } + + func toggleAllowNotifications() { + waitForExistence(allowNotificationsToggle) + allowNotificationsToggle.tap() + } + + func toggleCriticalAlerts() { + waitForExistence(criticalAlertsToggle) + criticalAlertsToggle.tap() + } +} From b08b57f293a24095df2391156b5417f91e454318 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 8 Feb 2024 14:33:37 -0800 Subject: [PATCH 019/184] [LOOP-4793] Beginning XCUI Tests --- .../xcschemes/LoopUITests.xcscheme | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 Loop.xcodeproj/xcshareddata/xcschemes/LoopUITests.xcscheme diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/LoopUITests.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/LoopUITests.xcscheme new file mode 100644 index 0000000000..10fb231af9 --- /dev/null +++ b/Loop.xcodeproj/xcshareddata/xcschemes/LoopUITests.xcscheme @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + From 362b68ee54e48125ede07951293717ae29562723 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 14 Feb 2024 13:33:25 -0800 Subject: [PATCH 020/184] [LOOP-4793] Beginning XCUI Tests --- DIYLoopUITests/DIYLoopUITestPlan.xctestplan | 36 ++ DIYLoopUITests/DIYLoopUITests.swift | 41 +++ DIYLoopUITests/Screens/OnboardingScreen.swift | 80 ++++ Loop.xcodeproj/project.pbxproj | 341 ++++++++++++++---- .../xcschemes/LoopUITests.xcscheme | 54 --- LoopUITests/DIYLoopUnitTestPlan.xctestplan | 113 ++++++ LoopUITests/Helpers/Common.swift | 33 -- LoopUITests/LoopUITestPlan.xctestplan | 29 ++ LoopUITests/LoopUITests.swift | 38 +- LoopUITests/Screens/BaseScreen.swift | 4 - LoopUITests/Screens/HomeScreen.swift | 3 - 11 files changed, 577 insertions(+), 195 deletions(-) create mode 100644 DIYLoopUITests/DIYLoopUITestPlan.xctestplan create mode 100644 DIYLoopUITests/DIYLoopUITests.swift create mode 100644 DIYLoopUITests/Screens/OnboardingScreen.swift delete mode 100644 Loop.xcodeproj/xcshareddata/xcschemes/LoopUITests.xcscheme create mode 100644 LoopUITests/DIYLoopUnitTestPlan.xctestplan delete mode 100644 LoopUITests/Helpers/Common.swift create mode 100644 LoopUITests/LoopUITestPlan.xctestplan diff --git a/DIYLoopUITests/DIYLoopUITestPlan.xctestplan b/DIYLoopUITests/DIYLoopUITestPlan.xctestplan new file mode 100644 index 0000000000..e8b3aff881 --- /dev/null +++ b/DIYLoopUITests/DIYLoopUITestPlan.xctestplan @@ -0,0 +1,36 @@ +{ + "configurations" : [ + { + "id" : "7D98F861-1A40-4E2D-B298-96208D0BC6BC", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:..\/Loop\/Loop.xcodeproj", + "identifier" : "43776F8B1B8022E90074EA36", + "name" : "Loop" + }, + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:..\/Loop\/Loop.xcodeproj", + "identifier" : "847434F22B7C41D30084BE98", + "name" : "DIYLoopUITests" + } + }, + { + "target" : { + "containerPath" : "container:..\/Loop\/Loop.xcodeproj", + "identifier" : "840B7A7D2B7BFF58000ED932", + "name" : "LoopUITests" + } + } + ], + "version" : 1 +} diff --git a/DIYLoopUITests/DIYLoopUITests.swift b/DIYLoopUITests/DIYLoopUITests.swift new file mode 100644 index 0000000000..8a278c1df9 --- /dev/null +++ b/DIYLoopUITests/DIYLoopUITests.swift @@ -0,0 +1,41 @@ +// +// DIYLoopUITests.swift +// DIYLoopUITests +// +// Created by Cameron Ingham on 2/13/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopUITestingKit +import XCTest + +@MainActor +final class DIYLoopUITests: XCTestCase { + var app: XCUIApplication! + var baseScreen: BaseScreen! + var homeScreen: HomeScreen! + var settingsScreen: SettingsScreen! + var systemSettingsScreen: SystemSettingsScreen! + var pumpSimulatorScreen: PumpSimulatorScreen! + var onboardingScreen: OnboardingScreen! + var common: Common! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication(bundleIdentifier: "org.tidepool.diy.Loop") + app.launch() + baseScreen = BaseScreen(app: app) + homeScreen = HomeScreen(app: app) + settingsScreen = SettingsScreen(app: app) + systemSettingsScreen = SystemSettingsScreen() + pumpSimulatorScreen = PumpSimulatorScreen(app: app) + onboardingScreen = OnboardingScreen(app: app) + common = Common(appName: "DIY Loop") + } + + func testSkippingOnboarding() async throws { + baseScreen.deleteApp(appName: "DIY Loop") + app.launch() + onboardingScreen.skipAllOfOnboarding() + } +} diff --git a/DIYLoopUITests/Screens/OnboardingScreen.swift b/DIYLoopUITests/Screens/OnboardingScreen.swift new file mode 100644 index 0000000000..25fbed7418 --- /dev/null +++ b/DIYLoopUITests/Screens/OnboardingScreen.swift @@ -0,0 +1,80 @@ +// +// OnboardingScreen.swift +// DIYLoopUITests +// +// Created by Cameron Ingham on 2/13/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopUITestingKit +import XCTest + +extension OnboardingScreen { + + // MARK: Elements + + var loopLogo: XCUIElement { + app.images.matching(identifier: "loopLogo").firstMatch + } + + var simulatorAlert: XCUIElement { + app.alerts["Are you sure you want to skip the rest of onboarding (and use simulators)?"] + } + + var useSimulatorConfirmationButton: XCUIElement { + app.buttons["Yes"] + } + + var alertAllowButton:XCUIElement { + springboardApp.buttons["Allow"] + } + + var turnOnAllHealthCategoriesText: XCUIElement { + app.tables.staticTexts["Turn On All"] + } + + var healthDoneButton: XCUIElement { + app.navigationBars["Health Access"].buttons["Allow"] + } + + // MARK: Actions + + func skipAllOfOnboardingIfNeeded() { + if loopLogo.exists { + skipAllOfOnboarding() + } + } + + func skipAllOfOnboarding() { + allowSiri() + skipOnboarding() + allowNotificationsAuthorization() + allowHealthKitAuthorization() + } + + private func allowSiri() { + waitForExistence(alertAllowButton) + alertAllowButton.tap() + } + + private func skipOnboarding() { + waitForExistence(loopLogo) + loopLogo.press(forDuration: 2) + } + + private func allowSimulatorAlert() { + waitForExistence(simulatorAlert) + useSimulatorConfirmationButton.tap() + } + + private func allowNotificationsAuthorization() { + waitForExistence(alertAllowButton) + alertAllowButton.tap() + } + + private func allowHealthKitAuthorization() { + waitForExistence(turnOnAllHealthCategoriesText) + turnOnAllHealthCategoriesText.tap() + healthDoneButton.tap() + } +} diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 249616f182..fbb576dd32 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -242,12 +242,11 @@ 7D70765E1FE06EE3004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076601FE06EE3004AC8EA /* Localizable.strings */; }; 7D7076631FE06EE4004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076651FE06EE4004AC8EA /* Localizable.strings */; }; 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */; }; - 8402E9022B72B9D200E3EB1F /* LoopUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8402E9012B72B9D200E3EB1F /* LoopUITests.swift */; }; - 8402E9042B72F19400E3EB1F /* PumpSimulatorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8402E9032B72F19400E3EB1F /* PumpSimulatorScreen.swift */; }; - 845DD3362B6AE07300B0E700 /* OnboardingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845DD32E2B6AE07300B0E700 /* OnboardingScreen.swift */; }; - 845DD3372B6AE07300B0E700 /* BaseScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845DD32F2B6AE07300B0E700 /* BaseScreen.swift */; }; - 845DD3382B6AE07300B0E700 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845DD3302B6AE07300B0E700 /* HomeScreen.swift */; }; - 845DD33A2B6AE07300B0E700 /* Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845DD3332B6AE07300B0E700 /* Common.swift */; }; + 845C74352B7D686000F71F90 /* LoopUITestingKit in Frameworks */ = {isa = PBXBuildFile; productRef = 845C74342B7D686000F71F90 /* LoopUITestingKit */; }; + 845C74372B7D686700F71F90 /* LoopUITestingKit in Frameworks */ = {isa = PBXBuildFile; productRef = 845C74362B7D686700F71F90 /* LoopUITestingKit */; }; + 847434C82B7C17800084BE98 /* LoopUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847434C72B7C17800084BE98 /* LoopUITests.swift */; }; + 847434F62B7C41D30084BE98 /* DIYLoopUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847434F52B7C41D30084BE98 /* DIYLoopUITests.swift */; }; + 847435052B7C4F8D0084BE98 /* OnboardingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847435042B7C4F8D0084BE98 /* OnboardingScreen.swift */; }; 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */; }; @@ -257,8 +256,6 @@ 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 */; }; - 84FF23402B6DD10200C08A87 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FF233F2B6DD10200C08A87 /* SettingsScreen.swift */; }; - 84FF23422B6DDAE400C08A87 /* SystemSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FF23412B6DDAE400C08A87 /* SystemSettingsScreen.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 */; }; @@ -617,7 +614,14 @@ remoteGlobalIDString = 4F75288A1DFE1DC600C322D6; remoteInfo = LoopUI; }; - 845DD3272B6AE05900B0E700 /* PBXContainerItemProxy */ = { + 840B7A842B7BFF58000ED932 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 43776F841B8022E90074EA36 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 43776F8B1B8022E90074EA36; + remoteInfo = Loop; + }; + 847434F92B7C41D30084BE98 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; proxyType = 1; @@ -1165,13 +1169,13 @@ 7DD382791F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Interface.strings; sourceTree = ""; }; 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetLoopManager.swift; sourceTree = ""; }; 80F864E52433BF5D0026EC26 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; - 8402E9012B72B9D200E3EB1F /* LoopUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopUITests.swift; sourceTree = ""; }; - 8402E9032B72F19400E3EB1F /* PumpSimulatorScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpSimulatorScreen.swift; sourceTree = ""; }; - 845DD3212B6AE05900B0E700 /* LoopUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoopUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 845DD32E2B6AE07300B0E700 /* OnboardingScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingScreen.swift; sourceTree = ""; }; - 845DD32F2B6AE07300B0E700 /* BaseScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseScreen.swift; sourceTree = ""; }; - 845DD3302B6AE07300B0E700 /* HomeScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; - 845DD3332B6AE07300B0E700 /* Common.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Common.swift; sourceTree = ""; }; + 840B7A7E2B7BFF58000ED932 /* LoopUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoopUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 847434C72B7C17800084BE98 /* LoopUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopUITests.swift; sourceTree = ""; }; + 847434DC2B7C34F70084BE98 /* LoopUITestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = LoopUITestPlan.xctestplan; sourceTree = ""; }; + 847434F32B7C41D30084BE98 /* DIYLoopUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DIYLoopUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 847434F52B7C41D30084BE98 /* DIYLoopUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DIYLoopUITests.swift; sourceTree = ""; }; + 847435022B7C42300084BE98 /* DIYLoopUITestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUITestPlan.xctestplan; sourceTree = ""; }; + 847435042B7C4F8D0084BE98 /* OnboardingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreen.swift; sourceTree = ""; }; 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; @@ -1181,8 +1185,6 @@ 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 = ""; }; - 84FF233F2B6DD10200C08A87 /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; - 84FF23412B6DDAE400C08A87 /* SystemSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSettingsScreen.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; }; @@ -1795,10 +1797,19 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 845DD31E2B6AE05900B0E700 /* Frameworks */ = { + 840B7A7B2B7BFF58000ED932 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 845C74352B7D686000F71F90 /* LoopUITestingKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 847434F02B7C41D30084BE98 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 845C74372B7D686700F71F90 /* LoopUITestingKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1968,7 +1979,8 @@ E9B07F7D253BBA6500BAD8F8 /* Loop Intent Extension */, 14B1736128AED9EC006CCD7C /* Loop Widget Extension */, A900531928D60852000BC15B /* Shortcuts */, - 845DD3222B6AE05900B0E700 /* LoopUITests */, + 840B7A7F2B7BFF58000ED932 /* LoopUITests */, + 847434F42B7C41D30084BE98 /* DIYLoopUITests */, 968DCD53F724DE56FFE51920 /* Frameworks */, 43776F8D1B8022E90074EA36 /* Products */, 437D9BA11D7B5203007245E8 /* Loop.xcconfig */, @@ -1989,7 +2001,8 @@ 43D9002A21EB209400AF44BF /* LoopCore.framework */, E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */, 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */, - 845DD3212B6AE05900B0E700 /* LoopUITests.xctest */, + 840B7A7E2B7BFF58000ED932 /* LoopUITests.xctest */, + 847434F32B7C41D30084BE98 /* DIYLoopUITests.xctest */, ); name = Products; sourceTree = ""; @@ -2494,43 +2507,31 @@ path = Common; sourceTree = ""; }; - 845DD3222B6AE05900B0E700 /* LoopUITests */ = { + 840B7A7F2B7BFF58000ED932 /* LoopUITests */ = { isa = PBXGroup; children = ( - 8402E9012B72B9D200E3EB1F /* LoopUITests.swift */, - 845DD3322B6AE07300B0E700 /* Helpers */, - 845DD32D2B6AE07300B0E700 /* Screens */, - 845DD3352B6AE07300B0E700 /* TestPlans */, + 847434C72B7C17800084BE98 /* LoopUITests.swift */, + 847434DC2B7C34F70084BE98 /* LoopUITestPlan.xctestplan */, ); path = LoopUITests; sourceTree = ""; }; - 845DD32D2B6AE07300B0E700 /* Screens */ = { + 847434F42B7C41D30084BE98 /* DIYLoopUITests */ = { isa = PBXGroup; children = ( - 845DD32E2B6AE07300B0E700 /* OnboardingScreen.swift */, - 845DD32F2B6AE07300B0E700 /* BaseScreen.swift */, - 845DD3302B6AE07300B0E700 /* HomeScreen.swift */, - 84FF233F2B6DD10200C08A87 /* SettingsScreen.swift */, - 84FF23412B6DDAE400C08A87 /* SystemSettingsScreen.swift */, - 8402E9032B72F19400E3EB1F /* PumpSimulatorScreen.swift */, + 847435032B7C4F7D0084BE98 /* Screens */, + 847434F52B7C41D30084BE98 /* DIYLoopUITests.swift */, + 847435022B7C42300084BE98 /* DIYLoopUITestPlan.xctestplan */, ); - path = Screens; + path = DIYLoopUITests; sourceTree = ""; }; - 845DD3322B6AE07300B0E700 /* Helpers */ = { + 847435032B7C4F7D0084BE98 /* Screens */ = { isa = PBXGroup; children = ( - 845DD3332B6AE07300B0E700 /* Common.swift */, + 847435042B7C4F8D0084BE98 /* OnboardingScreen.swift */, ); - path = Helpers; - sourceTree = ""; - }; - 845DD3352B6AE07300B0E700 /* TestPlans */ = { - isa = PBXGroup; - children = ( - ); - path = TestPlans; + path = Screens; sourceTree = ""; }; 84AA81D12A4A2778000B658B /* Components */ = { @@ -3133,22 +3134,46 @@ productReference = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; productType = "com.apple.product-type.framework"; }; - 845DD3202B6AE05900B0E700 /* LoopUITests */ = { + 840B7A7D2B7BFF58000ED932 /* LoopUITests */ = { isa = PBXNativeTarget; - buildConfigurationList = 845DD32C2B6AE05900B0E700 /* Build configuration list for PBXNativeTarget "LoopUITests" */; + buildConfigurationList = 840B7A862B7BFF59000ED932 /* Build configuration list for PBXNativeTarget "LoopUITests" */; buildPhases = ( - 845DD31D2B6AE05900B0E700 /* Sources */, - 845DD31E2B6AE05900B0E700 /* Frameworks */, - 845DD31F2B6AE05900B0E700 /* Resources */, + 840B7A7A2B7BFF58000ED932 /* Sources */, + 840B7A7B2B7BFF58000ED932 /* Frameworks */, + 840B7A7C2B7BFF58000ED932 /* Resources */, ); buildRules = ( ); dependencies = ( - 845DD3282B6AE05900B0E700 /* PBXTargetDependency */, + 840B7A852B7BFF58000ED932 /* PBXTargetDependency */, ); name = LoopUITests; + packageProductDependencies = ( + 845C74342B7D686000F71F90 /* LoopUITestingKit */, + ); productName = LoopUITests; - productReference = 845DD3212B6AE05900B0E700 /* LoopUITests.xctest */; + productReference = 840B7A7E2B7BFF58000ED932 /* LoopUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 847434F22B7C41D30084BE98 /* DIYLoopUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 847434FE2B7C41D30084BE98 /* Build configuration list for PBXNativeTarget "DIYLoopUITests" */; + buildPhases = ( + 847434EF2B7C41D30084BE98 /* Sources */, + 847434F02B7C41D30084BE98 /* Frameworks */, + 847434F12B7C41D30084BE98 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 847434FA2B7C41D30084BE98 /* PBXTargetDependency */, + ); + name = DIYLoopUITests; + packageProductDependencies = ( + 845C74362B7D686700F71F90 /* LoopUITestingKit */, + ); + productName = DIYLoopUITests; + productReference = 847434F32B7C41D30084BE98 /* DIYLoopUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; E9B07F7B253BBA6500BAD8F8 /* Loop Intent Extension */ = { @@ -3268,9 +3293,12 @@ LastSwiftMigration = 1020; ProvisioningStyle = Automatic; }; - 845DD3202B6AE05900B0E700 = { + 840B7A7D2B7BFF58000ED932 = { + CreatedOnToolsVersion = 15.2; + LastSwiftMigration = 1520; + }; + 847434F22B7C41D30084BE98 = { CreatedOnToolsVersion = 15.2; - TestTargetID = 43776F8B1B8022E90074EA36; }; E9B07F7B253BBA6500BAD8F8 = { ProvisioningStyle = Automatic; @@ -3312,6 +3340,7 @@ C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */, C1D6EE9E2A06C7270047DE5C /* XCRemoteSwiftPackageReference "MKRingProgressView" */, C1735B1C2A0809830082BB8A /* XCRemoteSwiftPackageReference "ZIPFoundation" */, + 845C74332B7D686000F71F90 /* XCRemoteSwiftPackageReference "LoopUITestingKit" */, ); productRefGroup = 43776F8D1B8022E90074EA36 /* Products */; projectDirPath = ""; @@ -3327,7 +3356,8 @@ 43D9001A21EB209400AF44BF /* LoopCore-watchOS */, 4F75288A1DFE1DC600C322D6 /* LoopUI */, 43E2D90A1D20C581004DA55F /* LoopTests */, - 845DD3202B6AE05900B0E700 /* LoopUITests */, + 840B7A7D2B7BFF58000ED932 /* LoopUITests */, + 847434F22B7C41D30084BE98 /* DIYLoopUITests */, ); }; /* End PBXProject section */ @@ -3448,7 +3478,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 845DD31F2B6AE05900B0E700 /* Resources */ = { + 840B7A7C2B7BFF58000ED932 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 847434F12B7C41D30084BE98 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -4063,18 +4100,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 845DD31D2B6AE05900B0E700 /* Sources */ = { + 840B7A7A2B7BFF58000ED932 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 847434C82B7C17800084BE98 /* LoopUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 847434EF2B7C41D30084BE98 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 84FF23402B6DD10200C08A87 /* SettingsScreen.swift in Sources */, - 845DD3382B6AE07300B0E700 /* HomeScreen.swift in Sources */, - 84FF23422B6DDAE400C08A87 /* SystemSettingsScreen.swift in Sources */, - 8402E9022B72B9D200E3EB1F /* LoopUITests.swift in Sources */, - 845DD3372B6AE07300B0E700 /* BaseScreen.swift in Sources */, - 845DD3362B6AE07300B0E700 /* OnboardingScreen.swift in Sources */, - 8402E9042B72F19400E3EB1F /* PumpSimulatorScreen.swift in Sources */, - 845DD33A2B6AE07300B0E700 /* Common.swift in Sources */, + 847435052B7C4F8D0084BE98 /* OnboardingScreen.swift in Sources */, + 847434F62B7C41D30084BE98 /* DIYLoopUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4136,10 +4175,15 @@ target = 4F75288A1DFE1DC600C322D6 /* LoopUI */; targetProxy = 4F7528961DFE1ED400C322D6 /* PBXContainerItemProxy */; }; - 845DD3282B6AE05900B0E700 /* PBXTargetDependency */ = { + 840B7A852B7BFF58000ED932 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 43776F8B1B8022E90074EA36 /* Loop */; + targetProxy = 840B7A842B7BFF58000ED932 /* PBXContainerItemProxy */; + }; + 847434FA2B7C41D30084BE98 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 43776F8B1B8022E90074EA36 /* Loop */; - targetProxy = 845DD3272B6AE05900B0E700 /* PBXContainerItemProxy */; + targetProxy = 847434F92B7C41D30084BE98 /* PBXContainerItemProxy */; }; C117ED71232EDB3200DA57CD /* PBXTargetDependency */ = { isa = PBXTargetDependency; @@ -5495,11 +5539,12 @@ }; name = Release; }; - 845DD3292B6AE05900B0E700 /* Debug */ = { + 840B7A872B7BFF59000ED932 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; @@ -5525,19 +5570,21 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.LoopUITests; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Loop; }; name = Debug; }; - 845DD32A2B6AE05900B0E700 /* Testflight */ = { + 840B7A882B7BFF59000ED932 /* Testflight */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; @@ -5563,18 +5610,20 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.LoopUITests; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = ""; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Loop; }; name = Testflight; }; - 845DD32B2B6AE05900B0E700 /* Release */ = { + 840B7A892B7BFF59000ED932 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; @@ -5600,10 +5649,122 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.LoopUITests; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = ""; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 847434FB2B7C41D30084BE98 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 75U4X84TEG; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.DIYLoopUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = ""; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 847434FC2B7C41D30084BE98 /* Testflight */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 75U4X84TEG; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.DIYLoopUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = ""; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Testflight; + }; + 847434FD2B7C41D30084BE98 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 75U4X84TEG; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.DIYLoopUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = ""; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Loop; }; name = Release; }; @@ -6146,12 +6307,22 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 845DD32C2B6AE05900B0E700 /* Build configuration list for PBXNativeTarget "LoopUITests" */ = { + 840B7A862B7BFF59000ED932 /* Build configuration list for PBXNativeTarget "LoopUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 840B7A872B7BFF59000ED932 /* Debug */, + 840B7A882B7BFF59000ED932 /* Testflight */, + 840B7A892B7BFF59000ED932 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 847434FE2B7C41D30084BE98 /* Build configuration list for PBXNativeTarget "DIYLoopUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( - 845DD3292B6AE05900B0E700 /* Debug */, - 845DD32A2B6AE05900B0E700 /* Testflight */, - 845DD32B2B6AE05900B0E700 /* Release */, + 847434FB2B7C41D30084BE98 /* Debug */, + 847434FC2B7C41D30084BE98 /* Testflight */, + 847434FD2B7C41D30084BE98 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -6169,6 +6340,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 845C74332B7D686000F71F90 /* XCRemoteSwiftPackageReference "LoopUITestingKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tidepool-org/LoopUITestingKit.git"; + requirement = { + branch = main; + kind = branch; + }; + }; C1735B1C2A0809830082BB8A /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/LoopKit/ZIPFoundation.git"; @@ -6196,6 +6375,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 845C74342B7D686000F71F90 /* LoopUITestingKit */ = { + isa = XCSwiftPackageProductDependency; + package = 845C74332B7D686000F71F90 /* XCRemoteSwiftPackageReference "LoopUITestingKit" */; + productName = LoopUITestingKit; + }; + 845C74362B7D686700F71F90 /* LoopUITestingKit */ = { + isa = XCSwiftPackageProductDependency; + package = 845C74332B7D686000F71F90 /* XCRemoteSwiftPackageReference "LoopUITestingKit" */; + productName = LoopUITestingKit; + }; C11B9D5A286778A800500CF8 /* SwiftCharts */ = { isa = XCSwiftPackageProductDependency; package = C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */; diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/LoopUITests.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/LoopUITests.xcscheme deleted file mode 100644 index 10fb231af9..0000000000 --- a/Loop.xcodeproj/xcshareddata/xcschemes/LoopUITests.xcscheme +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/LoopUITests/DIYLoopUnitTestPlan.xctestplan b/LoopUITests/DIYLoopUnitTestPlan.xctestplan new file mode 100644 index 0000000000..844d8fb21e --- /dev/null +++ b/LoopUITests/DIYLoopUnitTestPlan.xctestplan @@ -0,0 +1,113 @@ +{ + "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:..\/Common\/TidepoolService\/TidepoolService.xcodeproj", + "identifier" : "A9DAAD0622E7987800E76C9F", + "name" : "TidepoolServiceKitTests" + } + }, + { + "target" : { + "containerPath" : "container:..\/Common\/TidepoolService\/TidepoolService.xcodeproj", + "identifier" : "A9DAAD2222E7988900E76C9F", + "name" : "TidepoolServiceKitUITests" + } + }, + { + "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/LoopUITests/Helpers/Common.swift b/LoopUITests/Helpers/Common.swift deleted file mode 100644 index 724c283dd9..0000000000 --- a/LoopUITests/Helpers/Common.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Common.swift -// LoopUITests -// -// Created by Ginny Yadav on 10/31/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import Foundation -import XCTest - -@MainActor -class Common { - struct TestSettings { - static let elementTimeout: TimeInterval = 5 - } -} - -func waitForExistence(_ element: XCUIElement) { - XCTAssert(element.waitForExistence(timeout: Common.TestSettings.elementTimeout)) -} - -extension XCUIElement { - func forceTap() { - if self.isHittable { - self.tap() - } - else { - let coordinate: XCUICoordinate = self.coordinate(withNormalizedOffset: CGVector(dx:0.0, dy:0.0)) - coordinate.tap() - } - } -} diff --git a/LoopUITests/LoopUITestPlan.xctestplan b/LoopUITests/LoopUITestPlan.xctestplan new file mode 100644 index 0000000000..53e2ab73c8 --- /dev/null +++ b/LoopUITests/LoopUITestPlan.xctestplan @@ -0,0 +1,29 @@ +{ + "configurations" : [ + { + "id" : "E21F6FDF-4D9A-44ED-99CD-2F9CA0B20D37", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:..\/Loop\/Loop.xcodeproj", + "identifier" : "43776F8B1B8022E90074EA36", + "name" : "Loop" + }, + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:..\/Loop\/Loop.xcodeproj", + "identifier" : "840B7A7D2B7BFF58000ED932", + "name" : "LoopUITests" + } + } + ], + "version" : 1 +} diff --git a/LoopUITests/LoopUITests.swift b/LoopUITests/LoopUITests.swift index e23cbf9996..cd05f719b3 100644 --- a/LoopUITests/LoopUITests.swift +++ b/LoopUITests/LoopUITests.swift @@ -2,55 +2,39 @@ // LoopUITests.swift // LoopUITests // -// Created by Cameron Ingham on 2/6/24. +// Created by Cameron Ingham on 2/13/24. // Copyright © 2024 LoopKit Authors. All rights reserved. // +import LoopUITestingKit import XCTest @MainActor final class LoopUITests: XCTestCase { var app: XCUIApplication! var baseScreen: BaseScreen! - var onboardingScreen: OnboardingScreen! var homeScreen: HomeScreen! var settingsScreen: SettingsScreen! var systemSettingsScreen: SystemSettingsScreen! var pumpSimulatorScreen: PumpSimulatorScreen! + var onboardingScreen: OnboardingScreen! var common: Common! - + override func setUpWithError() throws { continueAfterFailure = false - app = XCUIApplication() + app = XCUIApplication(bundleIdentifier: "org.tidepool.Loop") app.launch() baseScreen = BaseScreen(app: app) - onboardingScreen = OnboardingScreen(app: app) homeScreen = HomeScreen(app: app) settingsScreen = SettingsScreen(app: app) systemSettingsScreen = SystemSettingsScreen() pumpSimulatorScreen = PumpSimulatorScreen(app: app) - common = Common() - } - - func testSkippingOnboardingLeadsToHomepageWithSimulators() { - baseScreen.deleteApp() - app.launch() - onboardingScreen.skipAllOfOnboarding() - waitForExistence(homeScreen.hudStatusClosedLoop) - homeScreen.openSettings() - settingsScreen.openPumpManager() - waitForExistence(settingsScreen.pumpSimulatorTitle) - settingsScreen.closePumpSimulator() - settingsScreen.openCGMManager() - waitForExistence(settingsScreen.cgmSimulatorTitle) - settingsScreen.closeCGMSimulator() - settingsScreen.closeSettingsScreen() - waitForExistence(homeScreen.hudStatusClosedLoop) + onboardingScreen = OnboardingScreen(app: app) + common = Common(appName: "Tidepool Loop") } // https://tidepool.atlassian.net/browse/LOOP-1605 func testAlertSettingsUI() { - onboardingScreen.skipAllOfOnboardingIfNeeded() systemSettingsScreen.launchApp() systemSettingsScreen.openAppSystemSettings() systemSettingsScreen.openSystemNotificationSettings() @@ -76,7 +60,6 @@ final class LoopUITests: XCTestCase { // https://tidepool.atlassian.net/browse/LOOP-1713 func testConfigureClosedLoopManagement() { - onboardingScreen.skipAllOfOnboardingIfNeeded() waitForExistence(homeScreen.hudStatusClosedLoop) waitForExistence(homeScreen.preMealTabEnabled) homeScreen.tapPreMealButton() @@ -113,12 +96,12 @@ final class LoopUITests: XCTestCase { // https://tidepool.atlassian.net/browse/LOOP-1636 func testPumpErrorAndStateHandlingStatusBarDisplay() { - onboardingScreen.skipAllOfOnboardingIfNeeded() waitForExistence(homeScreen.hudStatusClosedLoop) homeScreen.tapPumpPill() pumpSimulatorScreen.tapSuspendInsulinButton() waitForExistence(pumpSimulatorScreen.resumeInsulinButton) pumpSimulatorScreen.closePumpSimulator() + waitForExistence(homeScreen.hudPumpPill) XCTAssertEqual(homeScreen.hudPumpPill.value as? String, NSLocalizedString("Insulin Suspended", comment: "")) homeScreen.tapPumpPill() pumpSimulatorScreen.tapResumeInsulinButton() @@ -131,6 +114,7 @@ final class LoopUITests: XCTestCase { pumpSimulatorScreen.closeReservoirRemainingScreen() pumpSimulatorScreen.closePumpSettings() pumpSimulatorScreen.closePumpSimulator() + waitForExistence(homeScreen.hudPumpPill) XCTAssertEqual(homeScreen.hudPumpPill.value as? String, NSLocalizedString("No Insulin", comment: "")) homeScreen.tapPumpPill() pumpSimulatorScreen.openPumpSettings() @@ -141,6 +125,7 @@ final class LoopUITests: XCTestCase { pumpSimulatorScreen.closeReservoirRemainingScreen() pumpSimulatorScreen.closePumpSettings() pumpSimulatorScreen.closePumpSimulator() + waitForExistence(homeScreen.hudPumpPill) XCTAssert((homeScreen.hudPumpPill.value as? String)?.contains("15 units remaining") == true) homeScreen.tapPumpPill() pumpSimulatorScreen.openPumpSettings() @@ -151,12 +136,14 @@ final class LoopUITests: XCTestCase { pumpSimulatorScreen.closeReservoirRemainingScreen() pumpSimulatorScreen.closePumpSettings() pumpSimulatorScreen.closePumpSimulator() + waitForExistence(homeScreen.hudPumpPill) XCTAssert((homeScreen.hudPumpPill.value as? String)?.contains("45 units remaining") == true) homeScreen.tapPumpPill() pumpSimulatorScreen.openPumpSettings() pumpSimulatorScreen.tapDetectOcclusionButton() pumpSimulatorScreen.closePumpSettings() pumpSimulatorScreen.closePumpSimulator() + waitForExistence(homeScreen.hudPumpPill) XCTAssertEqual(homeScreen.hudPumpPill.value as? String, NSLocalizedString("Pump Occlusion", comment: "")) homeScreen.tapBolusEntry() homeScreen.tapBolusEntryTextField() @@ -171,6 +158,7 @@ final class LoopUITests: XCTestCase { pumpSimulatorScreen.tapCausePumpErrorButton() pumpSimulatorScreen.closePumpSettings() pumpSimulatorScreen.closePumpSimulator() + waitForExistence(homeScreen.hudPumpPill) XCTAssertEqual(homeScreen.hudPumpPill.value as? String, NSLocalizedString("Pump Error", comment: "")) homeScreen.tapPumpPill() pumpSimulatorScreen.openPumpSettings() diff --git a/LoopUITests/Screens/BaseScreen.swift b/LoopUITests/Screens/BaseScreen.swift index da4cd64e0c..3acf372cd1 100644 --- a/LoopUITests/Screens/BaseScreen.swift +++ b/LoopUITests/Screens/BaseScreen.swift @@ -41,7 +41,3 @@ class BaseScreen { } } } - - - - diff --git a/LoopUITests/Screens/HomeScreen.swift b/LoopUITests/Screens/HomeScreen.swift index 0f3ec59685..6b2da6a8a3 100644 --- a/LoopUITests/Screens/HomeScreen.swift +++ b/LoopUITests/Screens/HomeScreen.swift @@ -231,8 +231,5 @@ class HomeScreen: BaseScreen { waitForExistence(passcodeEntry) passcodeEntry.tap() springboardApp.typeText("1\n") -// sleep(1) -// waitForExistence(springboardKeyboardDoneButton) -// springboardKeyboardDoneButton.tap() } } From 0d7ce81cfdd279bec8fba5fd7cfd6505024bbb8b Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 14 Feb 2024 14:13:03 -0800 Subject: [PATCH 021/184] [LOOP-4793] Beginning XCUI Tests --- DIYLoopUITests/DIYLoopUITests.swift | 14 +++++++------- LoopUITests/LoopUITests.swift | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/DIYLoopUITests/DIYLoopUITests.swift b/DIYLoopUITests/DIYLoopUITests.swift index 8a278c1df9..32a6ff066d 100644 --- a/DIYLoopUITests/DIYLoopUITests.swift +++ b/DIYLoopUITests/DIYLoopUITests.swift @@ -24,17 +24,17 @@ final class DIYLoopUITests: XCTestCase { continueAfterFailure = false app = XCUIApplication(bundleIdentifier: "org.tidepool.diy.Loop") app.launch() - baseScreen = BaseScreen(app: app) - homeScreen = HomeScreen(app: app) - settingsScreen = SettingsScreen(app: app) - systemSettingsScreen = SystemSettingsScreen() - pumpSimulatorScreen = PumpSimulatorScreen(app: app) - onboardingScreen = OnboardingScreen(app: app) + baseScreen = BaseScreen(app: app, appName: "DIY Loop") + homeScreen = HomeScreen(app: app, appName: "DIY Loop") + settingsScreen = SettingsScreen(app: app, appName: "DIY Loop") + systemSettingsScreen = SystemSettingsScreen(app: app, appName: "DIY Loop") + pumpSimulatorScreen = PumpSimulatorScreen(app: app, appName: "DIY Loop") + onboardingScreen = OnboardingScreen(app: app, appName: "DIY Loop") common = Common(appName: "DIY Loop") } func testSkippingOnboarding() async throws { - baseScreen.deleteApp(appName: "DIY Loop") + baseScreen.deleteApp() app.launch() onboardingScreen.skipAllOfOnboarding() } diff --git a/LoopUITests/LoopUITests.swift b/LoopUITests/LoopUITests.swift index cd05f719b3..6eff4194b9 100644 --- a/LoopUITests/LoopUITests.swift +++ b/LoopUITests/LoopUITests.swift @@ -24,12 +24,12 @@ final class LoopUITests: XCTestCase { continueAfterFailure = false app = XCUIApplication(bundleIdentifier: "org.tidepool.Loop") app.launch() - baseScreen = BaseScreen(app: app) - homeScreen = HomeScreen(app: app) - settingsScreen = SettingsScreen(app: app) - systemSettingsScreen = SystemSettingsScreen() - pumpSimulatorScreen = PumpSimulatorScreen(app: app) - onboardingScreen = OnboardingScreen(app: app) + baseScreen = BaseScreen(app: app, appName: "Tidepool Loop") + homeScreen = HomeScreen(app: app, appName: "Tidepool Loop") + settingsScreen = SettingsScreen(app: app, appName: "Tidepool Loop") + systemSettingsScreen = SystemSettingsScreen(app: app, appName: "Tidepool Loop") + pumpSimulatorScreen = PumpSimulatorScreen(app: app, appName: "Tidepool Loop") + onboardingScreen = OnboardingScreen(app: app, appName: "Tidepool Loop") common = Common(appName: "Tidepool Loop") } From c9dbc95fc9fd29e0c35bb9769ff7655d849ae9ba Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 16 Feb 2024 13:54:08 -0600 Subject: [PATCH 022/184] Fail loop if pump data is too old (#618) --- Loop/Managers/ExtensionDataManager.swift | 6 +----- Loop/Managers/LoopDataManager.swift | 4 ++++ LoopTests/Managers/LoopDataManagerTests.swift | 2 ++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift index 09d7170237..c5c6f49b50 100644 --- a/Loop/Managers/ExtensionDataManager.swift +++ b/Loop/Managers/ExtensionDataManager.swift @@ -115,13 +115,9 @@ final class ExtensionDataManager { unit: HKUnit.milligramsPerDeciliter, startDate: Date(), interval: TimeInterval(minutes: 5)) - - let lastLoopCompleted = Date(timeIntervalSinceNow: -TimeInterval(minutes: 0)) - #else - let lastLoopCompleted = loopDataManager.lastLoopCompleted #endif - context.lastLoopCompleted = lastLoopCompleted + context.lastLoopCompleted = loopDataManager.lastLoopCompleted context.isClosedLoop = self.automaticDosingStatus.automaticDosingEnabled diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 7afb2f7244..003ef8a668 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -468,6 +468,10 @@ final class LoopDataManager { throw LoopError.invalidFutureGlucose(date: latestGlucose.startDate) } + guard startDate.timeIntervalSince(doseStore.lastAddedPumpData) <= LoopAlgorithm.inputDataRecencyInterval else { + throw LoopError.pumpDataTooOld(date: doseStore.lastAddedPumpData) + } + var output = LoopAlgorithm.run(input: input) switch output.recommendationResult { diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 2380ba701b..2819956f23 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -111,6 +111,8 @@ class LoopDataManagerTests: XCTestCase { now = dateFormatter.date(from: "2023-07-29T19:21:00Z")! + doseStore.lastAddedPumpData = now + dosingDecisionStore = MockDosingDecisionStore() automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) From db0532737be8aa1025a2c892fecc2cc2a9011571 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 20 Feb 2024 11:59:21 -0800 Subject: [PATCH 023/184] [LOOP-4793] Beginning XCUI Tests --- DIYLoopUITests/DIYLoopUITests.swift | 2 +- LoopUITests/LoopUITests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DIYLoopUITests/DIYLoopUITests.swift b/DIYLoopUITests/DIYLoopUITests.swift index 32a6ff066d..cc3697b916 100644 --- a/DIYLoopUITests/DIYLoopUITests.swift +++ b/DIYLoopUITests/DIYLoopUITests.swift @@ -22,7 +22,7 @@ final class DIYLoopUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false - app = XCUIApplication(bundleIdentifier: "org.tidepool.diy.Loop") + app = XCUIApplication(bundleIdentifier: Bundle.main.bundleIdentifier!) app.launch() baseScreen = BaseScreen(app: app, appName: "DIY Loop") homeScreen = HomeScreen(app: app, appName: "DIY Loop") diff --git a/LoopUITests/LoopUITests.swift b/LoopUITests/LoopUITests.swift index 6eff4194b9..42548fc12d 100644 --- a/LoopUITests/LoopUITests.swift +++ b/LoopUITests/LoopUITests.swift @@ -22,7 +22,7 @@ final class LoopUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false - app = XCUIApplication(bundleIdentifier: "org.tidepool.Loop") + app = XCUIApplication(bundleIdentifier: Bundle.main.bundleIdentifier!) app.launch() baseScreen = BaseScreen(app: app, appName: "Tidepool Loop") homeScreen = HomeScreen(app: app, appName: "Tidepool Loop") From 4ac1f3f8156a9843724f817d28140cdef33112f4 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 20 Feb 2024 13:38:32 -0800 Subject: [PATCH 024/184] [LOOP-4793] Beginning XCUI Tests --- DIYLoopUITests/DIYLoopUITestPlan.xctestplan | 17 ++++++++++++++++- DIYLoopUITests/DIYLoopUITests.swift | 16 ++++++++-------- Loop.xcodeproj/project.pbxproj | 8 ++++++++ LoopUITests/LoopUITestPlan.xctestplan | 11 ++++++++++- LoopUITests/LoopUITests.swift | 16 ++++++++-------- 5 files changed, 50 insertions(+), 18 deletions(-) diff --git a/DIYLoopUITests/DIYLoopUITestPlan.xctestplan b/DIYLoopUITests/DIYLoopUITestPlan.xctestplan index e8b3aff881..499bdd7419 100644 --- a/DIYLoopUITests/DIYLoopUITestPlan.xctestplan +++ b/DIYLoopUITests/DIYLoopUITestPlan.xctestplan @@ -4,11 +4,26 @@ "id" : "7D98F861-1A40-4E2D-B298-96208D0BC6BC", "name" : "Configuration 1", "options" : { - + "environmentVariableEntries" : [ + { + "key" : "appName", + "value" : "DIY Loop" + } + ] } } ], "defaultOptions" : { + "environmentVariableEntries" : [ + { + "key" : "bundleIdentifier", + "value" : "org.tidepool.diy.Loop" + }, + { + "key" : "appName", + "value" : "DIY Loop" + } + ], "targetForVariableExpansion" : { "containerPath" : "container:..\/Loop\/Loop.xcodeproj", "identifier" : "43776F8B1B8022E90074EA36", diff --git a/DIYLoopUITests/DIYLoopUITests.swift b/DIYLoopUITests/DIYLoopUITests.swift index cc3697b916..98956c7994 100644 --- a/DIYLoopUITests/DIYLoopUITests.swift +++ b/DIYLoopUITests/DIYLoopUITests.swift @@ -22,15 +22,15 @@ final class DIYLoopUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false - app = XCUIApplication(bundleIdentifier: Bundle.main.bundleIdentifier!) + app = XCUIApplication() app.launch() - baseScreen = BaseScreen(app: app, appName: "DIY Loop") - homeScreen = HomeScreen(app: app, appName: "DIY Loop") - settingsScreen = SettingsScreen(app: app, appName: "DIY Loop") - systemSettingsScreen = SystemSettingsScreen(app: app, appName: "DIY Loop") - pumpSimulatorScreen = PumpSimulatorScreen(app: app, appName: "DIY Loop") - onboardingScreen = OnboardingScreen(app: app, appName: "DIY Loop") - common = Common(appName: "DIY Loop") + baseScreen = BaseScreen(app: app) + homeScreen = HomeScreen(app: app) + settingsScreen = SettingsScreen(app: app) + systemSettingsScreen = SystemSettingsScreen(app: app) + pumpSimulatorScreen = PumpSimulatorScreen(app: app) + onboardingScreen = OnboardingScreen(app: app) + common = Common() } func testSkippingOnboarding() async throws { diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index fbb576dd32..5c7017171b 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -3296,9 +3296,11 @@ 840B7A7D2B7BFF58000ED932 = { CreatedOnToolsVersion = 15.2; LastSwiftMigration = 1520; + TestTargetID = 43776F8B1B8022E90074EA36; }; 847434F22B7C41D30084BE98 = { CreatedOnToolsVersion = 15.2; + TestTargetID = 43776F8B1B8022E90074EA36; }; E9B07F7B253BBA6500BAD8F8 = { ProvisioningStyle = Automatic; @@ -5576,6 +5578,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Loop; }; name = Debug; }; @@ -5615,6 +5618,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Loop; }; name = Testflight; }; @@ -5653,6 +5657,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Loop; }; name = Release; }; @@ -5691,6 +5696,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Loop; }; name = Debug; }; @@ -5728,6 +5734,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Loop; }; name = Testflight; }; @@ -5765,6 +5772,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Loop; }; name = Release; }; diff --git a/LoopUITests/LoopUITestPlan.xctestplan b/LoopUITests/LoopUITestPlan.xctestplan index 53e2ab73c8..44051bccdd 100644 --- a/LoopUITests/LoopUITestPlan.xctestplan +++ b/LoopUITests/LoopUITestPlan.xctestplan @@ -4,7 +4,16 @@ "id" : "E21F6FDF-4D9A-44ED-99CD-2F9CA0B20D37", "name" : "Configuration 1", "options" : { - + "environmentVariableEntries" : [ + { + "key" : "appName", + "value" : "Loop" + }, + { + "key" : "bundleIdentifier", + "value" : "org.tidepool.Loop" + } + ] } } ], diff --git a/LoopUITests/LoopUITests.swift b/LoopUITests/LoopUITests.swift index 42548fc12d..b72218a618 100644 --- a/LoopUITests/LoopUITests.swift +++ b/LoopUITests/LoopUITests.swift @@ -22,15 +22,15 @@ final class LoopUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false - app = XCUIApplication(bundleIdentifier: Bundle.main.bundleIdentifier!) + app = XCUIApplication() app.launch() - baseScreen = BaseScreen(app: app, appName: "Tidepool Loop") - homeScreen = HomeScreen(app: app, appName: "Tidepool Loop") - settingsScreen = SettingsScreen(app: app, appName: "Tidepool Loop") - systemSettingsScreen = SystemSettingsScreen(app: app, appName: "Tidepool Loop") - pumpSimulatorScreen = PumpSimulatorScreen(app: app, appName: "Tidepool Loop") - onboardingScreen = OnboardingScreen(app: app, appName: "Tidepool Loop") - common = Common(appName: "Tidepool Loop") + baseScreen = BaseScreen(app: app) + homeScreen = HomeScreen(app: app) + settingsScreen = SettingsScreen(app: app) + systemSettingsScreen = SystemSettingsScreen(app: app) + pumpSimulatorScreen = PumpSimulatorScreen(app: app) + onboardingScreen = OnboardingScreen(app: app) + common = Common() } // https://tidepool.atlassian.net/browse/LOOP-1605 From ab646a809bec4aec3f5953e9532a336283df9c85 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 22 Feb 2024 11:31:07 -0800 Subject: [PATCH 025/184] [LOOP-4793] Beginning XCUI Tests --- DIYLoopUITests/DIYLoopUITests.swift | 6 ++---- DIYLoopUITests/Screens/OnboardingScreen.swift | 2 +- LoopUITests/LoopUITests.swift | 7 +------ 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/DIYLoopUITests/DIYLoopUITests.swift b/DIYLoopUITests/DIYLoopUITests.swift index 98956c7994..e2e2cb328b 100644 --- a/DIYLoopUITests/DIYLoopUITests.swift +++ b/DIYLoopUITests/DIYLoopUITests.swift @@ -11,18 +11,17 @@ import XCTest @MainActor final class DIYLoopUITests: XCTestCase { - var app: XCUIApplication! + private let app = XCUIApplication() + var baseScreen: BaseScreen! var homeScreen: HomeScreen! var settingsScreen: SettingsScreen! var systemSettingsScreen: SystemSettingsScreen! var pumpSimulatorScreen: PumpSimulatorScreen! var onboardingScreen: OnboardingScreen! - var common: Common! override func setUpWithError() throws { continueAfterFailure = false - app = XCUIApplication() app.launch() baseScreen = BaseScreen(app: app) homeScreen = HomeScreen(app: app) @@ -30,7 +29,6 @@ final class DIYLoopUITests: XCTestCase { systemSettingsScreen = SystemSettingsScreen(app: app) pumpSimulatorScreen = PumpSimulatorScreen(app: app) onboardingScreen = OnboardingScreen(app: app) - common = Common() } func testSkippingOnboarding() async throws { diff --git a/DIYLoopUITests/Screens/OnboardingScreen.swift b/DIYLoopUITests/Screens/OnboardingScreen.swift index 25fbed7418..970cbd7b91 100644 --- a/DIYLoopUITests/Screens/OnboardingScreen.swift +++ b/DIYLoopUITests/Screens/OnboardingScreen.swift @@ -9,7 +9,7 @@ import LoopUITestingKit import XCTest -extension OnboardingScreen { +class OnboardingScreen: BaseScreen { // MARK: Elements diff --git a/LoopUITests/LoopUITests.swift b/LoopUITests/LoopUITests.swift index b72218a618..a998b807d0 100644 --- a/LoopUITests/LoopUITests.swift +++ b/LoopUITests/LoopUITests.swift @@ -11,26 +11,21 @@ import XCTest @MainActor final class LoopUITests: XCTestCase { - var app: XCUIApplication! + private let app = XCUIApplication() var baseScreen: BaseScreen! var homeScreen: HomeScreen! var settingsScreen: SettingsScreen! var systemSettingsScreen: SystemSettingsScreen! var pumpSimulatorScreen: PumpSimulatorScreen! - var onboardingScreen: OnboardingScreen! - var common: Common! override func setUpWithError() throws { continueAfterFailure = false - app = XCUIApplication() app.launch() baseScreen = BaseScreen(app: app) homeScreen = HomeScreen(app: app) settingsScreen = SettingsScreen(app: app) systemSettingsScreen = SystemSettingsScreen(app: app) pumpSimulatorScreen = PumpSimulatorScreen(app: app) - onboardingScreen = OnboardingScreen(app: app) - common = Common() } // https://tidepool.atlassian.net/browse/LOOP-1605 From 7058b8794099170b24176783b02c40c0376d100e Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 28 Feb 2024 14:06:24 -0800 Subject: [PATCH 026/184] [LOOP-4548] Fix 0 Value Placeholder in Simple Bolus Calculator --- Loop/View Models/SimpleBolusViewModel.swift | 2 +- Loop/Views/SimpleBolusView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift index ed13799b0f..1137f9bd03 100644 --- a/Loop/View Models/SimpleBolusViewModel.swift +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -216,7 +216,7 @@ class SimpleBolusViewModel: ObservableObject { enteredBolusString = Self.doseAmountFormatter.string(from: min(recommendation, maxBolus))! } else { recommendedBolus = NSLocalizedString("–", comment: "String denoting lack of a recommended bolus amount in the simple bolus calculator") - enteredBolusString = Self.doseAmountFormatter.string(from: 0.0)! + enteredBolusString = "" } } } diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index e0b413df53..087cd5a130 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -202,7 +202,7 @@ struct SimpleBolusView: View { HStack(alignment: .firstTextBaseline) { DismissibleKeyboardTextField( text: $viewModel.enteredBolusString, - placeholder: "", + placeholder: "0", font: .preferredFont(forTextStyle: .title1), textColor: .loopAccent, textAlignment: .right, From 679654013cf3e0c26d8cef594571582c6d6e853b Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 29 Feb 2024 10:42:56 -0800 Subject: [PATCH 027/184] [LOOP-4548] Fix 0 Value Placeholder in Simple Bolus Calculator Tests --- LoopTests/ViewModels/SimpleBolusViewModelTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift index b46077c9bf..bc35213e9c 100644 --- a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift +++ b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift @@ -136,7 +136,7 @@ class SimpleBolusViewModelTests: XCTestCase { viewModel.enteredCarbString = "" XCTAssertEqual("–", viewModel.recommendedBolus) - XCTAssertEqual("0", viewModel.enteredBolusString) + XCTAssertEqual("", viewModel.enteredBolusString) } func testDeleteCurrentGlucoseRemovesRecommendation() { @@ -155,7 +155,7 @@ class SimpleBolusViewModelTests: XCTestCase { viewModel.manualGlucoseString = "" XCTAssertEqual("–", viewModel.recommendedBolus) - XCTAssertEqual("0", viewModel.enteredBolusString) + XCTAssertEqual("", viewModel.enteredBolusString) } func testDeleteCurrentGlucoseRemovesActiveInsulin() { From 0bd52ba9faa74db81f9aa6de25fc5c834c6574aa Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 5 Mar 2024 12:07:42 -0600 Subject: [PATCH 028/184] LOOP-4781 Loop to use LoopAlgorithm swift package (#617) * Adding testRunWithOngoingTempBasal * Building with LoopAlgorithm swift package * fix merge * Tests moved to LoopAlgorithm package * Fix warning * Adding cancel helper for TempBasalRecommendation * Add TempBasalRecommendationTests for extensions * Add SimpleInsulinDose so we can specify which fast acting model to LoopAlgorithm * Remove unused imports * Update for LoopAlgorithm using doses with insulin models * Updates from PR review * Fix signature of method call, and cleanup unused method * Unused var * Remove unused parts of method for clarity --- Common/Extensions/SampleValue.swift | 2 +- Common/Models/StatusExtensionContext.swift | 1 + Common/Models/WatchContext.swift | 1 + Common/Models/WatchHistoricalGlucose.swift | 1 + Common/Models/WatchPredictedGlucose.swift | 1 + Learn/Managers/DataManager.swift | 1 - .../StatusViewController.swift | 13 +- .../Timeline/StatusWidgetTimelimeEntry.swift | 2 + .../StatusWidgetTimelineProvider.swift | 1 + Loop.xcodeproj/project.pbxproj | 38 +- Loop/Extensions/BasalDeliveryState.swift | 1 + Loop/Extensions/BasalRelativeDose.swift | 51 + Loop/Extensions/CollectionType+Loop.swift | 1 + ...osingDecisionStore+SimulatedCoreData.swift | 3 +- .../SettingsStore+SimulatedCoreData.swift | 1 + Loop/Extensions/TempBasalRecommendation.swift | 67 + Loop/Extensions/UserDefaults+Loop.swift | 1 + Loop/Managers/AppExpirationAlerter.swift | 7 +- Loop/Managers/CGMStalenessMonitor.swift | 1 + Loop/Managers/DeviceDataManager.swift | 5 +- Loop/Managers/DoseEnactor.swift | 1 + Loop/Managers/LoopAppManager.swift | 17 +- .../LoopDataManager+CarbAbsorption.swift | 6 +- Loop/Managers/LoopDataManager.swift | 117 +- .../MealDetectionManager.swift | 25 + Loop/Managers/SettingsManager.swift | 1 + Loop/Managers/StatusChartsManager.swift | 3 +- .../Store Protocols/CarbStoreProtocol.swift | 2 - .../Store Protocols/DoseStoreProtocol.swift | 1 + Loop/Models/BolusDosingDecision.swift | 1 + .../ConstantApplicationFactorStrategy.swift | 1 + Loop/Models/CrashRecoveryManager.swift | 1 + Loop/Models/GlucoseEffectVelocity.swift | 1 + Loop/Models/ManualBolusRecommendation.swift | 26 +- Loop/Models/NetBasal.swift | 1 + Loop/Models/PredictionInputEffect.swift | 1 + Loop/Models/SimpleInsulinDose.swift | 86 ++ Loop/Models/StoredDataAlgorithmInput.swift | 54 + Loop/Models/WatchContext+LoopKit.swift | 1 + .../CarbAbsorptionViewController.swift | 5 +- .../PredictionTableViewController.swift | 3 +- .../StatusTableViewController.swift | 5 +- Loop/View Models/BolusEntryViewModel.swift | 30 +- Loop/View Models/CarbEntryViewModel.swift | 5 +- .../ManualEntryDoseViewModel.swift | 17 +- Loop/View Models/SimpleBolusViewModel.swift | 1 + Loop/Views/PredictedGlucoseChartView.swift | 1 + Loop/Views/SimpleBolusView.swift | 3 +- LoopCore/LoopCoreConstants.swift | 4 +- LoopCore/LoopSettings.swift | 1 + LoopCore/NSUserDefaults.swift | 1 + .../live_capture/live_capture_input.json | 1352 ++++++++--------- .../live_capture_predicted_glucose.json | 152 +- .../Managers/DeviceDataManagerTests.swift | 9 +- LoopTests/Managers/DoseEnactorTests.swift | 1 + LoopTests/Managers/LoopAlgorithmTests.swift | 224 --- LoopTests/Managers/LoopDataManagerTests.swift | 103 +- .../Managers/MealDetectionManagerTests.swift | 19 +- .../TemporaryPresetsManagerTests.swift | 1 + LoopTests/Mock Stores/MockDoseStore.swift | 3 +- LoopTests/Mock Stores/MockGlucoseStore.swift | 1 + LoopTests/Mocks/MockDeliveryDelegate.swift | 1 + LoopTests/Mocks/MockSettingsProvider.swift | 1 + .../Models/TempBasalRecommendationTests.swift | 26 + .../ViewModels/BolusEntryViewModelTests.swift | 25 +- .../ManualEntryDoseViewModelTests.swift | 9 +- .../SimpleBolusViewModelTests.swift | 1 + .../ComplicationController.swift | 1 + .../Controllers/CarbEntryListController.swift | 3 +- .../Controllers/ChartHUDController.swift | 1 + .../Controllers/HUDInterfaceController.swift | 1 + .../Managers/ComplicationChartManager.swift | 1 + .../Managers/LoopDataManager.swift | 4 +- .../Models/GlucoseChartData.swift | 1 + .../Models/GlucoseChartScaler.swift | 1 + .../Scenes/GlucoseChartValueHashable.swift | 1 + 76 files changed, 1368 insertions(+), 1195 deletions(-) create mode 100644 Loop/Extensions/BasalRelativeDose.swift create mode 100644 Loop/Extensions/TempBasalRecommendation.swift create mode 100644 Loop/Models/SimpleInsulinDose.swift create mode 100644 Loop/Models/StoredDataAlgorithmInput.swift delete mode 100644 LoopTests/Managers/LoopAlgorithmTests.swift create mode 100644 LoopTests/Models/TempBasalRecommendationTests.swift diff --git a/Common/Extensions/SampleValue.swift b/Common/Extensions/SampleValue.swift index 39dcb16e9c..dd9c901ecf 100644 --- a/Common/Extensions/SampleValue.swift +++ b/Common/Extensions/SampleValue.swift @@ -7,7 +7,7 @@ import HealthKit import LoopKit - +import LoopAlgorithm extension Collection where Element == SampleValue { /// O(n) diff --git a/Common/Models/StatusExtensionContext.swift b/Common/Models/StatusExtensionContext.swift index bee1f32894..8f5f7634fb 100644 --- a/Common/Models/StatusExtensionContext.swift +++ b/Common/Models/StatusExtensionContext.swift @@ -11,6 +11,7 @@ import Foundation import HealthKit import LoopKit import LoopKitUI +import LoopAlgorithm struct NetBasalContext { diff --git a/Common/Models/WatchContext.swift b/Common/Models/WatchContext.swift index 3ce3adebf1..6d4e7a23a0 100644 --- a/Common/Models/WatchContext.swift +++ b/Common/Models/WatchContext.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm final class WatchContext: RawRepresentable { diff --git a/Common/Models/WatchHistoricalGlucose.swift b/Common/Models/WatchHistoricalGlucose.swift index 13fda34816..3b166170a9 100644 --- a/Common/Models/WatchHistoricalGlucose.swift +++ b/Common/Models/WatchHistoricalGlucose.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm struct WatchHistoricalGlucose { let samples: [StoredGlucoseSample] diff --git a/Common/Models/WatchPredictedGlucose.swift b/Common/Models/WatchPredictedGlucose.swift index 080a824074..8b32a45f01 100644 --- a/Common/Models/WatchPredictedGlucose.swift +++ b/Common/Models/WatchPredictedGlucose.swift @@ -9,6 +9,7 @@ import Foundation import LoopKit import HealthKit +import LoopAlgorithm struct WatchPredictedGlucose: Equatable { diff --git a/Learn/Managers/DataManager.swift b/Learn/Managers/DataManager.swift index 80e958a02f..3929c42bac 100644 --- a/Learn/Managers/DataManager.swift +++ b/Learn/Managers/DataManager.swift @@ -47,7 +47,6 @@ final class DataManager { healthStore: healthStore, cacheStore: cacheStore, observationEnabled: false, - insulinModelProvider: PresetInsulinModelProvider(defaultRapidActingModel: defaultRapidActingModel), longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, basalProfile: basalRateSchedule, insulinSensitivitySchedule: insulinSensitivitySchedule, diff --git a/Loop Status Extension/StatusViewController.swift b/Loop Status Extension/StatusViewController.swift index 16b9b64f10..21a4ada94b 100644 --- a/Loop Status Extension/StatusViewController.swift +++ b/Loop Status Extension/StatusViewController.swift @@ -15,6 +15,7 @@ import LoopUI import NotificationCenter import UIKit import SwiftCharts +import LoopAlgorithm class StatusViewController: UIViewController, NCWidgetProviding { @@ -91,7 +92,6 @@ class StatusViewController: UIViewController, NCWidgetProviding { 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, @@ -187,17 +187,6 @@ class StatusViewController: UIViewController, NCWidgetProviding { 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() diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift index d236427e7b..85c22c5649 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift @@ -10,6 +10,8 @@ import HealthKit import LoopCore import LoopKit import WidgetKit +import LoopAlgorithm + struct StatusWidgetTimelimeEntry: TimelineEntry { var date: Date diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift index 5dd3af7d29..f96f0dde62 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 diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 32667d60ba..69cfe11b5b 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -412,6 +412,7 @@ C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; C1201E2D23ECDF3D002DA84A /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; C129BF4A2B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */; }; + C129D3BF2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */; }; C13072BA2A76AF31009A7C58 /* live_capture_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */; }; C13255D6223E7BE2008AF50C /* BolusProgressTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */; }; C13DA2B024F6C7690098BB29 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13DA2AF24F6C7690098BB29 /* UIViewController.swift */; }; @@ -430,6 +431,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 */; }; @@ -474,7 +477,6 @@ 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 */; }; @@ -490,6 +492,8 @@ C1F00C60285A802A006302C5 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1F00C5F285A802A006302C5 /* SwiftCharts */; }; C1F00C78285A8256006302C5 /* SwiftCharts in Embed Frameworks */ = {isa = PBXBuildFile; productRef = C1F00C5F285A802A006302C5 /* SwiftCharts */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; C1F2075C26D6F9B0007AB7EB /* AppExpirationAlerter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */; }; + C1F2CAAA2B76B3EE00D7F581 /* TempBasalRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F2CAA92B76B3EE00D7F581 /* TempBasalRecommendation.swift */; }; + C1F2CAAC2B7A980600D7F581 /* BasalRelativeDose.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F2CAAB2B7A980600D7F581 /* BasalRelativeDose.swift */; }; C1F7822627CC056900C0919A /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F7822527CC056900C0919A /* SettingsManager.swift */; }; C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */; }; C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428B217806A300FAB378 /* StateColorPalette.swift */; }; @@ -1391,6 +1395,7 @@ C122DEFF29BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; C122DF0029BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryPresetsManagerTests.swift; sourceTree = ""; }; + C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempBasalRecommendationTests.swift; sourceTree = ""; }; C12BCCF929BBFA480066A158 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; C12CB9AC23106A3C00F84978 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Intents.strings; sourceTree = ""; }; C12CB9AE23106A5C00F84978 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Intents.strings; sourceTree = ""; }; @@ -1422,6 +1427,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 = ""; }; @@ -1527,7 +1534,6 @@ C1D0B62F2986D4D90098D215 /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; C1D197FE232CF92D0096D646 /* capture-build-details.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "capture-build-details.sh"; sourceTree = ""; }; C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalDeliveryState.swift; sourceTree = ""; }; - C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAlgorithmTests.swift; sourceTree = ""; }; C1D70F7A2A914F71009FE129 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; C1DA434E2B164C6C00CBD33F /* MockSettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSettingsProvider.swift; sourceTree = ""; }; C1DA43522B19310A00CBD33F /* LoopControlMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopControlMock.swift; sourceTree = ""; }; @@ -1557,6 +1563,8 @@ C1EE9E802A38D0FB0064784A /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = ""; }; C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashRecoveryManager.swift; sourceTree = ""; }; C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppExpirationAlerter.swift; sourceTree = ""; }; + C1F2CAA92B76B3EE00D7F581 /* TempBasalRecommendation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempBasalRecommendation.swift; sourceTree = ""; }; + C1F2CAAB2B7A980600D7F581 /* BasalRelativeDose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalRelativeDose.swift; sourceTree = ""; }; C1F48FF62995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F48FF72995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F48FF82995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; @@ -1820,7 +1828,6 @@ A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */, C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */, C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, - C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */, E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */, C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */, @@ -1912,11 +1919,12 @@ C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */, 4F526D601DF8D9A900A04910 /* NetBasal.swift */, 438D42F81D7C88BC003244B0 /* PredictionInputEffect.swift */, - A99A114029A581D6007919CE /* Remote */, C19008FD25225D3900721625 /* SimpleBolusCalculator.swift */, C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */, 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */, A987CD4824A58A0100439ADC /* ZipArchive.swift */, + C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, + C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, ); path = Models; sourceTree = ""; @@ -2139,6 +2147,7 @@ children = ( A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */, C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */, + C1F2CAAB2B7A980600D7F581 /* BasalRelativeDose.swift */, A9F703722489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift */, C17824991E1999FA00D9D25C /* CaseCountable.swift */, 4F6663931E905FD2009E74FC /* ChartColorPalette+Loop.swift */, @@ -2165,6 +2174,7 @@ 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */, A9CBE45B248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift */, C1FB428B217806A300FAB378 /* StateColorPalette.swift */, + C1F2CAA92B76B3EE00D7F581 /* TempBasalRecommendation.swift */, 43F89CA222BDFBBC006BB54E /* UIActivityIndicatorView.swift */, 43F41C361D3BF32400C11ED6 /* UIAlertController.swift */, A9F66FC2247F451500096EA7 /* UIDevice+Loop.swift */, @@ -2668,13 +2678,6 @@ path = Shortcuts; sourceTree = ""; }; - A99A114029A581D6007919CE /* Remote */ = { - isa = PBXGroup; - children = ( - ); - path = Remote; - sourceTree = ""; - }; A9E6DFED246A0460005B1A1C /* Models */ = { isa = PBXGroup; children = ( @@ -2684,6 +2687,7 @@ C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */, A9C1719625366F780053BCBD /* WatchHistoricalGlucoseTest.swift */, A9BD28E6272226B40071DF15 /* TestLocalizedError.swift */, + C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */, ); path = Models; sourceTree = ""; @@ -3545,6 +3549,7 @@ C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */, 1D080CBD2473214A00356610 /* AlertStore.xcdatamodeld in Sources */, C11BD0552523CFED00236B08 /* SimpleBolusViewModel.swift in Sources */, + C1F2CAAA2B76B3EE00D7F581 /* TempBasalRecommendation.swift in Sources */, 149A28BB2A853E5100052EDF /* CarbEntryViewModel.swift in Sources */, C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, @@ -3562,6 +3567,7 @@ 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */, A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, + C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */, 1452F4AB2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift in Sources */, C1742332259BEADC00399C9D /* ManualEntryDoseView.swift in Sources */, 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */, @@ -3605,6 +3611,7 @@ E9C00EF524C623EF00628F35 /* LoopSettings+Loop.swift in Sources */, 4389916B1E91B689000EEF90 /* ChartSettings+Loop.swift in Sources */, C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, + C1F2CAAC2B7A980600D7F581 /* BasalRelativeDose.swift in Sources */, B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */, 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */, DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, @@ -3641,6 +3648,7 @@ B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */, 84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */, 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, + C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */, 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */, E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, @@ -3860,7 +3868,6 @@ E93E86A824DDCC4400FF40C8 /* MockDoseStore.swift in Sources */, B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */, E98A55F124EDD85E0008715D /* MockDosingDecisionStore.swift in Sources */, - C1D476B42A8ED179002C1C87 /* LoopAlgorithmTests.swift in Sources */, A9BD28E7272226B40071DF15 /* TestLocalizedError.swift in Sources */, A9F5F1F5251050EC00E7C8A4 /* ZipArchiveTests.swift in Sources */, E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */, @@ -3883,6 +3890,7 @@ 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 */, @@ -4879,7 +4887,7 @@ TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; WARNING_CFLAGS = "-Wall"; - WATCHOS_DEPLOYMENT_TARGET = 7.1; + WATCHOS_DEPLOYMENT_TARGET = 8.0; }; name = Debug; }; @@ -4989,7 +4997,7 @@ VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; WARNING_CFLAGS = "-Wall"; - WATCHOS_DEPLOYMENT_TARGET = 7.1; + WATCHOS_DEPLOYMENT_TARGET = 8.0; }; name = Release; }; @@ -5485,7 +5493,7 @@ TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; WARNING_CFLAGS = "-Wall"; - WATCHOS_DEPLOYMENT_TARGET = 7.1; + WATCHOS_DEPLOYMENT_TARGET = 8.0; }; name = Testflight; }; diff --git a/Loop/Extensions/BasalDeliveryState.swift b/Loop/Extensions/BasalDeliveryState.swift index 6b53f06e2b..32cf77c930 100644 --- a/Loop/Extensions/BasalDeliveryState.swift +++ b/Loop/Extensions/BasalDeliveryState.swift @@ -8,6 +8,7 @@ import LoopKit import LoopCore +import LoopAlgorithm extension PumpManagerStatus.BasalDeliveryState { func getNetBasal(basalSchedule: BasalRateSchedule, maximumBasalRatePerHour: Double?) -> NetBasal? { 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/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/DosingDecisionStore+SimulatedCoreData.swift b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift index 94627cfdd1..ae4b0c05bc 100644 --- a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm // MARK: - Simulated Core Data @@ -168,7 +169,7 @@ fileprivate extension StoredDosingDecision { duration: .minutes(30)), bolusUnits: 1.25) let manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 0.2, - notice: .predictedGlucoseBelowTarget(minGlucose: PredictedGlucoseValue(startDate: date.addingTimeInterval(.minutes(30)), + notice: .predictedGlucoseBelowTarget(minGlucose: SimpleGlucoseValue(startDate: date.addingTimeInterval(.minutes(30)), quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 95.0)))), date: date.addingTimeInterval(-.minutes(1))) let manualBolusRequested = 0.5 diff --git a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift index 5fbcd152f6..e633401d0d 100644 --- a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift +++ b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm // MARK: - Simulated Core Data diff --git a/Loop/Extensions/TempBasalRecommendation.swift b/Loop/Extensions/TempBasalRecommendation.swift new file mode 100644 index 0000000000..8d60a52687 --- /dev/null +++ b/Loop/Extensions/TempBasalRecommendation.swift @@ -0,0 +1,67 @@ +// +// TempBasalRecommendation.swift +// Loop +// +// Created by Pete Schwamb on 2/9/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopAlgorithm + +extension TempBasalRecommendation { + /// Equates the recommended rate with another rate + /// + /// - Parameter unitsPerHour: The rate to compare + /// - Returns: Whether the rates are equal within Double precision + private func matchesRate(_ unitsPerHour: Double) -> Bool { + return abs(self.unitsPerHour - unitsPerHour) < .ulpOfOne + } + + /// Adjusts a recommendation based on the current state of pump delivery. If the current temp basal matches + /// the recommendation, and enough time is remaining, then recommend no action. If we are running a temp basal + /// and the new rate matches the scheduled rate, then cancel the currently running temp basal. If the current scheduled + /// rate matches the recommended rate, then recommend no action. Otherwise, set a new temp basal of the + /// recommended rate. + /// + /// - Parameters: + /// - date: The date the recommendation would be delivered + /// - neutralBasalRate: The scheduled basal rate at `date` + /// - lastTempBasal: The previously set temp basal + /// - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command + /// - neutralBasalRateMatchesPump: A flag describing whether `neutralBasalRate` matches the scheduled basal rate of the pump. + /// If `false` and the recommendation matches `neutralBasalRate`, the temp will be recommended + /// at the scheduled basal rate rather than recommending no temp. + /// - Returns: A temp basal recommendation + func adjustForCurrentDelivery( + at date: Date, + neutralBasalRate: Double, + currentTempBasal: DoseEntry?, + continuationInterval: TimeInterval, + neutralBasalRateMatchesPump: Bool + ) -> TempBasalRecommendation? { + // Adjust behavior for the currently active temp basal + if let currentTempBasal, currentTempBasal.type == .tempBasal, currentTempBasal.endDate > date + { + /// If the last temp basal has the same rate, and has more than `continuationInterval` of time remaining, don't set a new temp + if matchesRate(currentTempBasal.unitsPerHour), + currentTempBasal.endDate.timeIntervalSince(date) > continuationInterval { + return nil + } else if matchesRate(neutralBasalRate), neutralBasalRateMatchesPump { + // If our new temp matches the scheduled rate of the pump, cancel the current temp + return .cancel + } + } else if matchesRate(neutralBasalRate), neutralBasalRateMatchesPump { + // If we recommend the in-progress scheduled basal rate of the pump, do nothing + return nil + } + + return self + } + + public static var cancel: TempBasalRecommendation { + return self.init(unitsPerHour: 0, duration: 0) + } +} + diff --git a/Loop/Extensions/UserDefaults+Loop.swift b/Loop/Extensions/UserDefaults+Loop.swift index 4894dcc777..a663c1e8a4 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 { diff --git a/Loop/Managers/AppExpirationAlerter.swift b/Loop/Managers/AppExpirationAlerter.swift index e7768f92b6..054f50a5a4 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) - return false - #endif +#if targetEnvironment(simulator) + return false +#else // If an "embedded.mobileprovision" is present in the main bundle, then // this is an Xcode, Ad-Hoc, or Enterprise distribution. Return false. @@ -143,6 +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 } static func calculateExpirationDate(profileExpiration: Date) -> Date { diff --git a/Loop/Managers/CGMStalenessMonitor.swift b/Loop/Managers/CGMStalenessMonitor.swift index ad54f9d1eb..60fe0d06b2 100644 --- a/Loop/Managers/CGMStalenessMonitor.swift +++ b/Loop/Managers/CGMStalenessMonitor.swift @@ -9,6 +9,7 @@ import Foundation import LoopKit import LoopCore +import LoopAlgorithm protocol CGMStalenessMonitorDelegate: AnyObject { func getLatestCGMGlucose(since: Date, completion: @escaping (_ result: Swift.Result) -> Void) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 67746212f3..149d691be9 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -13,6 +13,7 @@ import LoopCore import LoopTestingKit import UserNotifications import Combine +import LoopAlgorithm protocol LoopControl { var lastLoopCompleted: Date? { get } @@ -1397,7 +1398,7 @@ extension DeviceDataManager: DeliveryDelegate { return pumpManager.roundToSupportedBolusVolume(units: units) } - var pumpInsulinType: LoopKit.InsulinType? { + var pumpInsulinType: InsulinType? { return pumpManager?.status.insulinType } @@ -1405,7 +1406,7 @@ extension DeviceDataManager: DeliveryDelegate { return pumpManager?.status.basalDeliveryState?.isSuspended ?? false } - func enact(_ recommendation: LoopKit.AutomaticDoseRecommendation) async throws { + func enact(_ recommendation: AutomaticDoseRecommendation) async throws { guard let pumpManager = pumpManager else { throw LoopError.configurationError(.pumpManager) } diff --git a/Loop/Managers/DoseEnactor.swift b/Loop/Managers/DoseEnactor.swift index fc533d6219..6777802a5d 100644 --- a/Loop/Managers/DoseEnactor.swift +++ b/Loop/Managers/DoseEnactor.swift @@ -8,6 +8,7 @@ import Foundation import LoopKit +import LoopAlgorithm class DoseEnactor { diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 2b026a384a..80ea2f046e 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -15,7 +15,7 @@ import MockKit import HealthKit import WidgetKit import LoopCore - +import LoopAlgorithm #if targetEnvironment(simulator) enum SimulatorError: Error { @@ -228,8 +228,6 @@ class LoopAppManager: NSObject { observationStart: Date().addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) ) - let absorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes - temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsManager) temporaryPresetsManager.overrideHistory.delegate = self @@ -239,9 +237,7 @@ class LoopAppManager: NSObject { self.carbStore = CarbStore( healthKitSampleStore: carbHealthStore, cacheStore: cacheStore, - cacheLength: localCacheDuration, - defaultAbsorptionTimes: absorptionTimes, - carbAbsorptionModel: FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + cacheLength: localCacheDuration ) let insulinHealthStore = HealthKitSampleStore( @@ -251,19 +247,10 @@ class LoopAppManager: NSObject { observationStart: Date().addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) ) - let insulinModelProvider: InsulinModelProvider - - if FeatureFlags.adultChildInsulinModelSelectionEnabled { - insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: settingsManager.settings.defaultRapidActingModel?.presetForRapidActingInsulin) - } else { - insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) - } - self.doseStore = DoseStore( healthKitSampleStore: insulinHealthStore, cacheStore: cacheStore, cacheLength: localCacheDuration, - insulinModelProvider: insulinModelProvider, longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, basalProfile: settingsManager.settings.basalRateSchedule, lastPumpEventsReconciliation: nil // PumpManager is nil at this point. Will update this via addPumpEvents below diff --git a/Loop/Managers/LoopDataManager+CarbAbsorption.swift b/Loop/Managers/LoopDataManager+CarbAbsorption.swift index 2d3053f08d..fc29c06e8a 100644 --- a/Loop/Managers/LoopDataManager+CarbAbsorption.swift +++ b/Loop/Managers/LoopDataManager+CarbAbsorption.swift @@ -9,6 +9,7 @@ import Foundation import LoopKit import HealthKit +import LoopAlgorithm struct CarbAbsorptionReview { var carbEntries: [StoredCarbEntry] @@ -33,7 +34,7 @@ extension LoopDataManager { let doses = try await doseStore.getDoses( start: dosesStart, end: end - ) + ).map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) } dosesStart = doses.map { $0.startDate }.min() ?? dosesStart @@ -82,10 +83,7 @@ extension LoopDataManager { // Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal let annotatedDoses = doses.annotated(with: basal) - let insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) - let insulinEffects = annotatedDoses.glucoseEffects( - insulinModelProvider: insulinModelProvider, insulinSensitivityHistory: sensitivity, from: start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval).dateFlooredToTimeInterval(GlucoseMath.defaultDelta), to: nil) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 003ef8a668..0715f7e522 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -13,10 +13,12 @@ import LoopKit import LoopKitUI import LoopCore import WidgetKit +import LoopAlgorithm + struct AlgorithmDisplayState { - var input: LoopAlgorithmInput? - var output: LoopAlgorithmOutput? + var input: StoredDataAlgorithmInput? + var output: AlgorithmOutput? var activeInsulin: InsulinValue? { guard let input, let value = output?.activeInsulin else { @@ -32,7 +34,7 @@ struct AlgorithmDisplayState { return CarbValue(startDate: input.predictionStart, value: value) } - var asTuple: (algoInput: LoopAlgorithmInput?, algoOutput: LoopAlgorithmOutput?) { + var asTuple: (algoInput: StoredDataAlgorithmInput?, algoOutput: AlgorithmOutput?) { return (algoInput: input, algoOutput: output) } } @@ -49,6 +51,17 @@ protocol DeliveryDelegate: AnyObject { func roundBolusVolume(units: Double) -> Double } +extension PumpManagerStatus.BasalDeliveryState { + var currentTempBasal: DoseEntry? { + switch self { + case .tempBasal(let dose): + return dose + default: + return nil + } + } +} + protocol DosingManagerDelegate { func didMakeDosingDecision(_ decision: StoredDosingDecision) } @@ -249,7 +262,23 @@ final class LoopDataManager { } } - func fetchData(for baseTime: Date = Date(), disablingPreMeal: Bool = false) async throws -> LoopAlgorithmInput { + 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 + } + } + + func fetchData( + for baseTime: Date = Date(), + disablingPreMeal: Bool = false + ) async throws -> StoredDataAlgorithmInput { // Need to fetch doses back as far as t - (DIA + DCA) for Dynamic carbs let dosesInputHistory = CarbMath.maximumAbsorptionTimeInterval + InsulinMath.defaultInsulinActivityDuration @@ -367,11 +396,11 @@ final class LoopDataManager { effectiveBolusApplicationFactor = nil } - return LoopAlgorithmInput( - predictionStart: baseTime, + return StoredDataAlgorithmInput( glucoseHistory: glucose, - doses: doses, + doses: doses.map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) }, carbEntries: carbEntries, + predictionStart: baseTime, basal: basalWithOverrides, sensitivity: sensitivityWithOverrides, carbRatio: carbRatioWithOverrides, @@ -380,11 +409,11 @@ final class LoopDataManager { maxBolus: maxBolus, maxBasalRate: maxBasalRate, useIntegralRetrospectiveCorrection: UserDefaults.standard.integralRetrospectiveCorrectionEnabled, + includePositiveVelocityAndRC: true, carbAbsorptionModel: carbAbsorptionModel, - recommendationInsulinType: deliveryDelegate?.pumpInsulinType ?? .novolog, + recommendationInsulinModel: insulinModel(for: deliveryDelegate?.pumpInsulinType ?? .novolog), recommendationType: .manualBolus, - automaticBolusApplicationFactor: effectiveBolusApplicationFactor - ) + automaticBolusApplicationFactor: effectiveBolusApplicationFactor) } func loopingReEnabled() async { @@ -451,7 +480,8 @@ final class LoopDataManager { var input = try await fetchData(for: loopBaseTime) - let startDate = input.predictionStart + // Trim future basal + input.doses = input.doses.trimmed(to: loopBaseTime) let dosingStrategy = settingsProvider.settings.automaticDosingStrategy input.recommendationType = dosingStrategy.recommendationType @@ -460,15 +490,15 @@ final class LoopDataManager { throw LoopError.missingDataError(.glucose) } - guard startDate.timeIntervalSince(latestGlucose.startDate) <= LoopAlgorithm.inputDataRecencyInterval else { + guard loopBaseTime.timeIntervalSince(latestGlucose.startDate) <= LoopAlgorithm.inputDataRecencyInterval else { throw LoopError.glucoseTooOld(date: latestGlucose.startDate) } - guard latestGlucose.startDate.timeIntervalSince(startDate) <= LoopAlgorithm.inputDataRecencyInterval else { + guard latestGlucose.startDate.timeIntervalSince(loopBaseTime) <= LoopAlgorithm.inputDataRecencyInterval else { throw LoopError.invalidFutureGlucose(date: latestGlucose.startDate) } - guard startDate.timeIntervalSince(doseStore.lastAddedPumpData) <= LoopAlgorithm.inputDataRecencyInterval else { + guard loopBaseTime.timeIntervalSince(doseStore.lastAddedPumpData) <= LoopAlgorithm.inputDataRecencyInterval else { throw LoopError.pumpDataTooOld(date: doseStore.lastAddedPumpData) } @@ -491,14 +521,13 @@ final class LoopDataManager { if var basal = algoRecommendation.basalAdjustment { basal.unitsPerHour = deliveryDelegate.roundBasalRate(unitsPerHour: basal.unitsPerHour) - let lastTempBasal = input.doses.first { $0.type == .tempBasal && $0.startDate < input.predictionStart && $0.endDate > input.predictionStart } let scheduledBasalRate = input.basal.closestPrior(to: loopBaseTime)!.value let activeOverride = temporaryPresetsManager.overrideHistory.activeOverride(at: loopBaseTime) - let basalAdjustment = basal.ifNecessary( + let basalAdjustment = basal.adjustForCurrentDelivery( at: loopBaseTime, neutralBasalRate: scheduledBasalRate, - lastTempBasal: lastTempBasal, + currentTempBasal: deliveryDelegate.basalDeliveryState?.currentTempBasal, continuationInterval: .minutes(11), neutralBasalRateMatchesPump: activeOverride == nil ) @@ -555,9 +584,9 @@ final class LoopDataManager { ) async throws -> ManualBolusRecommendation? { var input = try await self.fetchData(for: now(), disablingPreMeal: potentialCarbEntry != nil) - .addingGlucoseSample(sample: manualGlucoseSample) + .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseStample) .removingCarbEntry(carbEntry: originalCarbEntry) - .addingCarbEntry(carbEntry: potentialCarbEntry) + .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) input.includePositiveVelocityAndRC = usePositiveMomentumAndRCForManualBoluses input.recommendationType = .manualBolus @@ -573,10 +602,10 @@ final class LoopDataManager { } var iobValues: [InsulinValue] { - dosesRelativeToBasal.insulinOnBoard() + dosesRelativeToBasal.insulinOnBoardTimeline() } - var dosesRelativeToBasal: [DoseEntry] { + var dosesRelativeToBasal: [BasalRelativeDose] { displayState.output?.dosesRelativeToBasal ?? [] } @@ -714,10 +743,10 @@ extension LoopDataManager { /// 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 = LoopAlgorithm.insulinModelProvider.model(for: insulinType).effectDuration + let insulinEffectDuration = insulinModel(for: insulinType).effectDuration let endSuspend = startSuspend.addingTimeInterval(insulinEffectDuration) - var suspendDoses: [DoseEntry] = [] + var suspendDoses: [BasalRelativeDose] = [] let basal = try await settingsProvider.getBasalHistory(startDate: startSuspend, endDate: endSuspend) let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: startSuspend, endDate: endSuspend) @@ -743,14 +772,18 @@ extension LoopDataManager { endSuspendDoseDate = basal[index + 1].startDate } - let suspendDose = DoseEntry(type: .tempBasal, startDate: startSuspendDoseDate, endDate: endSuspendDoseDate, value: -basalItem.value, unit: DoseUnit.unitsPerHour) + let suspendDose = BasalRelativeDose( + type: .basal(scheduledRate: basalItem.value), + startDate: startSuspendDoseDate, + endDate: endSuspendDoseDate, + volume: 0 + ) suspendDoses.append(suspendDose) } // Calculate predicted glucose effect of suspending insulin delivery return suspendDoses.glucoseEffects( - insulinModelProvider: LoopAlgorithm.insulinModelProvider, insulinSensitivityHistory: sensitivity ).filterDateRange(startSuspend, endSuspend) } @@ -836,9 +869,9 @@ extension NewGlucoseSample { } -extension LoopAlgorithmInput { +extension StoredDataAlgorithmInput { - func addingDose(dose: DoseEntry?) -> LoopAlgorithmInput { + func addingDose(dose: InsulinDoseType?) -> StoredDataAlgorithmInput { var rval = self if let dose { rval.doses = doses + [dose] @@ -846,23 +879,23 @@ extension LoopAlgorithmInput { return rval } - func addingGlucoseSample(sample: NewGlucoseSample?) -> LoopAlgorithmInput { + func addingGlucoseSample(sample: GlucoseType?) -> StoredDataAlgorithmInput { var rval = self if let sample { - rval.glucoseHistory.append(sample.asStoredGlucoseStample) + rval.glucoseHistory.append(sample) } return rval } - func addingCarbEntry(carbEntry: NewCarbEntry?) -> LoopAlgorithmInput { + func addingCarbEntry(carbEntry: CarbType?) -> StoredDataAlgorithmInput { var rval = self if let carbEntry { - rval.carbEntries = carbEntries + [carbEntry.asStoredCarbEntry] + rval.carbEntries = carbEntries + [carbEntry] } return rval } - func removingCarbEntry(carbEntry: StoredCarbEntry?) -> LoopAlgorithmInput { + func removingCarbEntry(carbEntry: CarbType?) -> StoredDataAlgorithmInput { guard let carbEntry else { return self } @@ -978,7 +1011,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) } @@ -1043,7 +1076,7 @@ extension LoopDataManager: ServicesManagerDelegate { extension LoopDataManager: SimpleBolusViewModelDelegate { - func insulinOnBoard(at date: Date) async -> LoopKit.InsulinValue? { + func insulinOnBoard(at date: Date) async -> InsulinValue? { displayState.activeInsulin } @@ -1083,7 +1116,7 @@ extension LoopDataManager: BolusEntryViewModelDelegate { temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: presumingMealEntry) } - func generatePrediction(input: LoopAlgorithmInput) throws -> [PredictedGlucoseValue] { + func generatePrediction(input: StoredDataAlgorithmInput) throws -> [PredictedGlucoseValue] { try input.predictGlucose() } } @@ -1094,8 +1127,8 @@ extension LoopDataManager: CarbEntryViewModelDelegate { temporaryPresetsManager.scheduleOverrideEnabled(at: date) } - var defaultAbsorptionTimes: LoopKit.CarbStore.DefaultAbsorptionTimes { - carbStore.defaultAbsorptionTimes + var defaultAbsorptionTimes: DefaultAbsorptionTimes { + LoopCoreConstants.defaultCarbAbsorptionTimes } } @@ -1114,7 +1147,7 @@ extension LoopDataManager: ManualDoseViewModelDelegate { } func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { - return LoopAlgorithm.insulinModelProvider.model(for: type).effectDuration + return insulinModel(for: type).effectDuration } var algorithmDisplayState: AlgorithmDisplayState { @@ -1134,8 +1167,14 @@ extension AutomaticDosingStrategy { } } +extension AutomaticDoseRecommendation { + public var hasDosingChange: Bool { + return basalAdjustment != nil || bolusUnits != nil + } +} + extension StoredDosingDecision { - mutating func updateFrom(input: LoopAlgorithmInput, output: LoopAlgorithmOutput) { + 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): diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift index bf000d3e95..e014a4332d 100644 --- a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -12,6 +12,7 @@ import OSLog import LoopCore import LoopKit import Combine +import LoopAlgorithm enum MissedMealStatus: Equatable { case hasMissedMeal(startTime: Date, carbAmount: Double) @@ -389,3 +390,27 @@ extension BolusStateProvider { } } +extension GlucoseEffectVelocity { + /// The integration of the velocity span from `start` to `end` + public func effect(from start: Date, to end: Date) -> GlucoseEffect? { + guard + start <= end, + startDate <= start, + end <= endDate + else { + return nil + } + + let duration = end.timeIntervalSince(start) + let velocityPerSecond = quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit) + + return GlucoseEffect( + startDate: end, + quantity: HKQuantity( + unit: .milligramsPerDeciliter, + doubleValue: velocityPerSecond * duration + ) + ) + } +} + diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index cbac8f6b2d..f564e7a7d6 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -15,6 +15,7 @@ import Combine import LoopCore import LoopKitUI import os.log +import LoopAlgorithm protocol DeviceStatusProvider { diff --git a/Loop/Managers/StatusChartsManager.swift b/Loop/Managers/StatusChartsManager.swift index 79ec51ad62..f047a27575 100644 --- a/Loop/Managers/StatusChartsManager.swift +++ b/Loop/Managers/StatusChartsManager.swift @@ -10,6 +10,7 @@ import LoopKit import LoopUI import LoopKitUI import SwiftCharts +import LoopAlgorithm class StatusChartsManager: ChartsManager { @@ -115,7 +116,7 @@ extension StatusChartsManager { extension StatusChartsManager { - func setDoseEntries(_ doseEntries: [DoseEntry]) { + func setDoseEntries(_ doseEntries: [BasalRelativeDose]) { dose.doseEntries = doseEntries invalidateChart(atIndex: ChartIndex.dose.rawValue) } diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index bf41a4d3fd..0904631016 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -19,8 +19,6 @@ protocol CarbStoreProtocol: AnyObject { func deleteCarbEntry(_ oldEntry: StoredCarbEntry) async throws -> Bool - var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes { get } - } extension CarbStore: CarbStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift index 3bd2bcbdbb..29eb70b7ea 100644 --- a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift @@ -8,6 +8,7 @@ import LoopKit import HealthKit +import LoopAlgorithm protocol DoseStoreProtocol: AnyObject { func getDoses(start: Date?, end: Date?) async throws -> [DoseEntry] diff --git a/Loop/Models/BolusDosingDecision.swift b/Loop/Models/BolusDosingDecision.swift index 9d63905858..4d3002d2ba 100644 --- a/Loop/Models/BolusDosingDecision.swift +++ b/Loop/Models/BolusDosingDecision.swift @@ -7,6 +7,7 @@ // import LoopKit +import LoopAlgorithm struct BolusDosingDecision { enum Reason: String { diff --git a/Loop/Models/ConstantApplicationFactorStrategy.swift b/Loop/Models/ConstantApplicationFactorStrategy.swift index 0ef8dc1d13..82ab6ebad6 100644 --- a/Loop/Models/ConstantApplicationFactorStrategy.swift +++ b/Loop/Models/ConstantApplicationFactorStrategy.swift @@ -10,6 +10,7 @@ import Foundation import HealthKit import LoopKit import LoopCore +import LoopAlgorithm struct ConstantApplicationFactorStrategy: ApplicationFactorStrategy { func calculateDosingFactor( diff --git a/Loop/Models/CrashRecoveryManager.swift b/Loop/Models/CrashRecoveryManager.swift index e0f0e6f260..2e2a249e9c 100644 --- a/Loop/Models/CrashRecoveryManager.swift +++ b/Loop/Models/CrashRecoveryManager.swift @@ -8,6 +8,7 @@ import Foundation import LoopKit +import LoopAlgorithm class CrashRecoveryManager { diff --git a/Loop/Models/GlucoseEffectVelocity.swift b/Loop/Models/GlucoseEffectVelocity.swift index 9557f2fd50..6680073769 100644 --- a/Loop/Models/GlucoseEffectVelocity.swift +++ b/Loop/Models/GlucoseEffectVelocity.swift @@ -7,6 +7,7 @@ import HealthKit import LoopKit +import LoopAlgorithm extension GlucoseEffectVelocity: RawRepresentable { diff --git a/Loop/Models/ManualBolusRecommendation.swift b/Loop/Models/ManualBolusRecommendation.swift index d176b77cf8..1753813e2c 100644 --- a/Loop/Models/ManualBolusRecommendation.swift +++ b/Loop/Models/ManualBolusRecommendation.swift @@ -9,6 +9,7 @@ import Foundation import LoopKit import HealthKit +import LoopAlgorithm extension BolusRecommendationNotice { @@ -37,28 +38,3 @@ extension BolusRecommendationNotice { } } -extension BolusRecommendationNotice: Equatable { - public static func ==(lhs: BolusRecommendationNotice, rhs: BolusRecommendationNotice) -> Bool { - switch (lhs, rhs) { - case (.glucoseBelowSuspendThreshold, .glucoseBelowSuspendThreshold): - return true - - case (.currentGlucoseBelowTarget, .currentGlucoseBelowTarget): - return true - - case (let .predictedGlucoseBelowTarget(minGlucose1), let .predictedGlucoseBelowTarget(minGlucose2)): - // GlucoseValue is not equatable - return - minGlucose1.startDate == minGlucose2.startDate && - minGlucose1.endDate == minGlucose2.endDate && - minGlucose1.quantity == minGlucose2.quantity - - case (.predictedGlucoseInRange, .predictedGlucoseInRange): - return true - - default: - return false - } - } -} - 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 164db3a234..175afd3c1b 100644 --- a/Loop/Models/PredictionInputEffect.swift +++ b/Loop/Models/PredictionInputEffect.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm struct PredictionInputEffect: OptionSet { let rawValue: Int diff --git a/Loop/Models/SimpleInsulinDose.swift b/Loop/Models/SimpleInsulinDose.swift new file mode 100644 index 0000000000..6235d69768 --- /dev/null +++ b/Loop/Models/SimpleInsulinDose.swift @@ -0,0 +1,86 @@ +// +// SimpleInsulinDose.swift +// Loop +// +// Created by Pete Schwamb on 2/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopAlgorithm + +// Implements the bare minimum of InsulinDose, including a slot for InsulinModel +// We could use DoseEntry, but we need to dynamically lookup user's preferred +// fast acting insulin model in settings. So until that is removed, we need this. +struct SimpleInsulinDose: InsulinDose { + var deliveryType: InsulinDeliveryType + var startDate: Date + var endDate: Date + var volume: Double + var insulinModel: InsulinModel +} + +extension DoseEntry { + public var deliveryType: InsulinDeliveryType { + switch self.type { + case .bolus: + return .bolus + default: + return .basal + } + } + + public var volume: Double { + return deliveredUnits ?? programmedUnits + } + + func simpleDose(with model: InsulinModel) -> SimpleInsulinDose { + SimpleInsulinDose( + deliveryType: deliveryType, + startDate: startDate, + endDate: endDate, + volume: volume, + insulinModel: model + ) + } +} + +extension Array where Element == SimpleInsulinDose { + func trimmed(to end: Date? = nil) -> [SimpleInsulinDose] { + return self.compactMap { (dose) -> SimpleInsulinDose? in + if let end, dose.startDate > end { + return nil + } + if dose.deliveryType == .bolus { + return dose + } + return dose.trimmed(to: end) + } + } +} + +extension SimpleInsulinDose { + public func trimmed(from start: Date? = nil, to end: Date? = nil, syncIdentifier: String? = nil) -> SimpleInsulinDose { + + let originalDuration = endDate.timeIntervalSince(startDate) + + let startDate = max(start ?? .distantPast, self.startDate) + let endDate = max(startDate, min(end ?? .distantFuture, self.endDate)) + + var trimmedVolume: Double = volume + + if originalDuration > .ulpOfOne && (startDate > self.startDate || endDate < self.endDate) { + trimmedVolume = volume * (endDate.timeIntervalSince(startDate) / originalDuration) + } + + return SimpleInsulinDose( + deliveryType: self.deliveryType, + startDate: startDate, + endDate: endDate, + volume: trimmedVolume, + insulinModel: insulinModel + ) + } +} + diff --git a/Loop/Models/StoredDataAlgorithmInput.swift b/Loop/Models/StoredDataAlgorithmInput.swift new file mode 100644 index 0000000000..321614a99c --- /dev/null +++ b/Loop/Models/StoredDataAlgorithmInput.swift @@ -0,0 +1,54 @@ +// +// StoredDataAlgorithmInput.swift +// Loop +// +// Created by Pete Schwamb on 2/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import HealthKit +import LoopAlgorithm + +struct StoredDataAlgorithmInput: AlgorithmInput { + typealias CarbType = StoredCarbEntry + + typealias GlucoseType = StoredGlucoseSample + + typealias InsulinDoseType = SimpleInsulinDose + + var glucoseHistory: [StoredGlucoseSample] + + var doses: [SimpleInsulinDose] + + var carbEntries: [StoredCarbEntry] + + var predictionStart: Date + + var basal: [AbsoluteScheduleValue] + + var sensitivity: [AbsoluteScheduleValue] + + var carbRatio: [AbsoluteScheduleValue] + + var target: GlucoseRangeTimeline + + var suspendThreshold: HKQuantity? + + var maxBolus: Double + + var maxBasalRate: Double + + var useIntegralRetrospectiveCorrection: Bool + + var includePositiveVelocityAndRC: Bool + + var carbAbsorptionModel: CarbAbsorptionModel + + var recommendationInsulinModel: InsulinModel + + var recommendationType: DoseRecommendationType + + var automaticBolusApplicationFactor: Double? +} diff --git a/Loop/Models/WatchContext+LoopKit.swift b/Loop/Models/WatchContext+LoopKit.swift index a9adf41da4..c97c316a00 100644 --- a/Loop/Models/WatchContext+LoopKit.swift +++ b/Loop/Models/WatchContext+LoopKit.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm extension WatchContext { convenience init(glucose: GlucoseSampleValue?, glucoseUnit: HKUnit?) { diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index 378617b680..e17ca700d6 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -13,6 +13,7 @@ import LoopKit import LoopKitUI import LoopUI import os.log +import LoopAlgorithm private extension RefreshContext { @@ -147,7 +148,7 @@ 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: -carbStore.maximumAbsorptionTimeInterval)) + let listStart = min(midnight, chartStartDate, Date(timeIntervalSinceNow: -CarbMath.maximumAbsorptionTimeInterval)) let shouldUpdateGlucose = currentContext.contains(.glucose) let shouldUpdateCarbs = currentContext.contains(.carbs) @@ -344,7 +345,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 diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index 1f48cb0c88..849e7e22d7 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -13,6 +13,7 @@ import LoopKitUI import LoopUI import UIKit import os.log +import LoopAlgorithm private extension RefreshContext { @@ -125,7 +126,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable } self.retrospectiveGlucoseDiscrepancies = algoOutput?.effects.retrospectiveGlucoseDiscrepancies - totalRetrospectiveCorrection = algoOutput?.effects.totalGlucoseCorrectionEffect + totalRetrospectiveCorrection = algoOutput?.effects.totalRetrospectiveCorrectionEffect self.glucoseChart.setPredictedGlucoseValues(algoOutput?.predictedGlucose ?? []) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 41935ed1f2..4ffe792d4c 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -19,6 +19,7 @@ import SwiftCharts import os.log import Combine import WidgetKit +import LoopAlgorithm private extension RefreshContext { @@ -436,7 +437,7 @@ final class StatusTableViewController: LoopChartsTableViewController { var glucoseSamples: [StoredGlucoseSample]? var predictedGlucoseValues: [GlucoseValue]? var iobValues: [InsulinValue]? - var doseEntries: [DoseEntry]? + var doseEntries: [BasalRelativeDose]? var totalDelivery: Double? var cobValues: [CarbValue]? var carbsOnBoard: HKQuantity? @@ -488,7 +489,7 @@ final class StatusTableViewController: LoopChartsTableViewController { if currentContext.contains(.insulin) { doseEntries = loopManager.dosesRelativeToBasal.trimmed(from: startDate) - iobValues = loopManager.iobValues.trimmed(from: startDate) + iobValues = loopManager.iobValues.filterDateRange(startDate, nil) totalDelivery = try? await loopManager.doseStore.getTotalUnitsDelivered(since: Calendar.current.startOfDay(for: Date())).value } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 38532c6495..98d248fbee 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -17,25 +17,26 @@ import LoopKitUI import LoopUI import SwiftUI import SwiftCharts +import LoopAlgorithm protocol BolusEntryViewModelDelegate: AnyObject { var settings: StoredSettings { get } var scheduleOverride: TemporaryScheduleOverride? { get } var preMealOverride: TemporaryScheduleOverride? { get } - var pumpInsulinType: InsulinType? { get } var mostRecentGlucoseDataDate: Date? { get } var mostRecentPumpDataDate: Date? { get } - func fetchData(for baseTime: Date, disablingPreMeal: Bool) async throws -> LoopAlgorithmInput + func fetchData(for baseTime: Date, disablingPreMeal: Bool) async throws -> StoredDataAlgorithmInput func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? - 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, activationType: BolusActivationType) async throws + func insulinModel(for type: InsulinType?) -> InsulinModel + func recommendManualBolus( manualGlucoseSample: NewGlucoseSample?, potentialCarbEntry: NewCarbEntry?, @@ -43,7 +44,7 @@ protocol BolusEntryViewModelDelegate: AnyObject { ) async throws -> ManualBolusRecommendation? - func generatePrediction(input: LoopAlgorithmInput) throws -> [PredictedGlucoseValue] + func generatePrediction(input: StoredDataAlgorithmInput) throws -> [PredictedGlucoseValue] var activeInsulin: InsulinValue? { get } var activeCarbs: CarbValue? { get } @@ -518,13 +519,14 @@ final class BolusEntryViewModel: ObservableObject { let startDate = now() var input = try await delegate.fetchData(for: startDate, disablingPreMeal: potentialCarbEntry != nil) - let enteredBolusDose = DoseEntry( - type: .bolus, + var insulinModel = delegate.insulinModel(for: deliveryDelegate?.pumpInsulinType) + + let enteredBolusDose = SimpleInsulinDose( + deliveryType: .bolus, startDate: startDate, - value: enteredBolus.doubleValue(for: .internationalUnit()), - unit: .units, - insulinType: deliveryDelegate?.pumpInsulinType, - manuallyEntered: true + endDate: startDate, + volume: enteredBolus.doubleValue(for: .internationalUnit()), + insulinModel: insulinModel ) storedGlucoseValues = input.glucoseHistory @@ -532,9 +534,9 @@ final class BolusEntryViewModel: ObservableObject { // Add potential bolus, carbs, manual glucose input = input .addingDose(dose: enteredBolusDose) - .addingGlucoseSample(sample: manualGlucoseSample) + .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseStample) .removingCarbEntry(carbEntry: originalCarbEntry) - .addingCarbEntry(carbEntry: potentialCarbEntry) + .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) let prediction = try delegate.generatePrediction(input: input) predictedGlucoseValues = prediction @@ -658,7 +660,9 @@ final class BolusEntryViewModel: ObservableObject { 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()) diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index ee0cbe12bc..10d47e6000 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -10,9 +10,10 @@ import SwiftUI import LoopKit import HealthKit import Combine +import LoopCore protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate { - var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes { get } + var defaultAbsorptionTimes: DefaultAbsorptionTimes { get } func scheduleOverrideEnabled(at date: Date) -> Bool } @@ -72,7 +73,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 { diff --git a/Loop/View Models/ManualEntryDoseViewModel.swift b/Loop/View Models/ManualEntryDoseViewModel.swift index 269cd3b735..de960b0e95 100644 --- a/Loop/View Models/ManualEntryDoseViewModel.swift +++ b/Loop/View Models/ManualEntryDoseViewModel.swift @@ -16,6 +16,7 @@ import LoopKit import LoopKitUI import LoopUI import SwiftUI +import LoopAlgorithm enum ManualEntryDoseViewModelError: Error { case notAuthenticated @@ -28,7 +29,7 @@ protocol ManualDoseViewModelDelegate: AnyObject { var scheduleOverride: TemporaryScheduleOverride? { get } func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) async - func insulinActivityDuration(for type: InsulinType?) -> TimeInterval + func insulinModel(for type: InsulinType?) -> InsulinModel } @MainActor @@ -229,7 +230,15 @@ final class ManualEntryDoseViewModel: ObservableObject { let state = await delegate.algorithmDisplayState - let enteredBolusDose = DoseEntry(type: .bolus, startDate: selectedDoseDate, value: enteredBolus.doubleValue(for: .internationalUnit()), unit: .units, insulinType: selectedInsulinType) + let insulinModel = delegate.insulinModel(for: selectedInsulinType) + + let enteredBolusDose = SimpleInsulinDose( + deliveryType: .bolus, + startDate: selectedDoseDate, + endDate: selectedDoseDate, + volume: enteredBolus.doubleValue(for: .internationalUnit()), + insulinModel: insulinModel + ) self.activeInsulin = state.activeInsulin?.quantity self.activeCarbs = state.activeCarbs?.quantity @@ -277,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 ?? .hours(4)).hours) let historyHours = max(LoopConstants.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours) let date = Date(timeInterval: -TimeInterval(hours: historyHours), since: now()) diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift index 1137f9bd03..3d90042d3e 100644 --- a/Loop/View Models/SimpleBolusViewModel.swift +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -15,6 +15,7 @@ import SwiftUI import LoopCore import Intents import LocalAuthentication +import LoopAlgorithm protocol SimpleBolusViewModelDelegate: AnyObject { diff --git a/Loop/Views/PredictedGlucoseChartView.swift b/Loop/Views/PredictedGlucoseChartView.swift index b7e34a3bdb..d8a0041fb8 100644 --- a/Loop/Views/PredictedGlucoseChartView.swift +++ b/Loop/Views/PredictedGlucoseChartView.swift @@ -11,6 +11,7 @@ import SwiftUI import LoopKit import LoopKitUI import LoopUI +import LoopAlgorithm struct PredictedGlucoseChartView: UIViewRepresentable { diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index 087cd5a130..6d255f9fb0 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -11,6 +11,7 @@ import LoopKit import LoopKitUI import HealthKit import LoopCore +import LoopAlgorithm struct SimpleBolusView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @@ -380,7 +381,7 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { userUpdatedDate: nil) } - func insulinOnBoard(at date: Date) async -> LoopKit.InsulinValue? { + func insulinOnBoard(at date: Date) async -> InsulinValue? { return nil } diff --git a/LoopCore/LoopCoreConstants.swift b/LoopCore/LoopCoreConstants.swift index d33ca167bc..6d8edfea82 100644 --- a/LoopCore/LoopCoreConstants.swift +++ b/LoopCore/LoopCoreConstants.swift @@ -9,11 +9,13 @@ import Foundation import LoopKit +public typealias DefaultAbsorptionTimes = (fast: TimeInterval, medium: TimeInterval, slow: TimeInterval) + public enum LoopCoreConstants { /// 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 1140f60c99..b93aecf837 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -7,6 +7,7 @@ import HealthKit import LoopKit +import LoopAlgorithm public extension AutomaticDosingStrategy { var title: String { diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index 93fa7e17d6..ed1ebf5a5c 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -9,6 +9,7 @@ import Foundation import LoopKit import HealthKit +import LoopAlgorithm extension UserDefaults { diff --git a/LoopTests/Fixtures/live_capture/live_capture_input.json b/LoopTests/Fixtures/live_capture/live_capture_input.json index 4bd97abaa9..26f8a3593e 100644 --- a/LoopTests/Fixtures/live_capture/live_capture_input.json +++ b/LoopTests/Fixtures/live_capture/live_capture_input.json @@ -2,964 +2,898 @@ "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", - "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 - }, - { - "endDate" : "2023-06-23T02:27:44Z", - "startDate" : "2023-06-23T02:07:42Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - }, - { - "endDate" : "2023-06-23T02:27:44Z", - "startDate" : "2023-06-23T02:27:44Z", - "type" : "basal", - "unit" : "U", - "value" : 0 - }, - { - "endDate" : "2023-06-23T02:47:39Z", - "startDate" : "2023-06-23T02:27:44Z", - "type" : "tempBasal", - "unit" : "U\/hour", - "value" : 0 - } - ], + { + "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" : [ { - "quantity" : 120, - "startDate" : "2023-06-22T16:42:33Z" + "value" : 120, + "date" : "2023-06-22T16:42:33Z" }, { - "quantity" : 119, - "startDate" : "2023-06-22T16:47:33Z" + "value" : 119, + "date" : "2023-06-22T16:47:33Z" }, { - "quantity" : 120, - "startDate" : "2023-06-22T16:52:34Z" + "value" : 120, + "date" : "2023-06-22T16:52:34Z" }, { - "quantity" : 118, - "startDate" : "2023-06-22T16:57:34Z" + "value" : 118, + "date" : "2023-06-22T16:57:34Z" }, { - "quantity" : 115, - "startDate" : "2023-06-22T17:02:34Z" + "value" : 115, + "date" : "2023-06-22T17:02:34Z" }, { - "quantity" : 120, - "startDate" : "2023-06-22T17:07:34Z" + "value" : 120, + "date" : "2023-06-22T17:07:34Z" }, { - "quantity" : 121, - "startDate" : "2023-06-22T17:12:34Z" + "value" : 121, + "date" : "2023-06-22T17:12:34Z" }, { - "quantity" : 119, - "startDate" : "2023-06-22T17:17:34Z" + "value" : 119, + "date" : "2023-06-22T17:17:34Z" }, { - "quantity" : 116, - "startDate" : "2023-06-22T17:22:34Z" + "value" : 116, + "date" : "2023-06-22T17:22:34Z" }, { - "quantity" : 115, - "startDate" : "2023-06-22T17:27:34Z" + "value" : 115, + "date" : "2023-06-22T17:27:34Z" }, { - "quantity" : 124, - "startDate" : "2023-06-22T17:32:34Z" + "value" : 124, + "date" : "2023-06-22T17:32:34Z" }, { - "quantity" : 114, - "startDate" : "2023-06-22T17:37:34Z" + "value" : 114, + "date" : "2023-06-22T17:37:34Z" }, { - "quantity" : 124, - "startDate" : "2023-06-22T17:42:34Z" + "value" : 124, + "date" : "2023-06-22T17:42:34Z" }, { - "quantity" : 124, - "startDate" : "2023-06-22T17:47:33Z" + "value" : 124, + "date" : "2023-06-22T17:47:33Z" }, { - "quantity" : 124, - "startDate" : "2023-06-22T17:52:34Z" + "value" : 124, + "date" : "2023-06-22T17:52:34Z" }, { - "quantity" : 126, - "startDate" : "2023-06-22T17:57:33Z" + "value" : 126, + "date" : "2023-06-22T17:57:33Z" }, { - "quantity" : 125, - "startDate" : "2023-06-22T18:02:34Z" + "value" : 125, + "date" : "2023-06-22T18:02:34Z" }, { - "quantity" : 118, - "startDate" : "2023-06-22T18:07:34Z" + "value" : 118, + "date" : "2023-06-22T18:07:34Z" }, { - "quantity" : 122, - "startDate" : "2023-06-22T18:12:33Z" + "value" : 122, + "date" : "2023-06-22T18:12:33Z" }, { - "quantity" : 123, - "startDate" : "2023-06-22T18:17:34Z" + "value" : 123, + "date" : "2023-06-22T18:17:34Z" }, { - "quantity" : 123, - "startDate" : "2023-06-22T18:22:34Z" + "value" : 123, + "date" : "2023-06-22T18:22:34Z" }, { - "quantity" : 121, - "startDate" : "2023-06-22T18:27:34Z" + "value" : 121, + "date" : "2023-06-22T18:27:34Z" }, { - "quantity" : 118, - "startDate" : "2023-06-22T18:32:34Z" + "value" : 118, + "date" : "2023-06-22T18:32:34Z" }, { - "quantity" : 116, - "startDate" : "2023-06-22T18:37:34Z" + "value" : 116, + "date" : "2023-06-22T18:37:34Z" }, { - "quantity" : 118, - "startDate" : "2023-06-22T18:42:34Z" + "value" : 118, + "date" : "2023-06-22T18:42:34Z" }, { - "quantity" : 115, - "startDate" : "2023-06-22T18:47:34Z" + "value" : 115, + "date" : "2023-06-22T18:47:34Z" }, { - "quantity" : 117, - "startDate" : "2023-06-22T18:52:34Z" + "value" : 117, + "date" : "2023-06-22T18:52:34Z" }, { - "quantity" : 125, - "startDate" : "2023-06-22T18:57:34Z" + "value" : 125, + "date" : "2023-06-22T18:57:34Z" }, { - "quantity" : 122, - "startDate" : "2023-06-22T19:02:34Z" + "value" : 122, + "date" : "2023-06-22T19:02:34Z" }, { - "quantity" : 119, - "startDate" : "2023-06-22T19:07:34Z" + "value" : 119, + "date" : "2023-06-22T19:07:34Z" }, { - "quantity" : 120, - "startDate" : "2023-06-22T19:12:34Z" + "value" : 120, + "date" : "2023-06-22T19:12:34Z" }, { - "quantity" : 112, - "startDate" : "2023-06-22T19:17:34Z" + "value" : 112, + "date" : "2023-06-22T19:17:34Z" }, { - "quantity" : 111, - "startDate" : "2023-06-22T19:22:34Z" + "value" : 111, + "date" : "2023-06-22T19:22:34Z" }, { - "quantity" : 114, - "startDate" : "2023-06-22T19:27:34Z" + "value" : 114, + "date" : "2023-06-22T19:27:34Z" }, { - "quantity" : 117, - "startDate" : "2023-06-22T19:32:34Z" + "value" : 117, + "date" : "2023-06-22T19:32:34Z" }, { - "quantity" : 107, - "startDate" : "2023-06-22T19:37:34Z" + "value" : 107, + "date" : "2023-06-22T19:37:34Z" }, { - "quantity" : 113, - "startDate" : "2023-06-22T19:42:34Z" + "value" : 113, + "date" : "2023-06-22T19:42:34Z" }, { - "quantity" : 117, - "startDate" : "2023-06-22T19:47:34Z" + "value" : 117, + "date" : "2023-06-22T19:47:34Z" }, { - "quantity" : 109, - "startDate" : "2023-06-22T19:52:34Z" + "value" : 109, + "date" : "2023-06-22T19:52:34Z" }, { - "quantity" : 117, - "startDate" : "2023-06-22T19:57:34Z" + "value" : 117, + "date" : "2023-06-22T19:57:34Z" }, { - "quantity" : 121, - "startDate" : "2023-06-22T20:02:34Z" + "value" : 121, + "date" : "2023-06-22T20:02:34Z" }, { - "quantity" : 121, - "startDate" : "2023-06-22T20:07:34Z" + "value" : 121, + "date" : "2023-06-22T20:07:34Z" }, { - "quantity" : 127, - "startDate" : "2023-06-22T20:12:34Z" + "value" : 127, + "date" : "2023-06-22T20:12:34Z" }, { - "quantity" : 133, - "startDate" : "2023-06-22T20:17:34Z" + "value" : 133, + "date" : "2023-06-22T20:17:34Z" }, { - "quantity" : 131, - "startDate" : "2023-06-22T20:22:34Z" + "value" : 131, + "date" : "2023-06-22T20:22:34Z" }, { - "quantity" : 132, - "startDate" : "2023-06-22T20:27:34Z" + "value" : 132, + "date" : "2023-06-22T20:27:34Z" }, { - "quantity" : 134, - "startDate" : "2023-06-22T20:32:34Z" + "value" : 134, + "date" : "2023-06-22T20:32:34Z" }, { - "quantity" : 134, - "startDate" : "2023-06-22T20:37:34Z" + "value" : 134, + "date" : "2023-06-22T20:37:34Z" }, { - "quantity" : 139, - "startDate" : "2023-06-22T20:42:34Z" + "value" : 139, + "date" : "2023-06-22T20:42:34Z" }, { - "quantity" : 139, - "startDate" : "2023-06-22T20:47:34Z" + "value" : 139, + "date" : "2023-06-22T20:47:34Z" }, { - "quantity" : 132, - "startDate" : "2023-06-22T20:52:34Z" + "value" : 132, + "date" : "2023-06-22T20:52:34Z" }, { - "quantity" : 118, - "startDate" : "2023-06-22T20:57:34Z" + "value" : 118, + "date" : "2023-06-22T20:57:34Z" }, { - "quantity" : 123, - "startDate" : "2023-06-22T21:02:34Z" + "value" : 123, + "date" : "2023-06-22T21:02:34Z" }, { - "quantity" : 122, - "startDate" : "2023-06-22T21:07:34Z" + "value" : 122, + "date" : "2023-06-22T21:07:34Z" }, { - "quantity" : 119, - "startDate" : "2023-06-22T21:12:34Z" + "value" : 119, + "date" : "2023-06-22T21:12:34Z" }, { - "quantity" : 116, - "startDate" : "2023-06-22T21:17:34Z" + "value" : 116, + "date" : "2023-06-22T21:17:34Z" }, { - "quantity" : 113, - "startDate" : "2023-06-22T21:22:34Z" + "value" : 113, + "date" : "2023-06-22T21:22:34Z" }, { - "quantity" : 111, - "startDate" : "2023-06-22T21:27:34Z" + "value" : 111, + "date" : "2023-06-22T21:27:34Z" }, { - "quantity" : 112, - "startDate" : "2023-06-22T21:32:34Z" + "value" : 112, + "date" : "2023-06-22T21:32:34Z" }, { - "quantity" : 107, - "startDate" : "2023-06-22T21:37:34Z" + "value" : 107, + "date" : "2023-06-22T21:37:34Z" }, { - "quantity" : 102, - "startDate" : "2023-06-22T21:42:34Z" + "value" : 102, + "date" : "2023-06-22T21:42:34Z" }, { - "quantity" : 95, - "startDate" : "2023-06-22T21:47:34Z" + "value" : 95, + "date" : "2023-06-22T21:47:34Z" }, { - "quantity" : 96, - "startDate" : "2023-06-22T21:52:34Z" + "value" : 96, + "date" : "2023-06-22T21:52:34Z" }, { - "quantity" : 89, - "startDate" : "2023-06-22T21:57:34Z" + "value" : 89, + "date" : "2023-06-22T21:57:34Z" }, { - "quantity" : 95, - "startDate" : "2023-06-22T22:02:34Z" + "value" : 95, + "date" : "2023-06-22T22:02:34Z" }, { - "quantity" : 95, - "startDate" : "2023-06-22T22:07:34Z" + "value" : 95, + "date" : "2023-06-22T22:07:34Z" }, { - "quantity" : 93, - "startDate" : "2023-06-22T22:12:34Z" + "value" : 93, + "date" : "2023-06-22T22:12:34Z" }, { - "quantity" : 98, - "startDate" : "2023-06-22T22:17:35Z" + "value" : 98, + "date" : "2023-06-22T22:17:35Z" }, { - "quantity" : 95, - "startDate" : "2023-06-22T22:22:35Z" + "value" : 95, + "date" : "2023-06-22T22:22:35Z" }, { - "quantity" : 101, - "startDate" : "2023-06-22T22:27:34Z" + "value" : 101, + "date" : "2023-06-22T22:27:34Z" }, { - "quantity" : 97, - "startDate" : "2023-06-22T22:32:34Z" + "value" : 97, + "date" : "2023-06-22T22:32:34Z" }, { - "quantity" : 108, - "startDate" : "2023-06-22T22:37:35Z" + "value" : 108, + "date" : "2023-06-22T22:37:35Z" }, { - "quantity" : 109, - "startDate" : "2023-06-22T22:42:34Z" + "value" : 109, + "date" : "2023-06-22T22:42:34Z" }, { - "quantity" : 109, - "startDate" : "2023-06-22T22:47:34Z" + "value" : 109, + "date" : "2023-06-22T22:47:34Z" }, { - "quantity" : 114, - "startDate" : "2023-06-22T22:52:34Z" + "value" : 114, + "date" : "2023-06-22T22:52:34Z" }, { - "quantity" : 115, - "startDate" : "2023-06-22T22:57:34Z" + "value" : 115, + "date" : "2023-06-22T22:57:34Z" }, { - "quantity" : 114, - "startDate" : "2023-06-22T23:02:34Z" + "value" : 114, + "date" : "2023-06-22T23:02:34Z" }, { - "quantity" : 121, - "startDate" : "2023-06-22T23:07:34Z" + "value" : 121, + "date" : "2023-06-22T23:07:34Z" }, { - "quantity" : 119, - "startDate" : "2023-06-22T23:12:34Z" + "value" : 119, + "date" : "2023-06-22T23:12:34Z" }, { - "quantity" : 117, - "startDate" : "2023-06-22T23:17:34Z" + "value" : 117, + "date" : "2023-06-22T23:17:34Z" }, { - "quantity" : 120, - "startDate" : "2023-06-22T23:22:35Z" + "value" : 120, + "date" : "2023-06-22T23:22:35Z" }, { - "quantity" : 122, - "startDate" : "2023-06-22T23:27:34Z" + "value" : 122, + "date" : "2023-06-22T23:27:34Z" }, { - "quantity" : 123, - "startDate" : "2023-06-22T23:32:34Z" + "value" : 123, + "date" : "2023-06-22T23:32:34Z" }, { - "quantity" : 127, - "startDate" : "2023-06-22T23:37:34Z" + "value" : 127, + "date" : "2023-06-22T23:37:34Z" }, { - "quantity" : 118, - "startDate" : "2023-06-22T23:42:35Z" + "value" : 118, + "date" : "2023-06-22T23:42:35Z" }, { - "quantity" : 120, - "startDate" : "2023-06-22T23:47:34Z" + "value" : 120, + "date" : "2023-06-22T23:47:34Z" }, { - "quantity" : 119, - "startDate" : "2023-06-22T23:52:35Z" + "value" : 119, + "date" : "2023-06-22T23:52:35Z" }, { - "quantity" : 115, - "startDate" : "2023-06-22T23:57:34Z" + "value" : 115, + "date" : "2023-06-22T23:57:34Z" }, { - "quantity" : 116, - "startDate" : "2023-06-23T00:02:34Z" + "value" : 116, + "date" : "2023-06-23T00:02:34Z" }, { - "quantity" : 133, - "startDate" : "2023-06-23T00:07:34Z" + "value" : 133, + "date" : "2023-06-23T00:07:34Z" }, { - "quantity" : 145, - "startDate" : "2023-06-23T00:12:34Z" + "value" : 145, + "date" : "2023-06-23T00:12:34Z" }, { - "quantity" : 140, - "startDate" : "2023-06-23T00:17:34Z" + "value" : 140, + "date" : "2023-06-23T00:17:34Z" }, { - "quantity" : 161, - "startDate" : "2023-06-23T00:22:35Z" + "value" : 161, + "date" : "2023-06-23T00:22:35Z" }, { - "quantity" : 166, - "startDate" : "2023-06-23T00:27:34Z" + "value" : 166, + "date" : "2023-06-23T00:27:34Z" }, { - "quantity" : 172, - "startDate" : "2023-06-23T00:32:35Z" + "value" : 172, + "date" : "2023-06-23T00:32:35Z" }, { - "quantity" : 182, - "startDate" : "2023-06-23T00:37:35Z" + "value" : 182, + "date" : "2023-06-23T00:37:35Z" }, { - "quantity" : 184, - "startDate" : "2023-06-23T00:42:35Z" + "value" : 184, + "date" : "2023-06-23T00:42:35Z" }, { - "quantity" : 185, - "startDate" : "2023-06-23T00:47:34Z" + "value" : 185, + "date" : "2023-06-23T00:47:34Z" }, { - "quantity" : 190, - "startDate" : "2023-06-23T00:52:35Z" + "value" : 190, + "date" : "2023-06-23T00:52:35Z" }, { - "quantity" : 182, - "startDate" : "2023-06-23T00:57:34Z" + "value" : 182, + "date" : "2023-06-23T00:57:34Z" }, { - "quantity" : 166, - "startDate" : "2023-06-23T01:02:35Z" + "value" : 166, + "date" : "2023-06-23T01:02:35Z" }, { - "quantity" : 174, - "startDate" : "2023-06-23T01:07:34Z" + "value" : 174, + "date" : "2023-06-23T01:07:34Z" }, { - "quantity" : 179, - "startDate" : "2023-06-23T01:12:34Z" + "value" : 179, + "date" : "2023-06-23T01:12:34Z" }, { - "quantity" : 166, - "startDate" : "2023-06-23T01:17:35Z" + "value" : 166, + "date" : "2023-06-23T01:17:35Z" }, { - "quantity" : 134, - "startDate" : "2023-06-23T01:22:34Z" + "value" : 134, + "date" : "2023-06-23T01:22:34Z" }, { - "quantity" : 131, - "startDate" : "2023-06-23T01:27:35Z" + "value" : 131, + "date" : "2023-06-23T01:27:35Z" }, { - "quantity" : 129, - "startDate" : "2023-06-23T01:32:34Z" + "value" : 129, + "date" : "2023-06-23T01:32:34Z" }, { - "quantity" : 136, - "startDate" : "2023-06-23T01:37:34Z" + "value" : 136, + "date" : "2023-06-23T01:37:34Z" }, { - "quantity" : 152, - "startDate" : "2023-06-23T01:42:34Z" + "value" : 152, + "date" : "2023-06-23T01:42:34Z" }, { - "quantity" : 162, - "startDate" : "2023-06-23T01:47:35Z" + "value" : 162, + "date" : "2023-06-23T01:47:35Z" }, { - "quantity" : 165, - "startDate" : "2023-06-23T01:52:34Z" + "value" : 165, + "date" : "2023-06-23T01:52:34Z" }, { - "quantity" : 172, - "startDate" : "2023-06-23T01:57:34Z" + "value" : 172, + "date" : "2023-06-23T01:57:34Z" }, { - "quantity" : 176, - "startDate" : "2023-06-23T02:02:35Z" + "value" : 176, + "date" : "2023-06-23T02:02:35Z" }, { - "quantity" : 165, - "startDate" : "2023-06-23T02:07:35Z" + "value" : 165, + "date" : "2023-06-23T02:07:35Z" }, { - "quantity" : 172, - "startDate" : "2023-06-23T02:12:34Z" + "value" : 172, + "date" : "2023-06-23T02:12:34Z" }, { - "quantity" : 170, - "startDate" : "2023-06-23T02:17:35Z" + "value" : 170, + "date" : "2023-06-23T02:17:35Z" }, { - "quantity" : 177, - "startDate" : "2023-06-23T02:22:35Z" + "value" : 177, + "date" : "2023-06-23T02:22:35Z" }, { - "quantity" : 176, - "startDate" : "2023-06-23T02:27:35Z" + "value" : 176, + "date" : "2023-06-23T02:27:35Z" }, { - "quantity" : 173, - "startDate" : "2023-06-23T02:32:34Z" + "value" : 173, + "date" : "2023-06-23T02:32:34Z" }, { - "quantity" : 180, - "startDate" : "2023-06-23T02:37:35Z" + "value" : 180, + "date" : "2023-06-23T02:37:35Z" } ], "basal" : [ 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/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift index 6872bf9590..f8f68b841f 100644 --- a/LoopTests/Managers/DeviceDataManagerTests.swift +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -10,6 +10,7 @@ import XCTest import HealthKit import LoopKit import LoopKitUI +import LoopCore @testable import Loop @MainActor @@ -50,17 +51,13 @@ final class DeviceDataManagerTests: XCTestCase { let healthStore = HKHealthStore() - let carbAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) - let carbStore = CarbStore( cacheStore: persistenceController, - cacheLength: .days(1), - defaultAbsorptionTimes: carbAbsorptionTimes + cacheLength: .days(1) ) let doseStore = DoseStore( - cacheStore: persistenceController, - insulinModelProvider: PresetInsulinModelProvider(defaultRapidActingModel: nil) + cacheStore: persistenceController ) let glucoseStore = GlucoseStore(cacheStore: persistenceController) diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index eddfac1a9a..08e5f4d9b5 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -10,6 +10,7 @@ import XCTest import Foundation import LoopKit import HealthKit +import LoopAlgorithm @testable import Loop diff --git a/LoopTests/Managers/LoopAlgorithmTests.swift b/LoopTests/Managers/LoopAlgorithmTests.swift deleted file mode 100644 index e63f86bb46..0000000000 --- a/LoopTests/Managers/LoopAlgorithmTests.swift +++ /dev/null @@ -1,224 +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() { - // 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 input = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) - - let prediction = LoopAlgorithm.generatePrediction( - start: input.glucoseHistory.last?.startDate ?? Date(), - glucoseHistory: input.glucoseHistory, - doses: input.doses, - carbEntries: input.carbEntries, - basal: input.basal, - sensitivity: input.sensitivity, - carbRatio: input.carbRatio, - useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection - ) - - 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) - } - } - - func testAutoBolusMaxIOBClamping() async { - let now = ISO8601DateFormatter().date(from: "2020-03-11T12:13:14-0700")! - - var input = LoopAlgorithmInput.mock(for: now) - input.recommendationType = .automaticBolus - - // 8U bolus on board, and 100g carbs; CR = 10, so that should be 10U to cover the carbs - input.doses = [DoseEntry(type: .bolus, startDate: now.addingTimeInterval(-.minutes(5)), value: 8, unit: .units)] - input.carbEntries = [ - StoredCarbEntry(startDate: now.addingTimeInterval(.minutes(-5)), quantity: .carbs(value: 100)) - ] - - // Max activeInsulin = 2 x maxBolus = 16U - input.maxBolus = 8 - var output = LoopAlgorithm.run(input: input) - var recommendedBolus = output.recommendation!.automatic?.bolusUnits - var activeInsulin = output.activeInsulin! - XCTAssertEqual(activeInsulin, 8.0) - XCTAssertEqual(recommendedBolus!, 1.71, accuracy: 0.01) - - // Now try with maxBolus of 4; should not recommend any more insulin, as we're at our max iob - input.maxBolus = 4 - output = LoopAlgorithm.run(input: input) - recommendedBolus = output.recommendation!.automatic?.bolusUnits - activeInsulin = output.activeInsulin! - XCTAssertEqual(activeInsulin, 8.0) - XCTAssertEqual(recommendedBolus!, 0, accuracy: 0.01) - } - - func testTempBasalMaxIOBClamping() { - let now = ISO8601DateFormatter().date(from: "2020-03-11T12:13:14-0700")! - - var input = LoopAlgorithmInput.mock(for: now) - input.recommendationType = .tempBasal - - // 8U bolus on board, and 100g carbs; CR = 10, so that should be 10U to cover the carbs - input.doses = [DoseEntry(type: .bolus, startDate: now.addingTimeInterval(-.minutes(5)), value: 8, unit: .units)] - input.carbEntries = [ - StoredCarbEntry(startDate: now.addingTimeInterval(.minutes(-5)), quantity: .carbs(value: 100)) - ] - - // Max activeInsulin = 2 x maxBolus = 16U - input.maxBolus = 8 - var output = LoopAlgorithm.run(input: input) - var recommendedRate = output.recommendation!.automatic!.basalAdjustment!.unitsPerHour - var activeInsulin = output.activeInsulin! - XCTAssertEqual(activeInsulin, 8.0) - XCTAssertEqual(recommendedRate, 8.0, accuracy: 0.01) - - // Now try with maxBolus of 4; should only recommend scheduled basal (1U/hr), as we're at our max iob - input.maxBolus = 4 - output = LoopAlgorithm.run(input: input) - recommendedRate = output.recommendation!.automatic!.basalAdjustment!.unitsPerHour - activeInsulin = output.activeInsulin! - XCTAssertEqual(activeInsulin, 8.0) - XCTAssertEqual(recommendedRate, 1.0, accuracy: 0.01) - } -} - - -extension LoopAlgorithmInput { - static func mock(for date: Date, glucose: [Double] = [100, 120, 140, 160]) -> LoopAlgorithmInput { - - func d(_ interval: TimeInterval) -> Date { - return date.addingTimeInterval(interval) - } - - var input = LoopAlgorithmInput( - predictionStart: date, - glucoseHistory: [], - doses: [], - carbEntries: [], - basal: [], - sensitivity: [], - carbRatio: [], - target: [], - suspendThreshold: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 65), - maxBolus: 6, - maxBasalRate: 8, - recommendationInsulinType: .novolog, - recommendationType: .automaticBolus - ) - - for (idx, value) in glucose.enumerated() { - let entry = StoredGlucoseSample(startDate: d(.minutes(Double(-(glucose.count - idx)*5)) + .minutes(1)), quantity: .glucose(value: value)) - input.glucoseHistory.append(entry) - } - - input.doses = [ - DoseEntry(type: .bolus, startDate: d(.minutes(-3)), value: 1.0, unit: .units) - ] - - input.carbEntries = [ - StoredCarbEntry(startDate: d(.minutes(-4)), quantity: .carbs(value: 20)) - ] - - let forecastEndTime = date.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(.minutes(GlucoseMath.defaultDelta)) - let dosesStart = date.addingTimeInterval(-(CarbMath.maximumAbsorptionTimeInterval + InsulinMath.defaultInsulinActivityDuration)) - let carbsStart = date.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) - - - let basalRateSchedule = BasalRateSchedule( - dailyItems: [ - RepeatingScheduleValue(startTime: 0, value: 1), - ], - timeZone: .utcTimeZone - )! - input.basal = basalRateSchedule.between(start: dosesStart, end: date) - - let insulinSensitivitySchedule = InsulinSensitivitySchedule( - unit: .milligramsPerDeciliter, - dailyItems: [ - RepeatingScheduleValue(startTime: 0, value: 45), - RepeatingScheduleValue(startTime: 32400, value: 55) - ], - timeZone: .utcTimeZone - )! - input.sensitivity = insulinSensitivitySchedule.quantitiesBetween(start: dosesStart, end: forecastEndTime) - - let carbRatioSchedule = CarbRatioSchedule( - unit: .gram(), - dailyItems: [ - RepeatingScheduleValue(startTime: 0.0, value: 10.0), - ], - timeZone: .utcTimeZone - )! - input.carbRatio = carbRatioSchedule.between(start: carbsStart, end: date) - - let targetSchedule = GlucoseRangeSchedule( - unit: .milligramsPerDeciliter, - dailyItems: [ - RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100, maxValue: 110)), - ], - timeZone: .utcTimeZone - )! - input.target = targetSchedule.quantityBetween(start: date, end: forecastEndTime) - return input - } -} - diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 2819956f23..d9fa9aaf31 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -10,6 +10,8 @@ import XCTest import HealthKit import LoopKit import HealthKit +import LoopAlgorithm + @testable import LoopCore @testable import Loop @@ -201,14 +203,14 @@ class LoopDataManagerTests: XCTestCase { automaticDosingStrategy: .automaticBolus ) - glucoseStore.storedGlucose = predictionInput.glucoseHistory + glucoseStore.storedGlucose = predictionInput.glucoseHistory.map { StoredGlucoseSample.from(fixture: $0) } let currentDate = glucoseStore.latestGlucose!.startDate now = currentDate - doseStore.doseHistory = predictionInput.doses + doseStore.doseHistory = predictionInput.doses.map { DoseEntry.from(fixture: $0) } doseStore.lastAddedPumpData = predictionInput.doses.last!.startDate - carbStore.carbHistory = predictionInput.carbEntries + carbStore.carbHistory = predictionInput.carbEntries.map { StoredCarbEntry.from(fixture: $0) } let expectedPredictedGlucose = loadPredictedGlucoseFixture("live_capture_predicted_glucose") @@ -258,13 +260,13 @@ class LoopDataManagerTests: XCTestCase { await loopDataManager.updateDisplayState() - XCTAssertEqual(150, loopDataManager.eventualBG) + XCTAssertEqual(132, loopDataManager.eventualBG!, accuracy: 0.5) XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) await loopDataManager.loop() // Should correct high. - XCTAssertEqual(0.4, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + XCTAssertEqual(0.25, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) } func testHighAndRisingWithCOB() async { @@ -277,13 +279,13 @@ class LoopDataManagerTests: XCTestCase { await loopDataManager.updateDisplayState() - XCTAssertEqual(250, loopDataManager.eventualBG) + XCTAssertEqual(268, loopDataManager.eventualBG!, accuracy: 0.5) XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) await loopDataManager.loop() // Should correct high. - XCTAssertEqual(1.15, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + XCTAssertEqual(1.25, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) } func testLowAndFalling() async { @@ -296,7 +298,7 @@ class LoopDataManagerTests: XCTestCase { await loopDataManager.updateDisplayState() - XCTAssertEqual(75, loopDataManager.eventualBG!, accuracy: 1.0) + XCTAssertEqual(66, loopDataManager.eventualBG!, accuracy: 0.5) XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) await loopDataManager.loop() @@ -311,8 +313,8 @@ class LoopDataManagerTests: XCTestCase { 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)), + StoredGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 92)), + StoredGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 90)), ] carbStore.carbHistory = [ @@ -321,7 +323,7 @@ class LoopDataManagerTests: XCTestCase { await loopDataManager.updateDisplayState() - XCTAssertEqual(185, loopDataManager.eventualBG!, accuracy: 1.0) + XCTAssertEqual(192, loopDataManager.eventualBG!, accuracy: 0.5) XCTAssert(!loopDataManager.displayState.output!.effects.momentum.isEmpty) await loopDataManager.loop() @@ -372,6 +374,47 @@ class LoopDataManagerTests: XCTestCase { } } + func testOngoingTempBasalIsSufficient() async { + // LoopDataManager should trim future temp basals when running the algorithm. + // and should not include effects from future delivery of the temp basal in its prediction. + + glucoseStore.storedGlucose = [ + StoredGlucoseSample(startDate: d(.minutes(-4)), quantity: .glucose(value: 100)), + ] + + carbStore.carbHistory = [ + StoredCarbEntry(startDate: d(.minutes(-5)), quantity: .carbs(value: 20)) + ] + + // Temp basal started one minute ago, covering carbs. + let dose = DoseEntry( + type: .tempBasal, + startDate: d(.minutes(-1)), + endDate: d(.minutes(29)), + value: 5.05, + unit: .unitsPerHour + ) + deliveryDelegate.basalDeliveryState = .tempBasal(dose) + + doseStore.doseHistory = [ dose ] + + settingsProvider.settings.automaticDosingStrategy = .tempBasalOnly + + await loopDataManager.loop() + + // Should not adjust delivery, as existing temp basal is correct. + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: nil) + XCTAssertNil(deliveryDelegate.lastEnact) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + if dosingDecisionStore.dosingDecisions.count == 1 { + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) + } + } + + func testLoopRecommendsTempBasalWithoutEnactingIfOpenLoop() async { glucoseStore.storedGlucose = [ StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)), @@ -400,7 +443,7 @@ class LoopDataManagerTests: XCTestCase { loopDataManager.usePositiveMomentumAndRCForManualBoluses = true var recommendation = try! await loopDataManager.recommendManualBolus()! - XCTAssertEqual(recommendation.amount, 2.46, accuracy: 0.01) + XCTAssertEqual(recommendation.amount, 3.44, accuracy: 0.01) loopDataManager.usePositiveMomentumAndRCForManualBoluses = false recommendation = try! await loopDataManager.recommendManualBolus()! @@ -448,3 +491,39 @@ extension LoopDataManager { displayState.output?.predictedGlucose.last?.quantity.doubleValue(for: .milligramsPerDeciliter) } } + +extension StoredGlucoseSample { + static func from(fixture: FixtureGlucoseSample) -> StoredGlucoseSample { + return StoredGlucoseSample( + startDate: fixture.startDate, + quantity: fixture.quantity, + condition: fixture.condition, + trendRate: fixture.trendRate, + isDisplayOnly: fixture.isDisplayOnly, + wasUserEntered: fixture.wasUserEntered + ) + } +} + +extension DoseEntry { + static func from(fixture: FixtureInsulinDose) -> DoseEntry { + return DoseEntry( + type: fixture.deliveryType == .bolus ? .bolus : .basal, + startDate: fixture.startDate, + endDate: fixture.endDate, + value: fixture.volume, + unit: .units + ) + } +} + +extension StoredCarbEntry { + static func from(fixture: FixtureCarbEntry) -> StoredCarbEntry { + return StoredCarbEntry( + startDate: fixture.startDate, + quantity: fixture.quantity, + foodType: fixture.foodType, + absorptionTime: fixture.absorptionTime + ) + } +} diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index 2148821f54..5b97629de5 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -10,6 +10,8 @@ import XCTest import HealthKit import LoopCore import LoopKit +import LoopAlgorithm + @testable import Loop fileprivate class MockGlucoseSample: GlucoseSampleValue { @@ -17,7 +19,7 @@ fileprivate class MockGlucoseSample: GlucoseSampleValue { let provenanceIdentifier = "" let isDisplayOnly: Bool let wasUserEntered: Bool - let condition: LoopKit.GlucoseCondition? = nil + let condition: GlucoseCondition? = nil let trendRate: HKQuantity? = nil var trend: LoopKit.GlucoseTrend? var syncIdentifier: String? @@ -191,8 +193,8 @@ class MealDetectionManagerTests: XCTestCase { mealDetectionManager.test_currentDate! } - var algorithmInput: LoopAlgorithmInput! - var algorithmOutput: LoopAlgorithmOutput! + var algorithmInput: StoredDataAlgorithmInput! + var algorithmOutput: AlgorithmOutput! var mockAlgorithmState: AlgorithmDisplayState! @@ -216,11 +218,11 @@ class MealDetectionManagerTests: XCTestCase { insulinSensitivityScheduleApplyingOverrideHistory = testType.insulinSensitivitySchedule carbRatioSchedule = testType.carbSchedule - algorithmInput = LoopAlgorithmInput( - predictionStart: date, + 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), @@ -228,7 +230,10 @@ class MealDetectionManagerTests: XCTestCase { suspendThreshold: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 65), maxBolus: maximumBolus!, maxBasalRate: maximumBasalRatePerHour, - recommendationInsulinType: .novolog, + useIntegralRetrospectiveCorrection: false, + includePositiveVelocityAndRC: true, + carbAbsorptionModel: .piecewiseLinear, + recommendationInsulinModel: ExponentialInsulinModelPreset.rapidActingAdult.model, recommendationType: .automaticBolus ) @@ -260,7 +265,7 @@ class MealDetectionManagerTests: XCTestCase { retrospectiveGlucoseDiscrepancies: [] ) - algorithmOutput = LoopAlgorithmOutput( + algorithmOutput = AlgorithmOutput( recommendationResult: .success(.init()), predictedGlucose: [], effects: effects, diff --git a/LoopTests/Managers/TemporaryPresetsManagerTests.swift b/LoopTests/Managers/TemporaryPresetsManagerTests.swift index 60da1a21c2..cb79a3878d 100644 --- a/LoopTests/Managers/TemporaryPresetsManagerTests.swift +++ b/LoopTests/Managers/TemporaryPresetsManagerTests.swift @@ -8,6 +8,7 @@ import XCTest import LoopKit + @testable import Loop diff --git a/LoopTests/Mock Stores/MockDoseStore.swift b/LoopTests/Mock Stores/MockDoseStore.swift index 985ac687fe..061d258e05 100644 --- a/LoopTests/Mock Stores/MockDoseStore.swift +++ b/LoopTests/Mock Stores/MockDoseStore.swift @@ -8,6 +8,7 @@ import HealthKit import LoopKit +import LoopAlgorithm @testable import Loop class MockDoseStore: DoseStoreProtocol { @@ -23,7 +24,7 @@ class MockDoseStore: DoseStoreProtocol { var lastReservoirValue: LoopKit.ReservoirValue? - func getTotalUnitsDelivered(since startDate: Date) async throws -> LoopKit.InsulinValue { + func getTotalUnitsDelivered(since startDate: Date) async throws -> InsulinValue { return InsulinValue(startDate: lastAddedPumpData, value: 0) } diff --git a/LoopTests/Mock Stores/MockGlucoseStore.swift b/LoopTests/Mock Stores/MockGlucoseStore.swift index 064f3c0fba..ea6c3f118d 100644 --- a/LoopTests/Mock Stores/MockGlucoseStore.swift +++ b/LoopTests/Mock Stores/MockGlucoseStore.swift @@ -8,6 +8,7 @@ import HealthKit import LoopKit +import LoopAlgorithm @testable import Loop class MockGlucoseStore: GlucoseStoreProtocol { diff --git a/LoopTests/Mocks/MockDeliveryDelegate.swift b/LoopTests/Mocks/MockDeliveryDelegate.swift index bc14f03f00..c3bd8e911b 100644 --- a/LoopTests/Mocks/MockDeliveryDelegate.swift +++ b/LoopTests/Mocks/MockDeliveryDelegate.swift @@ -8,6 +8,7 @@ import Foundation import LoopKit +import LoopAlgorithm @testable import Loop class MockDeliveryDelegate: DeliveryDelegate { diff --git a/LoopTests/Mocks/MockSettingsProvider.swift b/LoopTests/Mocks/MockSettingsProvider.swift index 150608a1fe..4fcfe6e34f 100644 --- a/LoopTests/Mocks/MockSettingsProvider.swift +++ b/LoopTests/Mocks/MockSettingsProvider.swift @@ -9,6 +9,7 @@ import Foundation import LoopKit import HealthKit +import LoopAlgorithm @testable import Loop class MockSettingsProvider: SettingsProvider { diff --git a/LoopTests/Models/TempBasalRecommendationTests.swift b/LoopTests/Models/TempBasalRecommendationTests.swift new file mode 100644 index 0000000000..8c0c7ab1f4 --- /dev/null +++ b/LoopTests/Models/TempBasalRecommendationTests.swift @@ -0,0 +1,26 @@ +// +// TempBasalRecommendationTests.swift +// LoopTests +// +// Created by Pete Schwamb on 2/21/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopAlgorithm +@testable import Loop + +class TempBasalRecommendationTests: XCTestCase { + + func testCancel() { + let cancel = TempBasalRecommendation.cancel + XCTAssertEqual(cancel.unitsPerHour, 0) + XCTAssertEqual(cancel.duration, 0) + } + + func testInitializer() { + let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 1.23, duration: 4.56) + XCTAssertEqual(tempBasalRecommendation.unitsPerHour, 1.23) + XCTAssertEqual(tempBasalRecommendation.duration, 4.56) + } +} diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index f5667f2857..05cac52a87 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -12,6 +12,8 @@ import LoopKit import LoopKitUI import SwiftUI import XCTest +import LoopAlgorithm + @testable import Loop @MainActor @@ -21,7 +23,7 @@ class BolusEntryViewModelTests: XCTestCase { static let now = ISO8601DateFormatter().date(from: "2020-03-11T07:00:00-0700")! static let exampleStartDate = now - .hours(2) static let exampleEndDate = now - .hours(1) - static fileprivate let exampleGlucoseValue = MockGlucoseValue(quantity: exampleManualGlucoseQuantity, startDate: exampleStartDate) + static fileprivate let exampleGlucoseValue = SimpleGlucoseValue(startDate: exampleStartDate, quantity: exampleManualGlucoseQuantity) static let exampleManualGlucoseQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.4) static let exampleManualGlucoseSample = HKQuantitySample(type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, @@ -828,6 +830,9 @@ public enum BolusEntryViewTestError: Error { } fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { + func insulinModel(for type: LoopKit.InsulinType?) -> InsulinModel { + return ExponentialInsulinModelPreset.rapidActingAdult + } var settings = StoredSettings( dosingEnabled: true, @@ -848,17 +853,17 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { var preMealOverride: LoopKit.TemporaryScheduleOverride? - var pumpInsulinType: LoopKit.InsulinType? + var pumpInsulinType: InsulinType? var mostRecentGlucoseDataDate: Date? var mostRecentPumpDataDate: Date? - var loopStateInput = LoopAlgorithmInput( - predictionStart: Date(), + var loopStateInput = StoredDataAlgorithmInput( glucoseHistory: [], doses: [], carbEntries: [], + predictionStart: Date(), basal: [], sensitivity: [], carbRatio: [], @@ -866,13 +871,15 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { suspendThreshold: nil, maxBolus: 3, maxBasalRate: 6, + useIntegralRetrospectiveCorrection: false, + includePositiveVelocityAndRC: true, carbAbsorptionModel: .piecewiseLinear, - recommendationInsulinType: .novolog, + recommendationInsulinModel: ExponentialInsulinModelPreset.rapidActingAdult, recommendationType: .manualBolus, automaticBolusApplicationFactor: 0.4 ) - func fetchData(for baseTime: Date, disablingPreMeal: Bool) async throws -> LoopAlgorithmInput { + func fetchData(for baseTime: Date, disablingPreMeal: Bool) async throws -> StoredDataAlgorithmInput { loopStateInput.predictionStart = baseTime return loopStateInput } @@ -925,14 +932,14 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { var activeCarbs: CarbValue? var prediction: [PredictedGlucoseValue] = [] - var lastGeneratePredictionInput: LoopAlgorithmInput? + var lastGeneratePredictionInput: StoredDataAlgorithmInput? - func generatePrediction(input: LoopAlgorithmInput) throws -> [PredictedGlucoseValue] { + func generatePrediction(input: StoredDataAlgorithmInput) throws -> [PredictedGlucoseValue] { lastGeneratePredictionInput = input return prediction } - var algorithmOutput: LoopAlgorithmOutput = LoopAlgorithmOutput( + var algorithmOutput: AlgorithmOutput = AlgorithmOutput( recommendationResult: .success(.init()), predictedGlucose: [], effects: LoopAlgorithmEffects.emptyMock, diff --git a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift index 46cb1e75a3..d21c3f9e43 100644 --- a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift +++ b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift @@ -10,6 +10,8 @@ import HealthKit import LoopCore import LoopKit import XCTest +import LoopAlgorithm + @testable import Loop @MainActor @@ -73,7 +75,7 @@ class ManualEntryDoseViewModelTests: XCTestCase { } fileprivate class MockManualEntryDoseViewModelDelegate: ManualDoseViewModelDelegate { - var pumpInsulinType: LoopKit.InsulinType? + var pumpInsulinType: InsulinType? var manualEntryBolusUnits: Double? var manualEntryDoseStartDate: Date? @@ -85,7 +87,7 @@ fileprivate class MockManualEntryDoseViewModelDelegate: ManualDoseViewModelDeleg manuallyEnteredDoseInsulinType = insulinType } - func insulinActivityDuration(for type: LoopKit.InsulinType?) -> TimeInterval { + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { return InsulinMath.defaultInsulinActivityDuration } @@ -95,5 +97,8 @@ fileprivate class MockManualEntryDoseViewModelDelegate: ManualDoseViewModelDeleg var scheduleOverride: TemporaryScheduleOverride? + func insulinModel(for type: LoopKit.InsulinType?) -> InsulinModel { + return ExponentialInsulinModelPreset.rapidActingAdult + } } diff --git a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift index bc35213e9c..d2425abd0b 100644 --- a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift +++ b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift @@ -11,6 +11,7 @@ import HealthKit import LoopKit import LoopKitUI import LoopCore +import LoopAlgorithm @testable import Loop diff --git a/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index 1eae019f17..0343a17d94 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -11,6 +11,7 @@ import WatchKit import LoopKit import LoopCore import os.log +import LoopAlgorithm final class ComplicationController: NSObject, CLKComplicationDataSource { diff --git a/WatchApp Extension/Controllers/CarbEntryListController.swift b/WatchApp Extension/Controllers/CarbEntryListController.swift index a704a942cd..8a2b74a420 100644 --- a/WatchApp Extension/Controllers/CarbEntryListController.swift +++ b/WatchApp Extension/Controllers/CarbEntryListController.swift @@ -10,6 +10,7 @@ import LoopCore import LoopKit import os.log import WatchKit +import LoopAlgorithm class CarbEntryListController: WKInterfaceController, IdentifiableClass { @IBOutlet private var table: WKInterfaceTable! @@ -79,7 +80,7 @@ extension CarbEntryListController { } private func reloadCarbEntries() { - let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -loopManager.carbStore.maximumAbsorptionTimeInterval)) + let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -CarbMath.maximumAbsorptionTimeInterval)) loopManager.carbStore.getCarbEntries(start: start) { (result) in switch result { diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift index 341eece3f6..d093bca3c9 100644 --- a/WatchApp Extension/Controllers/ChartHUDController.swift +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -13,6 +13,7 @@ import HealthKit import SpriteKit import os.log import LoopCore +import LoopAlgorithm final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { private enum TableRow: Int, CaseIterable { diff --git a/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift index 2ff5f54fb0..7ee49de7b7 100644 --- a/WatchApp Extension/Controllers/HUDInterfaceController.swift +++ b/WatchApp Extension/Controllers/HUDInterfaceController.swift @@ -9,6 +9,7 @@ import WatchKit import LoopCore import LoopKit +import LoopAlgorithm class HUDInterfaceController: WKInterfaceController { private var activeContextObserver: NSObjectProtocol? diff --git a/WatchApp Extension/Managers/ComplicationChartManager.swift b/WatchApp Extension/Managers/ComplicationChartManager.swift index bfca19ea24..1d71f66446 100644 --- a/WatchApp Extension/Managers/ComplicationChartManager.swift +++ b/WatchApp Extension/Managers/ComplicationChartManager.swift @@ -11,6 +11,7 @@ import UIKit import HealthKit import WatchKit import LoopKit +import LoopAlgorithm private let textInsets = UIEdgeInsets(top: 2, left: 2, bottom: 2, right: 2) diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 1fcbdbd30c..1a0be226f2 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -12,6 +12,7 @@ import LoopKit import LoopCore import WatchConnectivity import os.log +import LoopAlgorithm class LoopDataManager { @@ -66,7 +67,6 @@ 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 ) glucoseStore = GlucoseStore( @@ -114,7 +114,7 @@ extension LoopDataManager { func requestCarbBackfill() { dispatchPrecondition(condition: .onQueue(.main)) - let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -carbStore.maximumAbsorptionTimeInterval)) + let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -CarbMath.maximumAbsorptionTimeInterval)) let userInfo = CarbBackfillRequestUserInfo(startDate: start) WCSession.default.sendCarbBackfillRequestMessage(userInfo) { (result) in switch result { diff --git a/WatchApp Extension/Models/GlucoseChartData.swift b/WatchApp Extension/Models/GlucoseChartData.swift index 4ed6bd7ee8..4bf9a2b2c8 100644 --- a/WatchApp Extension/Models/GlucoseChartData.swift +++ b/WatchApp Extension/Models/GlucoseChartData.swift @@ -9,6 +9,7 @@ import Foundation import HealthKit import LoopKit +import LoopAlgorithm struct GlucoseChartData { diff --git a/WatchApp Extension/Models/GlucoseChartScaler.swift b/WatchApp Extension/Models/GlucoseChartScaler.swift index cb03f8380b..953f5bf1ea 100644 --- a/WatchApp Extension/Models/GlucoseChartScaler.swift +++ b/WatchApp Extension/Models/GlucoseChartScaler.swift @@ -11,6 +11,7 @@ import CoreGraphics import HealthKit import LoopKit import WatchKit +import LoopAlgorithm enum CoordinateSystem { diff --git a/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift index 4737a2336f..5060e5d372 100644 --- a/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift +++ b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift @@ -7,6 +7,7 @@ import LoopKit import HealthKit +import LoopAlgorithm protocol GlucoseChartValueHashable { From f35a3a6ea8ae02d0de699d3f6990262bca182567 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 14 Mar 2024 15:14:05 -0300 Subject: [PATCH 029/184] [LOOP-4807] need to round the bolus before added to the context (#621) * need to round the bolus before added to the context * response to PR comment * clean-up * minor refactor * updated unit tests --- Loop/Managers/LoopDataManager.swift | 6 +++++- Loop/View Models/BolusEntryViewModel.swift | 2 +- LoopTests/Managers/LoopDataManagerTests.swift | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 0715f7e522..77e6da91c7 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -595,7 +595,11 @@ final class LoopDataManager { switch output.recommendationResult { case .success(let prediction): - return prediction.manual + guard var manualBolusRecommendation = prediction.manual else { return nil } + if let roundedAmount = deliveryDelegate?.roundBolusVolume(units: manualBolusRecommendation.amount) { + manualBolusRecommendation.amount = roundedAmount + } + return manualBolusRecommendation case .failure(let error): throw error } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 98d248fbee..2d29edab01 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -562,7 +562,7 @@ final class BolusEntryViewModel: ObservableObject { recommendation = try await computeBolusRecommendation() if let recommendation, let deliveryDelegate { - recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: deliveryDelegate.roundBolusVolume(units: recommendation.amount)) + recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) switch recommendation.notice { case .glucoseBelowSuspendThreshold: diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index d9fa9aaf31..45d7612b7a 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -443,11 +443,11 @@ class LoopDataManagerTests: XCTestCase { loopDataManager.usePositiveMomentumAndRCForManualBoluses = true var recommendation = try! await loopDataManager.recommendManualBolus()! - XCTAssertEqual(recommendation.amount, 3.44, accuracy: 0.01) + XCTAssertEqual(recommendation.amount, 3.45, accuracy: 0.01) loopDataManager.usePositiveMomentumAndRCForManualBoluses = false recommendation = try! await loopDataManager.recommendManualBolus()! - XCTAssertEqual(recommendation.amount, 1.73, accuracy: 0.01) + XCTAssertEqual(recommendation.amount, 1.75, accuracy: 0.01) } From ea73ac5a384d39e22b16f7d8a30aa36f6509c205 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 19 Mar 2024 13:36:56 -0700 Subject: [PATCH 030/184] [LOOP-4782] 10s Canceled Bolus Status Banner --- .../StatusTableViewController.swift | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 41935ed1f2..796e61faac 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -656,6 +656,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case enactingBolus case bolusing(dose: DoseEntry) case cancelingBolus + case canceledBolus(dose: DoseEntry) case pumpSuspended(resuming: Bool) case onboardingSuspended case recommendManualGlucoseEntry @@ -672,6 +673,8 @@ final class StatusTableViewController: LoopChartsTableViewController { private var statusRowMode = StatusRowMode.hidden + private var canceledDose: DoseEntry? = nil + private func determineStatusRowMode() -> StatusRowMode { let statusRowMode: StatusRowMode @@ -679,6 +682,8 @@ final class StatusTableViewController: LoopChartsTableViewController { statusRowMode = .enactingBolus } else if case .canceling = bolusState { statusRowMode = .cancelingBolus + } else if let canceledDose { + statusRowMode = .canceledBolus(dose: canceledDose) } else if case .suspended = basalDeliveryState { statusRowMode = .pumpSuspended(resuming: false) } else if case .resuming = basalDeliveryState { @@ -1059,6 +1064,22 @@ final class StatusTableViewController: LoopChartsTableViewController { indicatorView.startAnimating() cell.accessoryView = indicatorView return cell + case .canceledBolus(let dose): + let cell = getTitleSubtitleCell() + + lazy var insulinFormatter: QuantityFormatter = { + let formatter = QuantityFormatter(for: .internationalUnit()) + formatter.numberFormatter.minimumFractionDigits = 2 + return formatter + }() + + let totalUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: dose.programmedUnits) + let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" + + let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: bolusProgressReporter!.progress.deliveredUnits) + let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" + cell.titleLabel.text = String(format: NSLocalizedString("Bolus Canceled: %1$@ of %2$@", comment: "The title of the cell indicating a bolus has been canceled. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) + return cell case .pumpSuspended(let resuming): let cell = getTitleSubtitleCell() cell.titleLabel.text = NSLocalizedString("Insulin Suspended", comment: "The title of the cell indicating the pump is suspended") @@ -1204,14 +1225,18 @@ final class StatusTableViewController: LoopChartsTableViewController { vc.delegate = self show(vc, sender: tableView.cellForRow(at: indexPath)) } - case .bolusing: + case .bolusing(let dose): 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 + self.canceledDose = dose + 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.presentErrorCancelingBolus(error) if case .inProgress(let dose) = self.bolusState { From 21c78cd0bb54037c5b54efce079a7b164ae6234d Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 20 Mar 2024 11:49:43 -0300 Subject: [PATCH 031/184] [PAL-478] needed to trigger viewDidAppear to present the modal after dismissing the pump manager view (#623) --- Loop/Managers/DeliveryUncertaintyAlertManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop/Managers/DeliveryUncertaintyAlertManager.swift b/Loop/Managers/DeliveryUncertaintyAlertManager.swift index d163d9d227..8bd74b7ef7 100644 --- a/Loop/Managers/DeliveryUncertaintyAlertManager.swift +++ b/Loop/Managers/DeliveryUncertaintyAlertManager.swift @@ -23,6 +23,7 @@ class DeliveryUncertaintyAlertManager { private func showUncertainDeliveryRecoveryView() { var controller = pumpManager.deliveryUncertaintyRecoveryViewController(colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures) controller.completionDelegate = self + controller.modalPresentationStyle = .fullScreen self.alertPresenter.present(controller, animated: true) } From d97b329b48c92bf8c5a88c0b7ef9f3e28be509af Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 20 Mar 2024 12:27:47 -0300 Subject: [PATCH 032/184] [PAL-471] allow manual glucose entry when recommendManualGlucoseEntry is presented (#624) * allow manual glucose entry when recommendManualGlucoseEntry is presented * using isGlucoseValueStale * clean-up --- Loop/Managers/Alerts/AlertManager.swift | 16 ++++++++-------- .../StatusTableViewController.swift | 4 ++-- Loop/View Models/CarbEntryViewModel.swift | 10 +++++++--- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 010a00074a..41a8a19011 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -59,14 +59,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 diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 4ffe792d4c..7d84b3c942 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1343,7 +1343,7 @@ final class StatusTableViewController: LoopChartsTableViewController { hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) } else { - let viewModel = CarbEntryViewModel(delegate: loopManager) + let viewModel = CarbEntryViewModel(delegate: loopManager, enableManualGlucoseEntry: deviceManager.isGlucoseValueStale) viewModel.deliveryDelegate = deviceManager viewModel.analyticsServicesManager = loopManager.analyticsServicesManager if let activity { @@ -1358,7 +1358,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } @IBAction func presentBolusScreen() { - presentBolusEntryView() + presentBolusEntryView(enableManualGlucoseEntry: deviceManager.isGlucoseValueStale) } @ViewBuilder diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 10d47e6000..01abe61905 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -51,6 +51,7 @@ final class CarbEntryViewModel: ObservableObject { @Published var bolusViewModel: BolusEntryViewModel? let shouldBeginEditingQuantity: Bool + let enableManualGlucoseEntry: Bool @Published var carbsQuantity: Double? = nil var preferredCarbUnit = HKUnit.gram() @@ -90,8 +91,9 @@ final class CarbEntryViewModel: ObservableObject { private lazy var cancellables = Set() /// Initalizer for when`CarbEntryView` is presented from the home screen - init(delegate: CarbEntryViewModelDelegate) { + init(delegate: CarbEntryViewModelDelegate, enableManualGlucoseEntry: Bool = false) { self.delegate = delegate + self.enableManualGlucoseEntry = enableManualGlucoseEntry self.absorptionTime = delegate.defaultAbsorptionTimes.medium self.defaultAbsorptionTimes = delegate.defaultAbsorptionTimes self.shouldBeginEditingQuantity = true @@ -103,8 +105,9 @@ final class CarbEntryViewModel: ObservableObject { } /// Initalizer for when`CarbEntryView` has an entry to edit - init(delegate: CarbEntryViewModelDelegate, originalCarbEntry: StoredCarbEntry) { + init(delegate: CarbEntryViewModelDelegate, enableManualGlucoseEntry: Bool = false, originalCarbEntry: StoredCarbEntry) { self.delegate = delegate + self.enableManualGlucoseEntry = enableManualGlucoseEntry self.originalCarbEntry = originalCarbEntry self.defaultAbsorptionTimes = delegate.defaultAbsorptionTimes @@ -190,7 +193,8 @@ final class CarbEntryViewModel: ObservableObject { screenWidth: UIScreen.main.bounds.width, originalCarbEntry: originalCarbEntry, potentialCarbEntry: updatedCarbEntry, - selectedCarbAbsorptionTimeEmoji: selectedDefaultAbsorptionTimeEmoji + selectedCarbAbsorptionTimeEmoji: selectedDefaultAbsorptionTimeEmoji, + isManualGlucoseEntryEnabled: enableManualGlucoseEntry ) viewModel.analyticsServicesManager = analyticsServicesManager From 6c9a0cdd8ed5768caa42833f96f8ffb78b8bb952 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 20 Mar 2024 17:03:56 -0700 Subject: [PATCH 033/184] [LOOP-4782] 10s Canceled Bolus Status Banner --- .../StatusTableViewController.swift | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 796e61faac..3d08791ec4 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1078,7 +1078,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: bolusProgressReporter!.progress.deliveredUnits) let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" - cell.titleLabel.text = String(format: NSLocalizedString("Bolus Canceled: %1$@ of %2$@", comment: "The title of the cell indicating a bolus has been canceled. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) + cell.titleLabel.text = String(format: NSLocalizedString("Bolus Canceled: %1$@ of %2$@ delivered", comment: "The title of the cell indicating a bolus has been canceled. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) return cell case .pumpSuspended(let resuming): let cell = getTitleSubtitleCell() @@ -1227,22 +1227,25 @@ final class StatusTableViewController: LoopChartsTableViewController { } case .bolusing(let dose): updateBannerAndHUDandStatusRows(statusRowMode: .cancelingBolus, newSize: nil, animated: true) - deviceManager.pumpManager?.cancelBolus() { (result) in - DispatchQueue.main.async { - switch result { - case .success: - self.canceledDose = dose - Task { - try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 10) + Task { + self.canceledDose = dose + deviceManager.pumpManager?.cancelBolus() { (result) in + DispatchQueue.main.async { + switch result { + case .success: + Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 10) + self.canceledDose = nil + self.updateBannerAndHUDandStatusRows(statusRowMode: self.determineStatusRowMode(), newSize: nil, animated: false) + } + case .failure(let error): self.canceledDose = nil - self.updateBannerAndHUDandStatusRows(statusRowMode: self.determineStatusRowMode(), newSize: nil, animated: true) - } - 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) + 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) + } } } } From d5140bf8f1b305587238a03c99a3ee96a57fa6f9 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 21 Mar 2024 14:11:08 -0300 Subject: [PATCH 034/184] [LOOP-4824] updating ZipFoundation (#626) --- Loop.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 69cfe11b5b..374529d858 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -5937,8 +5937,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/LoopKit/ZIPFoundation.git"; requirement = { - branch = "stream-entry"; - kind = branch; + kind = revision; + revision = c67b7509ec82ee2b4b0ab3f97742b94ed9692494; }; }; C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */ = { From 72fca5821757bcd7479c401f5e27cc01a70eef38 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 21 Mar 2024 16:00:58 -0300 Subject: [PATCH 035/184] [PAL-458] Adding the check for rapidly rising glucose (#622) * Adding the check for rapidly rising glucose * matching the previous implementation * clean up --- Loop/Managers/LoopDataManager.swift | 5 ++- Loop/View Models/CarbEntryViewModel.swift | 47 ++++++++++++++++++++++- Loop/Views/CarbEntryView.swift | 4 ++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 77e6da91c7..8af722fc81 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1134,7 +1134,10 @@ extension LoopDataManager: CarbEntryViewModelDelegate { var defaultAbsorptionTimes: DefaultAbsorptionTimes { LoopCoreConstants.defaultCarbAbsorptionTimes } - + + func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] { + try await glucoseStore.getGlucoseSamples(start: start, end: end) + } } extension LoopDataManager: ManualDoseViewModelDelegate { diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 01abe61905..49b596f97e 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -15,6 +15,7 @@ import LoopCore protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate { var defaultAbsorptionTimes: DefaultAbsorptionTimes { get } func scheduleOverrideEnabled(at date: Date) -> Bool + func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] } final class CarbEntryViewModel: ObservableObject { @@ -38,11 +39,14 @@ final class CarbEntryViewModel: ObservableObject { return 1 case .overrideInProgress: return 2 + case .glucoseRisingRapidly: + return 3 } } case entryIsMissedMeal case overrideInProgress + case glucoseRisingRapidly } @Published var alert: CarbEntryViewModel.Alert? @@ -284,12 +288,14 @@ 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) } @@ -309,6 +315,45 @@ final class CarbEntryViewModel: ObservableObject { } } + private func checkGlucoseRisingRapidly() { + guard let delegate else { + warnings.remove(.glucoseRisingRapidly) + return + } + + let now = Date() + let startDate = now.addingTimeInterval(-LoopConstants.missedMealWarningGlucoseRecencyWindow) + + Task { @MainActor in + let glucoseSamples = try? await delegate.getGlucoseSamples(start: startDate, end: nil) + guard let glucoseSamples else { + warnings.remove(.glucoseRisingRapidly) + return + } + + let filteredGlucoseSamples = glucoseSamples.filterDateRange(startDate, now) + guard let startSample = filteredGlucoseSamples.first, let endSample = filteredGlucoseSamples.last else { + warnings.remove(.glucoseRisingRapidly) + return + } + + let duration = endSample.startDate.timeIntervalSince(startSample.startDate) + guard duration >= LoopConstants.missedMealWarningVelocitySampleMinDuration else { + warnings.remove(.glucoseRisingRapidly) + return + } + + let delta = endSample.quantity.doubleValue(for: .milligramsPerDeciliter) - startSample.quantity.doubleValue(for: .milligramsPerDeciliter) + let velocity = delta / duration.minutes // Unit = mg/dL/m + + if velocity > LoopConstants.missedMealWarningGlucoseRiseThreshold { + warnings.insert(.glucoseRisingRapidly) + } else { + warnings.remove(.glucoseRisingRapidly) + } + } + } + private func observeAbsorptionTimeChange() { $absorptionTime .receive(on: RunLoop.main) diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index 14c6b2c460..97082a9b59 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -167,6 +167,8 @@ extension CarbEntryView { return .critical case .overrideInProgress: return .warning + case .glucoseRisingRapidly: + return .critical } } @@ -176,6 +178,8 @@ extension CarbEntryView { return NSLocalizedString("Loop has detected an missed meal and estimated its size. Edit the carb amount to match the amount of any carbs you may have eaten.", comment: "Warning displayed when user is adding a meal from an missed meal notification") case .overrideInProgress: return NSLocalizedString("An active override is modifying your carb ratio and insulin sensitivity. If you don't want this to affect your bolus calculation and projected glucose, consider turning off the override.", comment: "Warning to ensure the carb entry is accurate during an override") + case .glucoseRisingRapidly: + return NSLocalizedString("Your glucose is rapidly rising. Check that any carbs you've eaten were logged. If you logged carbs, check that the time you entered lines up with when you started eating.", comment: "Warning to ensure the carb entry is accurate") } } From 8c92e37a2403d9d2283d18a34155027f19082601 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 26 Mar 2024 16:16:03 -0300 Subject: [PATCH 036/184] [PAL-471] reverting recent updates and adding missing loop algorithm error capture (#627) --- Loop/View Controllers/StatusTableViewController.swift | 4 ++-- Loop/View Models/BolusEntryViewModel.swift | 2 +- Loop/View Models/CarbEntryViewModel.swift | 10 +++------- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 7d84b3c942..4ffe792d4c 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1343,7 +1343,7 @@ final class StatusTableViewController: LoopChartsTableViewController { hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) } else { - let viewModel = CarbEntryViewModel(delegate: loopManager, enableManualGlucoseEntry: deviceManager.isGlucoseValueStale) + let viewModel = CarbEntryViewModel(delegate: loopManager) viewModel.deliveryDelegate = deviceManager viewModel.analyticsServicesManager = loopManager.analyticsServicesManager if let activity { @@ -1358,7 +1358,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } @IBAction func presentBolusScreen() { - presentBolusEntryView(enableManualGlucoseEntry: deviceManager.isGlucoseValueStale) + presentBolusEntryView() } @ViewBuilder diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 2d29edab01..6d8ce46bcc 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -586,7 +586,7 @@ final class BolusEntryViewModel: ObservableObject { recommendedBolus = nil switch error { - case LoopError.missingDataError(.glucose), LoopError.glucoseTooOld: + case LoopError.missingDataError(.glucose), LoopError.glucoseTooOld, AlgorithmError.missingGlucose, AlgorithmError.glucoseTooOld: notice = .staleGlucoseData case LoopError.invalidFutureGlucose: notice = .futureGlucoseData diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 49b596f97e..4fbe81dc42 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -55,7 +55,6 @@ final class CarbEntryViewModel: ObservableObject { @Published var bolusViewModel: BolusEntryViewModel? let shouldBeginEditingQuantity: Bool - let enableManualGlucoseEntry: Bool @Published var carbsQuantity: Double? = nil var preferredCarbUnit = HKUnit.gram() @@ -95,9 +94,8 @@ final class CarbEntryViewModel: ObservableObject { private lazy var cancellables = Set() /// Initalizer for when`CarbEntryView` is presented from the home screen - init(delegate: CarbEntryViewModelDelegate, enableManualGlucoseEntry: Bool = false) { + init(delegate: CarbEntryViewModelDelegate) { self.delegate = delegate - self.enableManualGlucoseEntry = enableManualGlucoseEntry self.absorptionTime = delegate.defaultAbsorptionTimes.medium self.defaultAbsorptionTimes = delegate.defaultAbsorptionTimes self.shouldBeginEditingQuantity = true @@ -109,9 +107,8 @@ final class CarbEntryViewModel: ObservableObject { } /// Initalizer for when`CarbEntryView` has an entry to edit - init(delegate: CarbEntryViewModelDelegate, enableManualGlucoseEntry: Bool = false, originalCarbEntry: StoredCarbEntry) { + init(delegate: CarbEntryViewModelDelegate, originalCarbEntry: StoredCarbEntry) { self.delegate = delegate - self.enableManualGlucoseEntry = enableManualGlucoseEntry self.originalCarbEntry = originalCarbEntry self.defaultAbsorptionTimes = delegate.defaultAbsorptionTimes @@ -197,8 +194,7 @@ final class CarbEntryViewModel: ObservableObject { screenWidth: UIScreen.main.bounds.width, originalCarbEntry: originalCarbEntry, potentialCarbEntry: updatedCarbEntry, - selectedCarbAbsorptionTimeEmoji: selectedDefaultAbsorptionTimeEmoji, - isManualGlucoseEntryEnabled: enableManualGlucoseEntry + selectedCarbAbsorptionTimeEmoji: selectedDefaultAbsorptionTimeEmoji ) viewModel.analyticsServicesManager = analyticsServicesManager From d4dcb2f62ed863aa438a85194deb958b3eef4f80 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 28 Mar 2024 15:11:13 -0300 Subject: [PATCH 037/184] [PAL-466-468] start the carb ratio at the most distant entry time (#628) --- Loop/Managers/LoopDataManager.swift | 7 ++++++- Loop/View Controllers/CarbAbsorptionViewController.swift | 3 ++- Loop/View Models/CarbEntryViewModel.swift | 5 +++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 8af722fc81..7b987c72bd 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -298,7 +298,7 @@ final class LoopDataManager { let forecastEndTime = baseTime.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(.minutes(GlucoseMath.defaultDelta)) - let carbsStart = baseTime.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) + let carbsStart = baseTime.addingTimeInterval(CarbMath.dateAdjustmentPast + .minutes(-1)) // additional minute to handle difference in seconds between carb entry and carb ratio // 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( @@ -1285,3 +1285,8 @@ extension LoopDataManager: DiagnosticReportGenerator { } extension LoopDataManager: LoopControl { } + +extension CarbMath { + public static let dateAdjustmentPast: TimeInterval = .hours(-12) + public static let dateAdjustmentFuture: TimeInterval = .hours(1) +} diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index e17ca700d6..1982b9977d 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -149,6 +149,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif let midnight = Calendar.current.startOfDay(for: Date()) let listStart = min(midnight, chartStartDate, Date(timeIntervalSinceNow: -CarbMath.maximumAbsorptionTimeInterval)) + let listEnd = Date().addingTimeInterval(CarbMath.dateAdjustmentFuture) let shouldUpdateGlucose = currentContext.contains(.glucose) let shouldUpdateCarbs = currentContext.contains(.carbs) @@ -160,7 +161,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif if shouldUpdateGlucose || shouldUpdateCarbs { do { - let review = try await loopDataManager.fetchCarbAbsorptionReview(start: listStart, end: Date()) + let review = try await loopDataManager.fetchCarbAbsorptionReview(start: listStart, end: listEnd) insulinCounteractionEffects = review.effectsVelocities.filterDateRange(chartStartDate, nil) carbStatuses = review.carbStatuses carbsOnBoard = carbStatuses?.getClampedCarbsOnBoard() diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 4fbe81dc42..8f09b4ed78 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -11,6 +11,7 @@ import LoopKit import HealthKit import Combine import LoopCore +import LoopAlgorithm protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate { var defaultAbsorptionTimes: DefaultAbsorptionTimes { get } @@ -64,10 +65,10 @@ final class CarbEntryViewModel: ObservableObject { @Published var time = Date() private var date = Date() var minimumDate: Date { - get { date.addingTimeInterval(.hours(-12)) } + get { date.addingTimeInterval(CarbMath.dateAdjustmentPast) } } var maximumDate: Date { - get { date.addingTimeInterval(.hours(1)) } + get { date.addingTimeInterval(CarbMath.dateAdjustmentFuture) } } @Published var foodType = "" From aa87bd885965551bd71afbebce9a3441d3d7bfb2 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 28 Mar 2024 16:08:37 -0300 Subject: [PATCH 038/184] [PAL-458] should be >= 3 (#629) --- Loop/View Models/CarbEntryViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 8f09b4ed78..53b1d3b1d0 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -343,7 +343,7 @@ final class CarbEntryViewModel: ObservableObject { let delta = endSample.quantity.doubleValue(for: .milligramsPerDeciliter) - startSample.quantity.doubleValue(for: .milligramsPerDeciliter) let velocity = delta / duration.minutes // Unit = mg/dL/m - if velocity > LoopConstants.missedMealWarningGlucoseRiseThreshold { + if velocity >= LoopConstants.missedMealWarningGlucoseRiseThreshold { warnings.insert(.glucoseRisingRapidly) } else { warnings.remove(.glucoseRisingRapidly) From cf8fe2b315b458d8c4b97837eeb0a1c3fc41cdf6 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 2 Apr 2024 09:51:16 -0700 Subject: [PATCH 039/184] [LOOP-4782] 10s Canceled Bolus Status Banner --- .../StatusTableViewController.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 3d08791ec4..b7713d230c 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -20,6 +20,10 @@ import os.log import Combine import WidgetKit +private struct CanceledDose { + let dose: DoseEntry + let delivered: Double +} private extension RefreshContext { static let all: Set = [.status, .glucose, .insulin, .carbs, .targets] @@ -1076,9 +1080,9 @@ final class StatusTableViewController: LoopChartsTableViewController { let totalUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: dose.programmedUnits) let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" - let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: bolusProgressReporter!.progress.deliveredUnits) + let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: dose.deliveredUnits ?? 0) let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" - cell.titleLabel.text = String(format: NSLocalizedString("Bolus Canceled: %1$@ of %2$@ delivered", comment: "The title of the cell indicating a bolus has been canceled. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) + cell.titleLabel.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) return cell case .pumpSuspended(let resuming): let cell = getTitleSubtitleCell() @@ -1225,9 +1229,11 @@ final class StatusTableViewController: LoopChartsTableViewController { vc.delegate = self show(vc, sender: tableView.cellForRow(at: indexPath)) } - case .bolusing(let dose): + case .bolusing(var dose): updateBannerAndHUDandStatusRows(statusRowMode: .cancelingBolus, 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 { @@ -1236,7 +1242,7 @@ final class StatusTableViewController: LoopChartsTableViewController { Task { try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 10) self.canceledDose = nil - self.updateBannerAndHUDandStatusRows(statusRowMode: self.determineStatusRowMode(), newSize: nil, animated: false) + self.updateBannerAndHUDandStatusRows(statusRowMode: self.determineStatusRowMode(), newSize: nil, animated: true) } case .failure(let error): self.canceledDose = nil From 9fffbf763c35002b1cc53b6d9c3d19477995f17c Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 2 Apr 2024 09:52:10 -0700 Subject: [PATCH 040/184] [LOOP-4782] 10s Canceled Bolus Status Banner --- Loop/View Controllers/StatusTableViewController.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index b7713d230c..f9e3abe15e 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -20,11 +20,6 @@ import os.log import Combine import WidgetKit -private struct CanceledDose { - let dose: DoseEntry - let delivered: Double -} - private extension RefreshContext { static let all: Set = [.status, .glucose, .insulin, .carbs, .targets] } From 5957dac99f2a64c3fea7d9ab31f4727a80efe402 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 4 Apr 2024 13:10:46 -0300 Subject: [PATCH 041/184] [PAL-470] hide action area when enter bolus is tapped (#630) --- Loop/Managers/DeviceDataManager.swift | 23 +++++++++++++++++++++++ Loop/Views/BolusEntryView.swift | 16 +++------------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 149d691be9..8064890070 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -595,6 +595,29 @@ final class DeviceDataManager { await self.checkPumpDataAndLoop() } } + +//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() async { await refreshCGM() diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 1d4d1e2c2a..bf0d263abe 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -36,16 +36,12 @@ struct BolusEntryView: View { self.chartSection self.summarySection } - // As of iOS 13, we can't programmatically scroll to the Bolus entry text field. This ugly hack scoots the - // list up instead, so the summarySection is visible and the keyboard shows when you tap "Enter Bolus". - // Unfortunately, after entry, the field scoots back down and remains hidden. So this is not a great solution. - // TODO: Fix this in Xcode 12 when we're building for iOS 14. - .padding(.top, self.shouldAutoScroll(basedOn: geometry) ? -200 : -28) + .padding(.top, -28) .insetGroupedListStyle() self.actionArea - .frame(height: self.isKeyboardVisible ? 0 : nil) - .opacity(self.isKeyboardVisible ? 0 : 1) + .frame(height: self.isKeyboardVisible || shouldBolusEntryBecomeFirstResponder ? 0 : nil) + .opacity(self.isKeyboardVisible || shouldBolusEntryBecomeFirstResponder ? 0 : 1) } .onKeyboardStateChange { state in self.isKeyboardVisible = state.height > 0 @@ -85,12 +81,6 @@ struct BolusEntryView: View { } return Text("Meal Bolus", comment: "Title for bolus entry screen when also entering carbs") } - - private func shouldAutoScroll(basedOn geometry: GeometryProxy) -> Bool { - // Taking a guess of 640 to cover iPhone SE, iPod Touch, and other smaller devices. - // Devices such as the iPhone 11 Pro Max do not need to auto-scroll. - return shouldBolusEntryBecomeFirstResponder && geometry.size.height > 640 - } private var chartSection: some View { Section { From ae28436b41aa7c27b1a858a76088e916e8d47eb2 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 10 Apr 2024 14:15:13 -0300 Subject: [PATCH 042/184] [PAL-502] Recommendation updated (#631) * do not present glucoseNoLongerStale * updated unit tests * remove related code --- Loop/View Models/BolusEntryViewModel.swift | 8 +++----- Loop/Views/BolusEntryView.swift | 5 ----- LoopTests/ViewModels/BolusEntryViewModelTests.swift | 11 +---------- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 6d8ce46bcc..f7a67fb995 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -61,7 +61,6 @@ final class BolusEntryViewModel: ObservableObject { case carbEntryPersistenceFailure case manualGlucoseEntryOutOfAcceptableRange case manualGlucoseEntryPersistenceFailure - case glucoseNoLongerStale case forecastInfo } @@ -495,7 +494,6 @@ final class BolusEntryViewModel: ObservableObject { isManualGlucoseEntryEnabled = false manualGlucoseQuantity = nil manualGlucoseSample = nil - presentAlert(.glucoseNoLongerStale) } } @@ -519,7 +517,7 @@ final class BolusEntryViewModel: ObservableObject { let startDate = now() var input = try await delegate.fetchData(for: startDate, disablingPreMeal: potentialCarbEntry != nil) - var insulinModel = delegate.insulinModel(for: deliveryDelegate?.pumpInsulinType) + let insulinModel = delegate.insulinModel(for: deliveryDelegate?.pumpInsulinType) let enteredBolusDose = SimpleInsulinDose( deliveryType: .bolus, @@ -550,7 +548,7 @@ final class BolusEntryViewModel: ObservableObject { private func updateRecommendedBolusAndNotice(isUpdatingFromUserInput: Bool) async { - guard let delegate = delegate else { + guard let delegate else { assertionFailure("Missing BolusEntryViewModelDelegate") return } @@ -561,7 +559,7 @@ final class BolusEntryViewModel: ObservableObject { do { recommendation = try await computeBolusRecommendation() - if let recommendation, let deliveryDelegate { + if let recommendation, deliveryDelegate != nil { recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) switch recommendation.notice { diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index bf0d263abe..8b54cce2e5 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -421,11 +421,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"), diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 05cac52a87..97faf3384b 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -150,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 From 0103da2fbe5f8de9765c27a8028732c9c6c3349f Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 12 Apr 2024 13:57:41 -0300 Subject: [PATCH 043/184] [LOOP-4841-4843] updated bolus completed check (#632) --- Loop/View Controllers/StatusTableViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 1d2effbb69..73a09c937a 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -688,7 +688,8 @@ final class StatusTableViewController: LoopChartsTableViewController { 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 From b6e9a018b29758ed0ebf841b6eaed519f725ba57 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 25 Apr 2024 17:15:52 -0400 Subject: [PATCH 044/184] Keep DoseStore basal profile current (#635) --- Loop/Managers/LoopAppManager.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 80ea2f046e..dd4fae7b12 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -471,7 +471,6 @@ class LoopAppManager: NSObject { .assign(to: \.automaticDosingStatus.automaticDosingEnabled, on: self) .store(in: &cancellables) - state = state.next await loopDataManager.updateDisplayState() @@ -483,6 +482,21 @@ class LoopAppManager: NSObject { } } .store(in: &cancellables) + + // DoseStore still needs to keep updated basal schedule for now + NotificationCenter.default.publisher(for: .LoopDataUpdated) + .receive(on: DispatchQueue.main) + .sink { [weak self] note in + if let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopUpdateContext.RawValue, + let context = LoopUpdateContext(rawValue: rawContext), + let self, + context == .preferences + { + self.doseStore.basalProfile = self.settingsManager.settings.basalRateSchedule + } + } + .store(in: &cancellables) + } private func loopCycleDidComplete() async { @@ -936,7 +950,7 @@ extension LoopAppManager: DiagnosticReportGenerator { "", await self.carbStore.generateDiagnosticReport(), "", - await self.carbStore.generateDiagnosticReport(), + await self.doseStore.generateDiagnosticReport(), "", await self.mealDetectionManager.generateDiagnosticReport(), "", From b7d8021ee247f4cf3379754d0c8f32be19281ea2 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 29 Apr 2024 13:43:16 -0300 Subject: [PATCH 045/184] [PAL-511] do not reset tidepool service default environment (#636) * do not reset tidepool service default environment * do not block alerts from onboarding --- Loop/Managers/Alerts/AlertManager.swift | 2 +- Loop/Managers/ResetLoopManager.swift | 2 ++ LoopCore/NSUserDefaults.swift | 11 ++++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 41a8a19011..1b616432fe 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -66,7 +66,7 @@ public final class AlertManager { expireAfter: TimeInterval = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */, bluetoothProvider: BluetoothProvider, analyticsServicesManager: AnalyticsServicesManager, - preventIssuanceBeforePlayback: Bool = true + preventIssuanceBeforePlayback: Bool = false ) { self.fileManager = fileManager self.analyticsServicesManager = analyticsServicesManager diff --git a/Loop/Managers/ResetLoopManager.swift b/Loop/Managers/ResetLoopManager.swift index fe3f1710a1..b14829edfa 100644 --- a/Loop/Managers/ResetLoopManager.swift +++ b/Loop/Managers/ResetLoopManager.swift @@ -102,12 +102,14 @@ class ResetLoopManager { private func resetLoopUserDefaults() { // Store values to persist let allowDebugFeatures = UserDefaults.appGroup?.allowDebugFeatures + let defaultEnvironment = UserDefaults.appGroup?.defaultEnvironment // Wipe away whole domain UserDefaults.appGroup?.removePersistentDomain(forName: Bundle.main.appGroupSuiteName) // Restore values to persist UserDefaults.appGroup?.allowDebugFeatures = allowDebugFeatures ?? false + UserDefaults.appGroup?.defaultEnvironment = defaultEnvironment } private func resetLoopDocuments() { diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index ed1ebf5a5c..c4a2cbe172 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -11,7 +11,6 @@ import LoopKit import HealthKit import LoopAlgorithm - extension UserDefaults { private enum Key: String { @@ -24,6 +23,7 @@ extension UserDefaults { case allowSimulators = "com.loopkit.Loop.allowSimulators" case LastMissedMealNotification = "com.loopkit.Loop.lastMissedMealNotification" case userRequestedLoopReset = "com.loopkit.Loop.userRequestedLoopReset" + case defaultEnvironment = "org.tidepool.TidepoolKit.DefaultEnvironment" } public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) @@ -166,6 +166,15 @@ extension UserDefaults { setValue(newValue, forKey: Key.userRequestedLoopReset.rawValue) } } + + public var defaultEnvironment: Data? { + get { + data(forKey: Key.defaultEnvironment.rawValue) + } + set { + setValue(newValue, forKey: Key.defaultEnvironment.rawValue) + } + } public func removeLegacyLoopSettings() { removeObject(forKey: "com.loudnate.Naterade.BasalRateSchedule") From 9238756dfc96a4b5590238f61f163ac0ca366583 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 30 Apr 2024 15:14:47 -0300 Subject: [PATCH 046/184] [COASTAL-1378] allow alerts during onboarding (#637) --- Loop/Managers/Alerts/AlertManager.swift | 3 ++- Loop/Managers/LoopAppManager.swift | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 1b616432fe..26553dbbda 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -66,7 +66,7 @@ public final class AlertManager { expireAfter: TimeInterval = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */, bluetoothProvider: BluetoothProvider, analyticsServicesManager: AnalyticsServicesManager, - preventIssuanceBeforePlayback: Bool = false + preventIssuanceBeforePlayback: Bool = true ) { self.fileManager = fileManager self.analyticsServicesManager = analyticsServicesManager @@ -445,6 +445,7 @@ extension AlertManager { extension AlertManager { func playbackAlertsFromPersistence() { + guard !playbackFinished else { return } playbackAlertsFromAlertStore() } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index dd4fae7b12..32e5db704d 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -513,6 +513,7 @@ class LoopAppManager: NSObject { onboardingManager.launch { DispatchQueue.main.async { self.state = self.state.next + self.alertManager.playbackAlertsFromPersistence() Task { await self.resumeLaunch() } From f597c8a9cd5b896a1441b8ebd5bfe421bf001c5d Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 2 May 2024 18:52:24 -0300 Subject: [PATCH 047/184] [LOOP-4852] clamp to min...max (#633) * guard against a count of 2 or greater * clamp to Int16 range --- .../Extensions/Comparable.swift | 2 +- Common/Models/WatchPredictedGlucose.swift | 2 +- Loop.xcodeproj/project.pbxproj | 12 +++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) rename {WatchApp Extension => Common}/Extensions/Comparable.swift (95%) 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/Models/WatchPredictedGlucose.swift b/Common/Models/WatchPredictedGlucose.swift index 8b32a45f01..d5978eb6ed 100644 --- a/Common/Models/WatchPredictedGlucose.swift +++ b/Common/Models/WatchPredictedGlucose.swift @@ -30,7 +30,7 @@ extension WatchPredictedGlucose: RawRepresentable { var rawValue: RawValue { return [ - "v": values.map { Int16($0.quantity.doubleValue(for: .milligramsPerDeciliter)) }, + "v": values.map { Int16($0.quantity.doubleValue(for: .milligramsPerDeciliter).clamped(to: Double(Int16.min)...Double(Int16.max))) }, "d": values[0].startDate, "i": values[1].startDate.timeIntervalSince(values[0].startDate) ] diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 374529d858..d55f167ef6 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -298,7 +298,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 */; }; @@ -370,6 +369,8 @@ B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42D124228D371C400E43D22 /* AlertMuter.swift */; }; B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */; }; B43DA44124D9C12100CAFF4E /* DismissibleHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */; }; + B455C7332BD14E25002B847E /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B455C7322BD14E25002B847E /* Comparable.swift */; }; + B455C7352BD14E30002B847E /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B455C7322BD14E25002B847E /* Comparable.swift */; }; B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B470F5832AB22B5100049695 /* StatefulPluggable.swift */; }; B48B0BAC24900093009A48DE /* PumpStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */; }; B490A03F24D0550F00F509FA /* GlucoseRangeCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A03E24D0550F00F509FA /* GlucoseRangeCategory.swift */; }; @@ -1214,7 +1215,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 = ""; }; @@ -1279,6 +1279,7 @@ B42D124228D371C400E43D22 /* AlertMuter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertMuter.swift; sourceTree = ""; }; B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowMuteAlertWorkView.swift; sourceTree = ""; }; B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissibleHostingController.swift; sourceTree = ""; }; + B455C7322BD14E25002B847E /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = ""; }; B470F5832AB22B5100049695 /* StatefulPluggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPluggable.swift; sourceTree = ""; }; B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatusHUDView.swift; sourceTree = ""; }; B490A03C24D04F9400F509FA /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; @@ -1878,7 +1879,6 @@ 898ECA64218ABD9A001E9D35 /* CGRect.swift */, 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */, 89FE21AC24AC57E30033F501 /* Collection.swift */, - 89E08FCB242E790C000D719B /* Comparable.swift */, 4F7E8AC420E2AB9600AEA65E /* Date.swift */, 43785E952120E4010057DED1 /* INRelevantShortcutStore+Loop.swift */, 4328E0231CFBE2C500E199AA /* NSUserDefaults+WatchApp.swift */, @@ -2445,9 +2445,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 */, @@ -3596,6 +3597,7 @@ 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */, 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */, + B455C7332BD14E25002B847E /* Comparable.swift in Sources */, A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */, A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */, 1DB1065124467E18005542BD /* AlertManager.swift in Sources */, @@ -3720,6 +3722,7 @@ buildActionMask = 2147483647; files = ( 894F6DDD243C0A2300CCE676 /* CarbAmountLabel.swift in Sources */, + B455C7352BD14E30002B847E /* Comparable.swift in Sources */, 89A605E524327F45009C1096 /* DoseVolumeInput.swift in Sources */, 4372E488213C862B0068E043 /* SampleValue.swift in Sources */, 89A605EB243288E4009C1096 /* TopDownTriangle.swift in Sources */, @@ -3772,7 +3775,6 @@ 89E08FCA242E7714000D719B /* UIFont.swift in Sources */, 4328E0281CFBE2C500E199AA /* CLKComplicationTemplate.swift in Sources */, 4328E01E1CFBE25F00E199AA /* CarbAndBolusFlowController.swift in Sources */, - 89E08FCC242E790C000D719B /* Comparable.swift in Sources */, 432CF87520D8AC950066B889 /* NSUserDefaults+WatchApp.swift in Sources */, 89A1B66F24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */, 43027F0F1DFE0EC900C51989 /* HKUnit.swift in Sources */, From 634799c4e46825638eb78b8053d900f18ce7686b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 9 May 2024 09:39:41 -0400 Subject: [PATCH 048/184] Update to new method signature (#638) --- Loop/Managers/LoopDataManager+CarbAbsorption.swift | 2 +- Loop/Managers/LoopDataManager.swift | 2 +- Loop/Managers/Store Protocols/DoseStoreProtocol.swift | 2 +- Loop/View Controllers/StatusTableViewController.swift | 1 + LoopTests/Mock Stores/MockDoseStore.swift | 4 ++-- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/LoopDataManager+CarbAbsorption.swift b/Loop/Managers/LoopDataManager+CarbAbsorption.swift index fc29c06e8a..d5ea04cba2 100644 --- a/Loop/Managers/LoopDataManager+CarbAbsorption.swift +++ b/Loop/Managers/LoopDataManager+CarbAbsorption.swift @@ -31,7 +31,7 @@ extension LoopDataManager { 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.getDoses( + let doses = try await doseStore.getNormalizedDoseEntries( start: dosesStart, end: end ).map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 7b987c72bd..3fb89074e3 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -283,7 +283,7 @@ final class LoopDataManager { let dosesInputHistory = CarbMath.maximumAbsorptionTimeInterval + InsulinMath.defaultInsulinActivityDuration var dosesStart = baseTime.addingTimeInterval(-dosesInputHistory) - let doses = try await doseStore.getDoses( + let doses = try await doseStore.getNormalizedDoseEntries( start: dosesStart, end: baseTime ) diff --git a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift index 29eb70b7ea..bfbbfcad30 100644 --- a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift @@ -11,7 +11,7 @@ import HealthKit import LoopAlgorithm protocol DoseStoreProtocol: AnyObject { - func getDoses(start: Date?, end: Date?) async throws -> [DoseEntry] + func getNormalizedDoseEntries(start: Date, end: Date?) async throws -> [DoseEntry] func addDoses(_ doses: [DoseEntry], from device: HKDevice?) async throws diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 73a09c937a..19ff3ed2d3 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -488,6 +488,7 @@ final class StatusTableViewController: LoopChartsTableViewController { if currentContext.contains(.insulin) { doseEntries = loopManager.dosesRelativeToBasal.trimmed(from: startDate) + iobValues = loopManager.iobValues.filterDateRange(startDate, nil) totalDelivery = try? await loopManager.doseStore.getTotalUnitsDelivered(since: Calendar.current.startOfDay(for: Date())).value } diff --git a/LoopTests/Mock Stores/MockDoseStore.swift b/LoopTests/Mock Stores/MockDoseStore.swift index 061d258e05..93aaa73068 100644 --- a/LoopTests/Mock Stores/MockDoseStore.swift +++ b/LoopTests/Mock Stores/MockDoseStore.swift @@ -12,10 +12,10 @@ import LoopAlgorithm @testable import Loop class MockDoseStore: DoseStoreProtocol { - func getDoses(start: Date?, end: Date?) async throws -> [LoopKit.DoseEntry] { + func getNormalizedDoseEntries(start: Date, end: Date?) async throws -> [LoopKit.DoseEntry] { return doseHistory ?? [] + addedDoses } - + var addedDoses: [DoseEntry] = [] func addDoses(_ doses: [DoseEntry], from device: HKDevice?) async throws { From 8675590fd4063e9f669cf94183039772002a8177 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 9 May 2024 12:41:39 -0700 Subject: [PATCH 049/184] [LOOP-4863] Notification Permission Alert Updates --- Loop/Managers/AlertPermissionsChecker.swift | 151 ++++++++++++++---- Loop/Managers/Alerts/AlertManager.swift | 86 ++++++---- .../StatusTableViewController.swift | 14 +- 3 files changed, 184 insertions(+), 67 deletions(-) diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index 12c88c5e71..1f90633ef2 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -12,7 +12,7 @@ import LoopKit import SwiftUI protocol AlertPermissionsCheckerDelegate: AnyObject { - func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool) + func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool, permissions: NotificationCenterSettingsFlags) } public class AlertPermissionsChecker: ObservableObject { @@ -106,44 +106,133 @@ 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 timeSensitiveNotificationsDisabled + + var alertTitle: String { + switch self { + case .notificationsDisabled: + NSLocalizedString("Turn On Critical Alerts and Other Safety Notifications", comment: "Notifications disabled alert title") + case .criticalAlertsDisabled: + NSLocalizedString("Turn On Critical Alerts", comment: "Critical alerts disabled alert title") + case .timeSensitiveNotificationsDisabled: + NSLocalizedString("Turn On Time Sensitive Notifications ", comment: "Time sensitive notifications disabled alert title") + } + } + + var notificationTitle: String { + switch self { + case .notificationsDisabled: + NSLocalizedString("Turn On Critical Alerts and other safety notifications", comment: "Notifications disabled notification title") + case .criticalAlertsDisabled: + NSLocalizedString("Turn On Critical Alerts", comment: "Critical alerts disabled notification title") + case .timeSensitiveNotificationsDisabled: + NSLocalizedString("Turn On Time Sensitive Notifications", comment: "Time sensitive notifications disabled alert title") + } + } + + var bannerTitle: String { + switch self { + case .notificationsDisabled: + NSLocalizedString("Critical Alerts and other safety notifications are turned OFF", comment: "Notifications disabled banner title") + case .criticalAlertsDisabled: + NSLocalizedString("Critical alerts are turned OFF", comment: "Critical alerts disabled banner title") + case .timeSensitiveNotificationsDisabled: + NSLocalizedString("Time Sensitive Alerts are turned OFF", comment: "Time sensitive notifications disabled banner title") + } + } + + var alertBody: String { + switch self { + case .notificationsDisabled: + NSLocalizedString("Critical Alerts and other safety 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: "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 .timeSensitiveNotificationsDisabled: + NSLocalizedString("Time Sensitive 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 Time Sensitive Notifications are turned ON.", comment: "Time sensitive notifications disabled alert body") + } + } + + var notificationBody: String { + switch self { + case .notificationsDisabled: + NSLocalizedString("Critical Alerts and other safety notifications are turned OFF. Go to the App to fix the issue now.", comment: "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 .timeSensitiveNotificationsDisabled: + 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 and Critical Alerts ON.", comment: "Notifications disabled banner body") + case .criticalAlertsDisabled: + NSLocalizedString("Fix now by turning Critical Alerts ON.", comment: "Critical alerts disabled banner body") + case .timeSensitiveNotificationsDisabled: + NSLocalizedString("Fix now by turning Time Sensitive alerts ON.", comment: "Time sensitive notifications disabled banner body") + } + } + + var alertIdentifier: LoopKit.Alert.Identifier { + switch self { + case .notificationsDisabled: + Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeNotificationPermissionsAlert") + case .criticalAlertsDisabled: + Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeCrititalAlertPermissionsAlert") + case .timeSensitiveNotificationsDisabled: + Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeTimeSensitiveNotificationPermissionsAlert") + } + } + + var alertContent: LoopKit.Alert.Content { + Alert.Content( + title: alertTitle, + body: alertBody, + acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Notifications permissions disabled alert button") + ) + } + + var alert: LoopKit.Alert { + Alert( + identifier: alertIdentifier, + foregroundContent: nil, + backgroundContent: alertContent, + trigger: .immediate + ) + } + + init?(permissions: NotificationCenterSettingsFlags) { + switch permissions { + case .notificationsDisabled, NotificationCenterSettingsFlags(rawValue: 3), NotificationCenterSettingsFlags(rawValue: 5): + self = .notificationsDisabled + case .criticalAlertsDisabled, NotificationCenterSettingsFlags(rawValue: 6): + self = .criticalAlertsDisabled + case .timeSensitiveNotificationsDisabled: + self = .timeSensitiveNotificationsDisabled + default: + return nil + } + } + } - static func constructUnsafeNotificationPermissionsInAppAlert(acknowledgementCompletion: @escaping () -> Void ) -> UIAlertController { + static func constructUnsafeNotificationPermissionsInAppAlert(alert: UnsafeNotificationPermissionAlert, acknowledgementCompletion: @escaping () -> Void ) -> UIAlertController { dispatchPrecondition(condition: .onQueue(.main)) - let alertController = UIAlertController(title: Self.unsafeNotificationPermissionsAlertContent.title, - message: Self.unsafeNotificationPermissionsAlertContent.body, + let alertController = UIAlertController(title: alert.alertTitle, + message: alert.alertBody, preferredStyle: .alert) let titleImageAttachment = NSTextAttachment() titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.critical) titleImageAttachment.bounds = CGRect(x: titleImageAttachment.bounds.origin.x, y: -10, width: 40, height: 35) let titleWithImage = NSMutableAttributedString(attachment: titleImageAttachment) titleWithImage.append(NSMutableAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: 8)])) - titleWithImage.append(NSMutableAttributedString(string: Self.unsafeNotificationPermissionsAlertContent.title, attributes: [.font: UIFont.preferredFont(forTextStyle: .headline)])) + titleWithImage.append(NSMutableAttributedString(string: alert.alertTitle, attributes: [.font: UIFont.preferredFont(forTextStyle: .headline)])) alertController.setValue(titleWithImage, forKey: "attributedTitle") - - let messageImageAttachment = NSTextAttachment() - messageImageAttachment.image = UIImage(named: "notification-permissions-on") - messageImageAttachment.bounds = CGRect(x: 0, y: -12, width: 228, height: 126) - let messageWithImageAttributed = NSMutableAttributedString(string: "\n", attributes: [.font: UIFont.systemFont(ofSize: 8)]) - messageWithImageAttributed.append(NSMutableAttributedString(string: Self.unsafeNotificationPermissionsAlertContent.body, attributes: [.font: UIFont.preferredFont(forTextStyle: .footnote)])) - messageWithImageAttributed.append(NSMutableAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: 12)])) - messageWithImageAttributed.append(NSMutableAttributedString(attachment: messageImageAttachment)) - alertController.setValue(messageWithImageAttributed, forKey: "attributedMessage") - + alertController.addAction(UIAlertAction(title: NSLocalizedString("Settings", comment: "Label of button that navigation user to iOS Settings"), style: .default, handler: { _ in @@ -178,7 +267,7 @@ extension AlertPermissionsChecker { trigger: .immediate) private func notificationCenterSettingsChanged(_ newValue: NotificationCenterSettingsFlags) { - delegate?.notificationsPermissions(requiresRiskMitigation: newValue.requiresRiskMitigation, scheduledDeliveryEnabled: newValue.scheduledDeliveryEnabled) + delegate?.notificationsPermissions(requiresRiskMitigation: newValue.requiresRiskMitigation, scheduledDeliveryEnabled: newValue.scheduledDeliveryEnabled, permissions: newValue) } } diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 26553dbbda..808e70873f 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -375,14 +375,9 @@ extension AlertManager: AlertIssuer { } private func replayAlert(_ alert: Alert) { - guard alert.identifier != AlertPermissionsChecker.unsafeNotificationPermissionsAlertIdentifier else { - // this alert does not replay through the alert system, since it provides a button to navigate to settings - presentUnsafeNotificationPermissionsInAppAlert() - return - } - - // Only alerts with foreground content are replayed - if alert.foregroundContent != nil { + if let unsafeNotificationPermissionsAlert = AlertPermissionsChecker.UnsafeNotificationPermissionAlert.allCases.first(where: { $0.alertIdentifier == alert.identifier }) { + presentUnsafeNotificationPermissionsInAppAlert(unsafeNotificationPermissionsAlert) + } else if alert.foregroundContent != nil { modalAlertScheduler.scheduleAlert(alert) } } @@ -726,28 +721,47 @@ extension AlertManager: PresetActivationObserver { // MARK: - Issue/Retract Alert Permissions Warning extension AlertManager: AlertPermissionsCheckerDelegate { - func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool) { - if !issueOrRetract(alert: AlertPermissionsChecker.unsafeNotificationPermissionsAlert, - condition: requiresRiskMitigation, - alreadyIssued: UserDefaults.standard.hasIssuedNotificationPermissionsAlert, - setAlreadyIssued: { UserDefaults.standard.hasIssuedNotificationPermissionsAlert = $0 }, - issueHandler: { alert in - // in-app modal is presented with a button to navigate to settings - self.presentUnsafeNotificationPermissionsInAppAlert() - self.userNotificationAlertScheduler.scheduleAlert(alert, muted: self.alertMuter.shouldMuteAlert(alert)) - self.recordIssued(alert: alert) - }, - retractionHandler: { alert in - // need to dismiss the in-app alert outside of the alert system - self.recordRetractedAlert(alert, at: Date()) - self.dismissUnsafeNotificationPermissionsInAppAlert() - }) { - _ = issueOrRetract(alert: AlertPermissionsChecker.scheduledDeliveryEnabledAlert, - condition: scheduledDeliveryEnabled, - alreadyIssued: UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert, - setAlreadyIssued: { UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0 }, - issueHandler: { alert in self.issueAlert(alert) }, - retractionHandler: { alert in self.retractAlert(identifier: alert.identifier) }) + func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool, permissions: NotificationCenterSettingsFlags) { + guard let unsafeNotificationAlert = AlertPermissionsChecker.UnsafeNotificationPermissionAlert(permissions: permissions) else { + return + } + + if !issueOrRetract( + alert: unsafeNotificationAlert.alert, + condition: requiresRiskMitigation, + alreadyIssued: UserDefaults.standard.hasIssuedNotificationPermissionsAlert, + setAlreadyIssued: { + UserDefaults.standard.hasIssuedNotificationPermissionsAlert = $0 + }, + issueHandler: { alert in + // in-app modal is presented with a button to navigate to settings + self.presentUnsafeNotificationPermissionsInAppAlert(unsafeNotificationAlert) + self.userNotificationAlertScheduler.scheduleAlert( + alert, + muted: self.alertMuter.shouldMuteAlert(alert) + ) + self.recordIssued(alert: alert) + }, + retractionHandler: { alert in + // need to dismiss the in-app alert outside of the alert system + self.recordRetractedAlert(alert, at: Date()) + self.dismissUnsafeNotificationPermissionsInAppAlert() + } + ) { + _ = issueOrRetract( + alert: AlertPermissionsChecker.scheduledDeliveryEnabledAlert, + condition: scheduledDeliveryEnabled, + alreadyIssued: UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert, + setAlreadyIssued: { + UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0 + }, + issueHandler: { + alert in self.issueAlert(alert) + }, + retractionHandler: { + alert in self.retractAlert(identifier: alert.identifier) + } + ) } } @@ -773,11 +787,17 @@ extension AlertManager: AlertPermissionsCheckerDelegate { } } - private func presentUnsafeNotificationPermissionsInAppAlert() { + private func presentUnsafeNotificationPermissionsInAppAlert(_ alert: AlertPermissionsChecker.UnsafeNotificationPermissionAlert) { DispatchQueue.main.async { - let alertController = AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert() { [weak self] in - self?.acknowledgeAlert(identifier: AlertPermissionsChecker.unsafeNotificationPermissionsAlertIdentifier) + let alertController = AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert(alert: alert) { [weak self] in + AlertPermissionsChecker.UnsafeNotificationPermissionAlert.allCases.forEach { [weak self] in + UserDefaults.standard.hasIssuedNotificationPermissionsAlert = false + self?.acknowledgeAlert( + identifier: $0.alertIdentifier + ) + } } + self.alertPresenter.present(alertController, animated: true) { [weak self] in // the completion is called after the alert is presented self?.unsafeNotificationPermissionsAlertController = alertController diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 19ff3ed2d3..c077076ff7 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -855,7 +855,14 @@ final class StatusTableViewController: LoopChartsTableViewController { } private class AlertPermissionsDisabledWarningCell: UITableViewCell { + + var alert: AlertPermissionsChecker.UnsafeNotificationPermissionAlert? + override func updateConfiguration(using state: UICellConfigurationState) { + guard let alert else { + return + } + super.updateConfiguration(using: state) let adjustViewForNarrowDisplay = bounds.width < 350 @@ -863,14 +870,14 @@ final class StatusTableViewController: LoopChartsTableViewController { var contentConfig = defaultContentConfiguration().updated(for: state) let titleImageAttachment = NSTextAttachment() titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.white) - let title = NSMutableAttributedString(string: NSLocalizedString(" Safety Notifications are OFF", comment: "Warning text for when Notifications or Critical Alerts Permissions is disabled")) + let title = NSMutableAttributedString(string: alert.bannerTitle) let titleWithImage = NSMutableAttributedString(attachment: titleImageAttachment) titleWithImage.append(title) contentConfig.attributedText = titleWithImage contentConfig.textProperties.color = .white contentConfig.textProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 16 : 18, weight: .bold) contentConfig.textProperties.adjustsFontSizeToFitWidth = true - contentConfig.secondaryText = NSLocalizedString("Fix now by turning Notifications, Critical Alerts and Time Sensitive Notifications ON.", comment: "Secondary text for alerts disabled warning, which appears on the main status screen.") + contentConfig.secondaryText = alert.bannerBody contentConfig.secondaryTextProperties.color = .white contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 13 : 15) contentConfiguration = contentConfig @@ -939,7 +946,8 @@ final class StatusTableViewController: LoopChartsTableViewController { switch Section(rawValue: indexPath.section)! { case .alertWarning: if alertPermissionsChecker.showWarning { - let cell = tableView.dequeueReusableCell(withIdentifier: AlertPermissionsDisabledWarningCell.className, for: indexPath) as! AlertPermissionsDisabledWarningCell + var cell = tableView.dequeueReusableCell(withIdentifier: AlertPermissionsDisabledWarningCell.className, for: indexPath) as! AlertPermissionsDisabledWarningCell + cell.alert = AlertPermissionsChecker.UnsafeNotificationPermissionAlert(permissions: alertPermissionsChecker.notificationCenterSettings) return cell } else { let cell = tableView.dequeueReusableCell(withIdentifier: MuteAlertsWarningCell.className, for: indexPath) as! MuteAlertsWarningCell From f48135fd1ec77221131e92c6ea3b2da74d2ff4fb Mon Sep 17 00:00:00 2001 From: Arwain Date: Thu, 9 May 2024 14:41:31 -1000 Subject: [PATCH 050/184] ConfirmationToggle and LoopStatusCircleView --- Loop.xcodeproj/project.pbxproj | 8 ++ Loop/Views/SettingsView.swift | 42 ++++++---- LoopUI/Views/ConfirmationToggle.swift | 102 ++++++++++++++++++++++++ LoopUI/Views/LoopStatusCircleView.swift | 72 +++++++++++++++++ 4 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 LoopUI/Views/ConfirmationToggle.swift create mode 100644 LoopUI/Views/LoopStatusCircleView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index d55f167ef6..b34aefb57c 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -507,6 +507,8 @@ 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 */; }; + E0BF443F2BEC3D0B00B3358D /* ConfirmationToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0BF443E2BEC3D0B00B3358D /* ConfirmationToggle.swift */; }; + E0BF44412BEC4F2600B3358D /* LoopStatusCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0BF44402BEC4F2600B3358D /* LoopStatusCircleView.swift */; }; 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 */; }; @@ -1607,6 +1609,8 @@ 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 = ""; }; + E0BF443E2BEC3D0B00B3358D /* ConfirmationToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationToggle.swift; sourceTree = ""; }; + E0BF44402BEC4F2600B3358D /* LoopStatusCircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStatusCircleView.swift; 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 = ""; }; @@ -2358,6 +2362,8 @@ B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */, B4E96D5A248A8229002DABAD /* StatusBarHUDView.swift */, B4E96D56248A7B0F002DABAD /* StatusHighlightHUDView.swift */, + E0BF443E2BEC3D0B00B3358D /* ConfirmationToggle.swift */, + E0BF44402BEC4F2600B3358D /* LoopStatusCircleView.swift */, ); path = Views; sourceTree = ""; @@ -3949,6 +3955,7 @@ B4AC0D3F24B9005300CDB0A1 /* UIImage.swift in Sources */, B4E96D5B248A8229002DABAD /* StatusBarHUDView.swift in Sources */, 4F7528A11DFE200B00C322D6 /* BasalStateView.swift in Sources */, + E0BF443F2BEC3D0B00B3358D /* ConfirmationToggle.swift in Sources */, 43BFF0C61E465A4400FF19A9 /* UIColor+HIG.swift in Sources */, 4F7528A01DFE1F9D00C322D6 /* LoopStateView.swift in Sources */, B491B0A324D0B66D004CBE8F /* Color.swift in Sources */, @@ -3965,6 +3972,7 @@ B490A04124D0559D00F509FA /* DeviceLifecycleProgressState.swift in Sources */, 4F75289C1DFE1F6000C322D6 /* GlucoseHUDView.swift in Sources */, B4E96D53248A7386002DABAD /* GlucoseValueHUDView.swift in Sources */, + E0BF44412BEC4F2600B3358D /* LoopStatusCircleView.swift in Sources */, B4E96D57248A7B0F002DABAD /* StatusHighlightHUDView.swift in Sources */, B42C951424A3C76000857C73 /* CGMStatusHUDViewModel.swift in Sources */, 4F75289E1DFE1F6000C322D6 /* LoopCompletionHUDView.swift in Sources */, diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index c3ec98b8dd..299e2a1c54 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -11,6 +11,8 @@ import LoopKitUI import MockKit import SwiftUI import HealthKit +import LoopUI + public struct SettingsView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @@ -59,6 +61,7 @@ public struct SettingsView: View { @State private var sheet: Destination.Sheet? var localizedAppNameAndVersion: String + var closedLoopUnavailable: Bool = false public init(viewModel: SettingsViewModel, localizedAppNameAndVersion: String) { self.viewModel = viewModel @@ -79,7 +82,7 @@ public struct SettingsView: View { } alertManagementSection if viewModel.pumpManagerSettingsViewModel.isSetUp() { - configurationSection + therapySection } deviceSettingsSection if FeatureFlags.allowExperimentalFeatures { @@ -216,18 +219,29 @@ extension SettingsView { } private var loopSection: some View { - Section(header: SectionHeader(label: localizedAppNameAndVersion)) { - Toggle(isOn: closedLoopToggleState) { - VStack(alignment: .leading) { - Text("Closed Loop", comment: "The title text for the looping enabled switch cell") - .padding(.vertical, 3) - if !viewModel.isOnboardingComplete { - DescriptiveText(label: NSLocalizedString("Closed Loop requires Setup to be Complete", comment: "The description text for the looping enabled switch cell when onboarding is not complete")) - } else if let closedLoopDescriptiveText = viewModel.closedLoopDescriptiveText { - DescriptiveText(label: closedLoopDescriptiveText) + Section(header: SectionHeader(label: NSLocalizedString("Tidepool Loop", comment: "Loop section header label"))) { + ConfirmationToggle( + isOn: closedLoopToggleState, + confirmationValue: false, + alertTitle: "Are you sure you want to turn automation OFF?", + alertBody: "Your pump and CGM will continue operating but the app will not make automatic adjustments. You will receive your scheduled basal rate(s).", + action: .init(label: {Text("Yes, turn OFF")}) + ) { + HStack { + LoopStatusCircleView(closedLoop: closedLoopToggleState, closedLoopUnavailable: closedLoopUnavailable) + .padding(.trailing) + 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) + .padding() } .disabled(!viewModel.isOnboardingComplete || !viewModel.isClosedLoopAllowed) } @@ -281,14 +295,14 @@ 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 App Sounds", comment: "Alert Permissions descriptive text") ) } } } - private var configurationSection: some View { - Section(header: SectionHeader(label: NSLocalizedString("Configuration", comment: "The title of the Configuration section in settings"))) { + private var therapySection: some View { + Section { LargeButton(action: { sheet = .therapySettings }, includeArrow: true, imageView: Image("Therapy Icon"), @@ -314,7 +328,7 @@ extension SettingsView { } private var deviceSettingsSection: some View { - Section { + Section(header: SectionHeader(label: NSLocalizedString("Devices", comment: ""))) { pumpSection cgmSection } diff --git a/LoopUI/Views/ConfirmationToggle.swift b/LoopUI/Views/ConfirmationToggle.swift new file mode 100644 index 0000000000..ad1efea911 --- /dev/null +++ b/LoopUI/Views/ConfirmationToggle.swift @@ -0,0 +1,102 @@ +// +// ConfirmationToggle.swift +// LoopUI +// +// Created by Arwain Karlin on 5/8/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI + +public struct ConfirmationToggle: View { + + public struct Action { + let role: ButtonRole? + let action: () -> Void + let label: () -> ActionLabel + + public init(role: ButtonRole? = nil, action: @escaping () -> Void = {}, label: @escaping () -> ActionLabel) { + self.role = role + self.action = action + self.label = label + } + } + + /// Label of the Toggle + let label: Label + + /// The value of the toggle to confirm before setting + /// - A value of false means the confirmation alert will present before setting the isOn Binding to false + /// - A value of true means the confirmation alert will present before setting the isOn Binding to true + let confirmationValue: Bool + + /// The title of the alert presented when asked to confirm toggle selection + let alertTitle: LocalizedStringKey + + let alertBody: LocalizedStringKey + + /// Action metadata of the confirmation action + let action: Action + + @State private var showConfirmAlert: Bool = false + + @Binding var isOn: Bool + + public init( + isOn: Binding, + confirmationValue: Bool, + alertTitle: LocalizedStringKey, + alertBody: LocalizedStringKey, + action: Action, + showConfirmationAlert: Bool = false, + @ViewBuilder label: () -> Label + ) { + self.label = label() + self.confirmationValue = confirmationValue + self.alertTitle = alertTitle + self.alertBody = alertBody + self.action = action + self._isOn = isOn + self.showConfirmAlert = showConfirmAlert + } + + public var body: some View { + Toggle( + isOn: Binding( + get: { isOn }, + set: { newValue in + if newValue == confirmationValue { + isOn = !confirmationValue + showConfirmAlert = true + } else { + isOn = newValue + } + } + ) + ) { + label + } + .alert( + alertTitle, + isPresented: $showConfirmAlert, + actions: { + Button( + role: .cancel, + action: {}, + label: { Text("Cancel") } + ) + + Button( + role: action.role, + action: { + isOn = confirmationValue + action.action() + }, + label: action.label + ) + }, + message: {Text(alertBody)}) + } +} + diff --git a/LoopUI/Views/LoopStatusCircleView.swift b/LoopUI/Views/LoopStatusCircleView.swift new file mode 100644 index 0000000000..5065555ed5 --- /dev/null +++ b/LoopUI/Views/LoopStatusCircleView.swift @@ -0,0 +1,72 @@ +// +// LoopStatusCircleView.swift +// LoopUI +// +// Created by Arwain Karlin on 5/8/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI + +@available(iOSApplicationExtension 17.0, *) +public struct LoopStatusCircleView: View { + + public enum Status { + case closedLoopOn + case closedLoopOff + case closedLoopUnavailable + + var color: Color { + switch self { + case .closedLoopOn: + return .green // Use guidanceColors + case .closedLoopOff: + return .red // Use guidanceColors + case .closedLoopUnavailable: + return .orange // Use guidanceColors + } + } + } + + @Binding var closedLoop: Bool + var closedLoopUnavailable: Bool + + @State var loopStatus: Status + + public init( + closedLoop: Binding, + closedLoopUnavailable: Bool + ) { + self._closedLoop = closedLoop + self.closedLoopUnavailable = closedLoopUnavailable + self.loopStatus = closedLoopUnavailable ? .closedLoopUnavailable : (closedLoop.wrappedValue ? .closedLoopOn : .closedLoopOff) + } + + public var body: some View { + Circle() + .trim(from: closedLoop ? 0 : 0.25, to: 1) + .rotation(.degrees(-135)) + .stroke(loopStatus.color, lineWidth: 6) + .frame(width: 30) + .animation(.default, value: closedLoop) + .onChange(of: closedLoop) { _, newValue in + withAnimation { + if closedLoopUnavailable { + loopStatus = .closedLoopUnavailable + } else { + loopStatus = newValue ? .closedLoopOn : .closedLoopOff + } + } + } + .onChange(of: closedLoopUnavailable) { _, newValue in + withAnimation { + if newValue { + loopStatus = .closedLoopUnavailable + } else { + loopStatus = closedLoop ? .closedLoopOn : .closedLoopOff + } + } + } + } +} From 79c82fc195f7c83450552423cae4311501f3077c Mon Sep 17 00:00:00 2001 From: Arwain Date: Fri, 10 May 2024 09:52:59 -1000 Subject: [PATCH 051/184] Update LoopStatusCircleView rendering --- Loop/View Models/SettingsViewModel.swift | 2 +- Loop/Views/SettingsView.swift | 7 +++---- LoopUI/Views/ConfirmationToggle.swift | 8 ++++---- LoopUI/Views/LoopStatusCircleView.swift | 17 ++++++++--------- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 16f5a71f72..ea185c6666 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -93,7 +93,7 @@ public class SettingsViewModel: ObservableObject { } } - var closedLoopPreference: Bool { + @Published var closedLoopPreference: Bool { didSet { delegate?.dosingEnabledChanged(closedLoopPreference) } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 299e2a1c54..1a9e3d8921 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -61,7 +61,6 @@ public struct SettingsView: View { @State private var sheet: Destination.Sheet? var localizedAppNameAndVersion: String - var closedLoopUnavailable: Bool = false public init(viewModel: SettingsViewModel, localizedAppNameAndVersion: String) { self.viewModel = viewModel @@ -223,12 +222,12 @@ extension SettingsView { ConfirmationToggle( isOn: closedLoopToggleState, confirmationValue: false, - alertTitle: "Are you sure you want to turn automation OFF?", - alertBody: "Your pump and CGM will continue operating but the app will not make automatic adjustments. You will receive your scheduled basal rate(s).", + 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"), action: .init(label: {Text("Yes, turn OFF")}) ) { HStack { - LoopStatusCircleView(closedLoop: closedLoopToggleState, closedLoopUnavailable: closedLoopUnavailable) + LoopStatusCircleView(closedLoop: closedLoopToggleState, closedLoopAvailable: $viewModel.isClosedLoopAllowed) .padding(.trailing) VStack(alignment: .leading) { Text("Closed Loop", comment: "The title text for the looping enabled switch cell") diff --git a/LoopUI/Views/ConfirmationToggle.swift b/LoopUI/Views/ConfirmationToggle.swift index ad1efea911..a88842bbfd 100644 --- a/LoopUI/Views/ConfirmationToggle.swift +++ b/LoopUI/Views/ConfirmationToggle.swift @@ -32,9 +32,9 @@ public struct ConfirmationToggle: View { let confirmationValue: Bool /// The title of the alert presented when asked to confirm toggle selection - let alertTitle: LocalizedStringKey + let alertTitle: String - let alertBody: LocalizedStringKey + let alertBody: String /// Action metadata of the confirmation action let action: Action @@ -46,8 +46,8 @@ public struct ConfirmationToggle: View { public init( isOn: Binding, confirmationValue: Bool, - alertTitle: LocalizedStringKey, - alertBody: LocalizedStringKey, + alertTitle: String, + alertBody: String, action: Action, showConfirmationAlert: Bool = false, @ViewBuilder label: () -> Label diff --git a/LoopUI/Views/LoopStatusCircleView.swift b/LoopUI/Views/LoopStatusCircleView.swift index 5065555ed5..bf482a62d9 100644 --- a/LoopUI/Views/LoopStatusCircleView.swift +++ b/LoopUI/Views/LoopStatusCircleView.swift @@ -9,7 +9,6 @@ import Foundation import SwiftUI -@available(iOSApplicationExtension 17.0, *) public struct LoopStatusCircleView: View { public enum Status { @@ -30,17 +29,17 @@ public struct LoopStatusCircleView: View { } @Binding var closedLoop: Bool - var closedLoopUnavailable: Bool + @Binding var closedLoopAvailable: Bool @State var loopStatus: Status public init( closedLoop: Binding, - closedLoopUnavailable: Bool + closedLoopAvailable: Binding ) { self._closedLoop = closedLoop - self.closedLoopUnavailable = closedLoopUnavailable - self.loopStatus = closedLoopUnavailable ? .closedLoopUnavailable : (closedLoop.wrappedValue ? .closedLoopOn : .closedLoopOff) + self._closedLoopAvailable = closedLoopAvailable + self.loopStatus = !closedLoopAvailable.wrappedValue ? .closedLoopUnavailable : (closedLoop.wrappedValue ? .closedLoopOn : .closedLoopOff) } public var body: some View { @@ -50,18 +49,18 @@ public struct LoopStatusCircleView: View { .stroke(loopStatus.color, lineWidth: 6) .frame(width: 30) .animation(.default, value: closedLoop) - .onChange(of: closedLoop) { _, newValue in + .onChange(of: closedLoop) {newValue in withAnimation { - if closedLoopUnavailable { + if !closedLoopAvailable { loopStatus = .closedLoopUnavailable } else { loopStatus = newValue ? .closedLoopOn : .closedLoopOff } } } - .onChange(of: closedLoopUnavailable) { _, newValue in + .onChange(of: closedLoopAvailable) {newValue in withAnimation { - if newValue { + if !newValue { loopStatus = .closedLoopUnavailable } else { loopStatus = closedLoop ? .closedLoopOn : .closedLoopOff From a9a649458faab3879440d534ffb07b9fab421b1e Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 22 May 2024 14:40:26 -0700 Subject: [PATCH 052/184] [LOOP-4870] Misc cleanup --- Loop/View Models/SettingsViewModel.swift | 2 +- Loop/Views/SettingsView.swift | 12 ++-- LoopUI/Views/ConfirmationToggle.swift | 50 ++++++++++------- LoopUI/Views/LoopStatusCircleView.swift | 70 ++++++++++++++---------- 4 files changed, 78 insertions(+), 56 deletions(-) diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index ea185c6666..51f83957b2 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -80,7 +80,7 @@ public class SettingsViewModel: ObservableObject { let isOnboardingComplete: Bool let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? - @Published var isClosedLoopAllowed: Bool + var isClosedLoopAllowed: Bool var closedLoopDescriptiveText: String? { return delegate?.closedLoopDescriptiveText diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 1a9e3d8921..42665c0248 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -221,14 +221,18 @@ extension SettingsView { Section(header: SectionHeader(label: NSLocalizedString("Tidepool Loop", comment: "Loop section header label"))) { ConfirmationToggle( isOn: closedLoopToggleState, - confirmationValue: false, + 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"), - action: .init(label: {Text("Yes, turn OFF")}) + confirmAction: .init(role: .destructive, label: { Text("Yes, turn OFF") }) ) { HStack { - LoopStatusCircleView(closedLoop: closedLoopToggleState, closedLoopAvailable: $viewModel.isClosedLoopAllowed) - .padding(.trailing) + LoopStatusCircleView( + closedLoop: closedLoopToggleState, + isClosedLoopAllowed: viewModel.isClosedLoopAllowed + ) + .padding(.trailing) + 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")) diff --git a/LoopUI/Views/ConfirmationToggle.swift b/LoopUI/Views/ConfirmationToggle.swift index a88842bbfd..3233fb6a63 100644 --- a/LoopUI/Views/ConfirmationToggle.swift +++ b/LoopUI/Views/ConfirmationToggle.swift @@ -13,50 +13,58 @@ public struct ConfirmationToggle: View { public struct Action { let role: ButtonRole? - let action: () -> Void let label: () -> ActionLabel - public init(role: ButtonRole? = nil, action: @escaping () -> Void = {}, label: @escaping () -> ActionLabel) { + public init(role: ButtonRole? = nil, label: @escaping () -> ActionLabel) { self.role = role - self.action = action self.label = label } } /// Label of the Toggle - let label: Label + private let label: Label /// The value of the toggle to confirm before setting /// - A value of false means the confirmation alert will present before setting the isOn Binding to false /// - A value of true means the confirmation alert will present before setting the isOn Binding to true - let confirmationValue: Bool + private let confirmOn: Bool /// The title of the alert presented when asked to confirm toggle selection - let alertTitle: String + private let alertTitle: String - let alertBody: String + /// The body of the alert presented when asked to confirm toggle selection + private let alertBody: String /// Action metadata of the confirmation action - let action: Action + private let confirmAction: Action + /// Determines display of alert confirming toggled state @State private var showConfirmAlert: Bool = false - @Binding var isOn: Bool + /// State of the toggle + @Binding private var isOn: Bool + /// Creates a ConfirmationToggle + /// - Parameters: + /// - isOn: State of the toggle + /// - confirmOn: The value of the toggle to confirm before setting + /// - alertTitle: The title of the alert presented when asked to confirm toggle selection + /// - alertBody: The body of the alert presented when asked to confirm toggle selection + /// - confirmAction: Action metadata of the confirmation action + /// - label: Label of the Toggle public init( isOn: Binding, - confirmationValue: Bool, + confirmOn: Bool, alertTitle: String, alertBody: String, - action: Action, - showConfirmationAlert: Bool = false, + confirmAction: Action, @ViewBuilder label: () -> Label ) { self.label = label() - self.confirmationValue = confirmationValue + self.confirmOn = confirmOn self.alertTitle = alertTitle self.alertBody = alertBody - self.action = action + self.confirmAction = confirmAction self._isOn = isOn self.showConfirmAlert = showConfirmAlert } @@ -66,8 +74,8 @@ public struct ConfirmationToggle: View { isOn: Binding( get: { isOn }, set: { newValue in - if newValue == confirmationValue { - isOn = !confirmationValue + if newValue == confirmOn { + isOn = !confirmOn showConfirmAlert = true } else { isOn = newValue @@ -88,15 +96,15 @@ public struct ConfirmationToggle: View { ) Button( - role: action.role, + role: confirmAction.role, action: { - isOn = confirmationValue - action.action() + isOn = confirmOn }, - label: action.label + label: confirmAction.label ) }, - message: {Text(alertBody)}) + message: { Text(alertBody) } + ) } } diff --git a/LoopUI/Views/LoopStatusCircleView.swift b/LoopUI/Views/LoopStatusCircleView.swift index bf482a62d9..e831a36c06 100644 --- a/LoopUI/Views/LoopStatusCircleView.swift +++ b/LoopUI/Views/LoopStatusCircleView.swift @@ -6,65 +6,75 @@ // Copyright © 2024 LoopKit Authors. All rights reserved. // -import Foundation +import LoopKitUI import SwiftUI public struct LoopStatusCircleView: View { - public enum Status { + @Environment(\.guidanceColors) private var guidanceColors + + private enum Status { case closedLoopOn case closedLoopOff - case closedLoopUnavailable + case closedLoopNotAllowed - var color: Color { + func color(from guidanceColors: GuidanceColors) -> Color { switch self { case .closedLoopOn: - return .green // Use guidanceColors + return guidanceColors.acceptable case .closedLoopOff: - return .red // Use guidanceColors - case .closedLoopUnavailable: - return .orange // Use guidanceColors + return guidanceColors.critical + case .closedLoopNotAllowed: + return guidanceColors.warning } } } - @Binding var closedLoop: Bool - @Binding var closedLoopAvailable: Bool + /// Determines whether a full ring or broken ring will show and which color the ring will be + /// - a value of `false` will show a broken ring that'll appear red if ``isClosedLoopAllowed`` is `true` and yellow if ``isClosedLoopAllowed`` is `false` + /// - a value of `true` will show an unbroken ring that'll appear green if ``isClosedLoopAllowed`` is `true` and yellow if ``isClosedLoopAllowed`` is `false` + @Binding private var closedLoop: Bool + + /// Determines whether the ring should be yellow to indicate the availability of closed loop + /// - a value of `false` will always show a yellow ring (broken or unbroken) + /// - a value of `true` will show green when ``closedLoop`` is `true` and red when ``closedLoop`` is `false` + private var isClosedLoopAllowed: Bool + + /// The aggregated ``Status`` derived from ``closedLoop`` and ``isClosedLoopAllowed`` + @State private var loopStatus: Status - @State var loopStatus: Status + /// Creates a LoopStatusCircleView + /// - Parameters: + /// - closedLoop: Binding to the current state of the user's closed loop setting + /// - isClosedLoopAllowed: Binding to whether closed loop therapy is currently allowed public init( closedLoop: Binding, - closedLoopAvailable: Binding + isClosedLoopAllowed: Bool ) { self._closedLoop = closedLoop - self._closedLoopAvailable = closedLoopAvailable - self.loopStatus = !closedLoopAvailable.wrappedValue ? .closedLoopUnavailable : (closedLoop.wrappedValue ? .closedLoopOn : .closedLoopOff) + self.isClosedLoopAllowed = isClosedLoopAllowed + self.loopStatus = !isClosedLoopAllowed ? .closedLoopNotAllowed : (closedLoop.wrappedValue ? .closedLoopOn : .closedLoopOff) } public var body: some View { Circle() .trim(from: closedLoop ? 0 : 0.25, to: 1) .rotation(.degrees(-135)) - .stroke(loopStatus.color, lineWidth: 6) + .stroke(loopStatus.color(from: guidanceColors), lineWidth: 6) .frame(width: 30) - .animation(.default, value: closedLoop) - .onChange(of: closedLoop) {newValue in - withAnimation { - if !closedLoopAvailable { - loopStatus = .closedLoopUnavailable - } else { - loopStatus = newValue ? .closedLoopOn : .closedLoopOff - } + .onChange(of: closedLoop) { + if !isClosedLoopAllowed { + loopStatus = .closedLoopNotAllowed + } else { + loopStatus = $0 ? .closedLoopOn : .closedLoopOff } } - .onChange(of: closedLoopAvailable) {newValue in - withAnimation { - if !newValue { - loopStatus = .closedLoopUnavailable - } else { - loopStatus = closedLoop ? .closedLoopOn : .closedLoopOff - } + .onChange(of: isClosedLoopAllowed) { + if !$0 { + loopStatus = .closedLoopNotAllowed + } else { + loopStatus = closedLoop ? .closedLoopOn : .closedLoopOff } } } From df920e6ef214742de0d1e61668954ff6b28006e9 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 22 May 2024 15:14:33 -0700 Subject: [PATCH 053/184] [LOOP-4870] Misc cleanup --- Loop/View Models/SettingsViewModel.swift | 2 +- Loop/Views/SettingsView.swift | 3 ++- LoopUI/Views/LoopStatusCircleView.swift | 20 ++++++++++++-------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 51f83957b2..665fecdf93 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -80,7 +80,7 @@ public class SettingsViewModel: ObservableObject { let isOnboardingComplete: Bool let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? - var isClosedLoopAllowed: Bool + @Published private(set) var isClosedLoopAllowed: Bool var closedLoopDescriptiveText: String? { return delegate?.closedLoopDescriptiveText diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 42665c0248..306c4314c8 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -229,7 +229,8 @@ extension SettingsView { HStack { LoopStatusCircleView( closedLoop: closedLoopToggleState, - isClosedLoopAllowed: viewModel.isClosedLoopAllowed + isClosedLoopAllowed: viewModel.isClosedLoopAllowed, + colorPalette: .loopStatus ) .padding(.trailing) diff --git a/LoopUI/Views/LoopStatusCircleView.swift b/LoopUI/Views/LoopStatusCircleView.swift index e831a36c06..8f8e17eb2d 100644 --- a/LoopUI/Views/LoopStatusCircleView.swift +++ b/LoopUI/Views/LoopStatusCircleView.swift @@ -11,21 +11,19 @@ import SwiftUI public struct LoopStatusCircleView: View { - @Environment(\.guidanceColors) private var guidanceColors - private enum Status { case closedLoopOn case closedLoopOff case closedLoopNotAllowed - func color(from guidanceColors: GuidanceColors) -> Color { + func color(from palette: StateColorPalette) -> Color { switch self { case .closedLoopOn: - return guidanceColors.acceptable + return Color(palette.normal) case .closedLoopOff: - return guidanceColors.critical + return Color(palette.error) case .closedLoopNotAllowed: - return guidanceColors.warning + return Color(palette.warning) } } } @@ -39,6 +37,9 @@ public struct LoopStatusCircleView: View { /// - a value of `false` will always show a yellow ring (broken or unbroken) /// - a value of `true` will show green when ``closedLoop`` is `true` and red when ``closedLoop`` is `false` private var isClosedLoopAllowed: Bool + + /// Determines the colors used for different states + private let colorPalette: StateColorPalette /// The aggregated ``Status`` derived from ``closedLoop`` and ``isClosedLoopAllowed`` @State private var loopStatus: Status @@ -48,12 +49,15 @@ public struct LoopStatusCircleView: View { /// - Parameters: /// - closedLoop: Binding to the current state of the user's closed loop setting /// - isClosedLoopAllowed: Binding to whether closed loop therapy is currently allowed + /// - colorPalette: Determines the colors used for different states public init( closedLoop: Binding, - isClosedLoopAllowed: Bool + isClosedLoopAllowed: Bool, + colorPalette: StateColorPalette ) { self._closedLoop = closedLoop self.isClosedLoopAllowed = isClosedLoopAllowed + self.colorPalette = colorPalette self.loopStatus = !isClosedLoopAllowed ? .closedLoopNotAllowed : (closedLoop.wrappedValue ? .closedLoopOn : .closedLoopOff) } @@ -61,7 +65,7 @@ public struct LoopStatusCircleView: View { Circle() .trim(from: closedLoop ? 0 : 0.25, to: 1) .rotation(.degrees(-135)) - .stroke(loopStatus.color(from: guidanceColors), lineWidth: 6) + .stroke(loopStatus.color(from: colorPalette), lineWidth: 6) .frame(width: 30) .onChange(of: closedLoop) { if !isClosedLoopAllowed { From 3044b00730c9c2dae3d92de60d4652114964fd54 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 22 May 2024 15:17:37 -0700 Subject: [PATCH 054/184] [LOOP-4870] Misc cleanup --- Loop/Views/SettingsView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 306c4314c8..5bb9237d00 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -218,13 +218,17 @@ extension SettingsView { } private var loopSection: some View { - Section(header: SectionHeader(label: NSLocalizedString("Tidepool Loop", comment: "Loop section header label"))) { + Section( + header: SectionHeader( + label: NSLocalizedString("Tidepool Loop", comment: "Loop section header label") + ) + ) { 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(role: .destructive, label: { Text("Yes, turn OFF") }) + confirmAction: .init(label: { Text("Yes, turn OFF") }) ) { HStack { LoopStatusCircleView( From 8e28c7fb9d5f4730b8fc403ee578005d9b31cbee Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 24 May 2024 00:40:56 -0700 Subject: [PATCH 055/184] [PAL-615] Scenario Loading Fixes --- Loop/Managers/TestingScenariosManager.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index eff494ebd2..017accdadb 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -275,11 +275,13 @@ extension TestingScenariosManager { if let error { bail(with: error) } else { - testingPumpManager?.reservoirFillFraction = 1.0 - testingPumpManager?.injectPumpEvents(instance.pumpEvents) - testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) - self.activeScenario = scenario - completion(nil) + Task { + testingPumpManager?.reservoirFillFraction = 1.0 + testingPumpManager?.injectPumpEvents(instance.pumpEvents) + await testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) + self.activeScenario = scenario + completion(nil) + } } } } @@ -327,7 +329,9 @@ extension TestingScenariosManager { case .success(let setupUIResult): switch setupUIResult { case .createdAndOnboarded(let cgmManager): - return cgmManager as! TestingCGMManager + let cgmManager = cgmManager as! TestingCGMManager + cgmManager.autoStartTrace = false + return cgmManager default: fatalError("Failed to reload CGM manager. UI interaction required for setup") } @@ -341,6 +345,9 @@ extension TestingScenariosManager { fatalError("\(#function) should be invoked only when scenarios are enabled") } + activeScenario = nil + activeScenarioURL = nil + deviceManager.deleteTestingPumpData { error in guard error == nil else { completion(error!) From ef4acc663b998e95a4e05ab0c58e297f9569b6d8 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 24 May 2024 01:20:00 -0700 Subject: [PATCH 056/184] [PAL-615] Scenario Loading Fixes --- Loop/Managers/TestingScenariosManager.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index 017accdadb..e219be5694 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -275,13 +275,11 @@ extension TestingScenariosManager { if let error { bail(with: error) } else { - Task { testingPumpManager?.reservoirFillFraction = 1.0 testingPumpManager?.injectPumpEvents(instance.pumpEvents) - await testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) + testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) self.activeScenario = scenario completion(nil) - } } } } @@ -345,9 +343,6 @@ extension TestingScenariosManager { fatalError("\(#function) should be invoked only when scenarios are enabled") } - activeScenario = nil - activeScenarioURL = nil - deviceManager.deleteTestingPumpData { error in guard error == nil else { completion(error!) From 768946a9bc7c19ce2d3329f0c97bc96d1eca6e4f Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 24 May 2024 01:20:27 -0700 Subject: [PATCH 057/184] [PAL-615] Scenario Loading Fixes --- Loop/Managers/TestingScenariosManager.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index e219be5694..42ec4e0c1e 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -275,11 +275,11 @@ extension TestingScenariosManager { if let error { bail(with: error) } else { - testingPumpManager?.reservoirFillFraction = 1.0 - testingPumpManager?.injectPumpEvents(instance.pumpEvents) - testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) - self.activeScenario = scenario - completion(nil) + testingPumpManager?.reservoirFillFraction = 1.0 + testingPumpManager?.injectPumpEvents(instance.pumpEvents) + testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) + self.activeScenario = scenario + completion(nil) } } } From b34efc8e5684164b918cac314acb3a49b06d1752 Mon Sep 17 00:00:00 2001 From: Arwain Date: Fri, 24 May 2024 21:01:50 -0600 Subject: [PATCH 058/184] Update SettingsView section header to use localizedAppNameAndVersion --- Loop/Views/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 5bb9237d00..32baff8852 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -220,7 +220,7 @@ extension SettingsView { private var loopSection: some View { Section( header: SectionHeader( - label: NSLocalizedString("Tidepool Loop", comment: "Loop section header label") + label: localizedAppNameAndVersion.description ) ) { ConfirmationToggle( From dc77394f21183f0a1361ba853de0c90af0f79d0c Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 29 May 2024 17:08:06 -0300 Subject: [PATCH 059/184] reload CGM manager is async --- Loop/Managers/TestingScenariosManager.swift | 46 ++++++++++++++------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index 42ec4e0c1e..966fcc8d9f 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -242,7 +242,9 @@ extension TestingScenariosManager { if instance.hasCGMData { if let cgmManager = deviceManager.cgmManager as? TestingCGMManager { if instance.shouldReloadManager?.cgm == true { - testingCGMManager = reloadCGMManager(withIdentifier: cgmManager.pluginIdentifier) + Task { + testingCGMManager = await reloadCGMManager(withIdentifier: cgmManager.pluginIdentifier) + } } else { testingCGMManager = cgmManager } @@ -320,21 +322,37 @@ extension TestingScenariosManager { } } - 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): - let cgmManager = cgmManager as! TestingCGMManager - cgmManager.autoStartTrace = false - return cgmManager + func reloadCGMManager(withIdentifier cgmManagerIdentifier: String) async -> TestingCGMManager { + var cgmManager: TestingCGMManager? = nil + try? await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + reloadCGMManager(withIdentifier: cgmManagerIdentifier) { testingCGMManager in + cgmManager = testingCGMManager + continuation.resume() + } + } + guard let cgmManager else { + fatalError("Failed to reload CGM manager. UI interaction required for setup") + } + + return cgmManager + } + + private func reloadCGMManager(withIdentifier cgmManagerIdentifier: String, completion: @escaping (TestingCGMManager) -> Void) { + 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 + completion(cgmManager) + default: + fatalError("Failed to reload CGM manager. UI interaction required for setup") + } default: - fatalError("Failed to reload CGM manager. UI interaction required for setup") + fatalError("Failed to reload CGM manager. Setup failed") } - default: - fatalError("Failed to reload CGM manager. Setup failed") } } From a64473af09ce7c44d7563172dbdb8b136a6f0cd5 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 29 May 2024 13:18:28 -0700 Subject: [PATCH 060/184] cleanup --- Loop/Managers/TestingScenariosManager.swift | 143 +++++++++----------- 1 file changed, 65 insertions(+), 78 deletions(-) diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index 966fcc8d9f..69af2eb992 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -224,73 +224,73 @@ extension TestingScenariosManager { } 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 { - Task { - testingCGMManager = await reloadCGMManager(withIdentifier: cgmManager.pluginIdentifier) + Task { + guard FeatureFlags.scenariosEnabled else { + fatalError("\(#function) should be invoked only when scenarios are enabled") + } + + 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 = await reloadCGMManager(withIdentifier: cgmManager.pluginIdentifier) + } else { + testingCGMManager = cgmManager } } else { - testingCGMManager = cgmManager + bail(with: ScenarioLoadingError.noTestingCGMManagerEnabled) + return } - } 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) + + if instance.hasPumpData { + if let pumpManager = deviceManager.pumpManager as? TestingPumpManager { + if instance.shouldReloadManager?.pump == true { + testingPumpManager = reloadPumpManager(withIdentifier: pumpManager.pluginIdentifier) + } else { + testingPumpManager = pumpManager + } } else { - testingPumpManager = pumpManager + bail(with: ScenarioLoadingError.noTestingPumpManagerEnabled) + return } - } else { - bail(with: ScenarioLoadingError.noTestingPumpManagerEnabled) - return } - } - wipeExistingData { error in - guard error == nil else { - bail(with: error!) - return - } + wipeExistingData { error in + guard error == nil else { + bail(with: error!) + return + } - self.carbStore.addNewCarbEntries(entries: instance.carbEntries) { error in - if let error { - bail(with: error) - } else { - testingPumpManager?.reservoirFillFraction = 1.0 - testingPumpManager?.injectPumpEvents(instance.pumpEvents) - testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) - self.activeScenario = scenario - completion(nil) + self.carbStore.addNewCarbEntries(entries: instance.carbEntries) { error in + if let error { + bail(with: error) + } else { + testingPumpManager?.reservoirFillFraction = 1.0 + testingPumpManager?.injectPumpEvents(instance.pumpEvents) + testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) + self.activeScenario = scenario + completion(nil) + } } } - } - - 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) + + 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) + } } } } @@ -322,36 +322,23 @@ extension TestingScenariosManager { } } - func reloadCGMManager(withIdentifier cgmManagerIdentifier: String) async -> TestingCGMManager { - var cgmManager: TestingCGMManager? = nil - try? await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - reloadCGMManager(withIdentifier: cgmManagerIdentifier) { testingCGMManager in - cgmManager = testingCGMManager - continuation.resume() - } - } - guard let cgmManager else { - fatalError("Failed to reload CGM manager. UI interaction required for setup") - } - - return cgmManager - } - - private func reloadCGMManager(withIdentifier cgmManagerIdentifier: String, completion: @escaping (TestingCGMManager) -> Void) { - 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 - completion(cgmManager) + 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. UI interaction required for setup") + fatalError("Failed to reload CGM manager. Setup failed") } - default: - fatalError("Failed to reload CGM manager. Setup failed") } } } From 2933402dbe255f141835828f50009d40abf50eb1 Mon Sep 17 00:00:00 2001 From: Arwain Date: Mon, 3 Jun 2024 13:47:46 -1000 Subject: [PATCH 061/184] Move LoopStatusCircleView and ConfirmationToggle to LoopKit --- Loop.xcodeproj/project.pbxproj | 8 -- LoopUI/Views/ConfirmationToggle.swift | 110 ----------------------- LoopUI/Views/LoopCompletionHUDView.swift | 16 ++-- LoopUI/Views/LoopStatusCircleView.swift | 85 ------------------ 4 files changed, 8 insertions(+), 211 deletions(-) delete mode 100644 LoopUI/Views/ConfirmationToggle.swift delete mode 100644 LoopUI/Views/LoopStatusCircleView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index b34aefb57c..d55f167ef6 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -507,8 +507,6 @@ 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 */; }; - E0BF443F2BEC3D0B00B3358D /* ConfirmationToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0BF443E2BEC3D0B00B3358D /* ConfirmationToggle.swift */; }; - E0BF44412BEC4F2600B3358D /* LoopStatusCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0BF44402BEC4F2600B3358D /* LoopStatusCircleView.swift */; }; 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 */; }; @@ -1609,8 +1607,6 @@ 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 = ""; }; - E0BF443E2BEC3D0B00B3358D /* ConfirmationToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationToggle.swift; sourceTree = ""; }; - E0BF44402BEC4F2600B3358D /* LoopStatusCircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStatusCircleView.swift; 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 = ""; }; @@ -2362,8 +2358,6 @@ B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */, B4E96D5A248A8229002DABAD /* StatusBarHUDView.swift */, B4E96D56248A7B0F002DABAD /* StatusHighlightHUDView.swift */, - E0BF443E2BEC3D0B00B3358D /* ConfirmationToggle.swift */, - E0BF44402BEC4F2600B3358D /* LoopStatusCircleView.swift */, ); path = Views; sourceTree = ""; @@ -3955,7 +3949,6 @@ B4AC0D3F24B9005300CDB0A1 /* UIImage.swift in Sources */, B4E96D5B248A8229002DABAD /* StatusBarHUDView.swift in Sources */, 4F7528A11DFE200B00C322D6 /* BasalStateView.swift in Sources */, - E0BF443F2BEC3D0B00B3358D /* ConfirmationToggle.swift in Sources */, 43BFF0C61E465A4400FF19A9 /* UIColor+HIG.swift in Sources */, 4F7528A01DFE1F9D00C322D6 /* LoopStateView.swift in Sources */, B491B0A324D0B66D004CBE8F /* Color.swift in Sources */, @@ -3972,7 +3965,6 @@ B490A04124D0559D00F509FA /* DeviceLifecycleProgressState.swift in Sources */, 4F75289C1DFE1F6000C322D6 /* GlucoseHUDView.swift in Sources */, B4E96D53248A7386002DABAD /* GlucoseValueHUDView.swift in Sources */, - E0BF44412BEC4F2600B3358D /* LoopStatusCircleView.swift in Sources */, B4E96D57248A7B0F002DABAD /* StatusHighlightHUDView.swift in Sources */, B42C951424A3C76000857C73 /* CGMStatusHUDViewModel.swift in Sources */, 4F75289E1DFE1F6000C322D6 /* LoopCompletionHUDView.swift in Sources */, diff --git a/LoopUI/Views/ConfirmationToggle.swift b/LoopUI/Views/ConfirmationToggle.swift deleted file mode 100644 index 3233fb6a63..0000000000 --- a/LoopUI/Views/ConfirmationToggle.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// ConfirmationToggle.swift -// LoopUI -// -// Created by Arwain Karlin on 5/8/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import Foundation -import SwiftUI - -public struct ConfirmationToggle: View { - - public struct Action { - let role: ButtonRole? - let label: () -> ActionLabel - - public init(role: ButtonRole? = nil, label: @escaping () -> ActionLabel) { - self.role = role - self.label = label - } - } - - /// Label of the Toggle - private let label: Label - - /// The value of the toggle to confirm before setting - /// - A value of false means the confirmation alert will present before setting the isOn Binding to false - /// - A value of true means the confirmation alert will present before setting the isOn Binding to true - private let confirmOn: Bool - - /// The title of the alert presented when asked to confirm toggle selection - private let alertTitle: String - - /// The body of the alert presented when asked to confirm toggle selection - private let alertBody: String - - /// Action metadata of the confirmation action - private let confirmAction: Action - - /// Determines display of alert confirming toggled state - @State private var showConfirmAlert: Bool = false - - /// State of the toggle - @Binding private var isOn: Bool - - /// Creates a ConfirmationToggle - /// - Parameters: - /// - isOn: State of the toggle - /// - confirmOn: The value of the toggle to confirm before setting - /// - alertTitle: The title of the alert presented when asked to confirm toggle selection - /// - alertBody: The body of the alert presented when asked to confirm toggle selection - /// - confirmAction: Action metadata of the confirmation action - /// - label: Label of the Toggle - public init( - isOn: Binding, - confirmOn: Bool, - alertTitle: String, - alertBody: String, - confirmAction: Action, - @ViewBuilder label: () -> Label - ) { - self.label = label() - self.confirmOn = confirmOn - self.alertTitle = alertTitle - self.alertBody = alertBody - self.confirmAction = confirmAction - self._isOn = isOn - self.showConfirmAlert = showConfirmAlert - } - - public var body: some View { - Toggle( - isOn: Binding( - get: { isOn }, - set: { newValue in - if newValue == confirmOn { - isOn = !confirmOn - showConfirmAlert = true - } else { - isOn = newValue - } - } - ) - ) { - label - } - .alert( - alertTitle, - isPresented: $showConfirmAlert, - actions: { - Button( - role: .cancel, - action: {}, - label: { Text("Cancel") } - ) - - Button( - role: confirmAction.role, - action: { - isOn = confirmOn - }, - label: confirmAction.label - ) - }, - message: { Text(alertBody) } - ) - } -} - diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index b0e6b1387b..6523f532d5 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -69,20 +69,20 @@ public final class LoopCompletionHUDView: BaseHUDView { super.stateColorsDidUpdate() updateTintColor() } - - private func updateTintColor() { - let tintColor: UIColor? - + + private var _tintColor: UIColor? { switch freshness { case .fresh: - tintColor = stateColors?.normal + return stateColors?.normal case .aging: - tintColor = stateColors?.warning + return stateColors?.warning case .stale: - tintColor = stateColors?.error + return stateColors?.error } + } - self.tintColor = tintColor + private func updateTintColor() { + self.tintColor = _tintColor } private func initTimer(_ startDate: Date) { diff --git a/LoopUI/Views/LoopStatusCircleView.swift b/LoopUI/Views/LoopStatusCircleView.swift deleted file mode 100644 index 8f8e17eb2d..0000000000 --- a/LoopUI/Views/LoopStatusCircleView.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// LoopStatusCircleView.swift -// LoopUI -// -// Created by Arwain Karlin on 5/8/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopKitUI -import SwiftUI - -public struct LoopStatusCircleView: View { - - private enum Status { - case closedLoopOn - case closedLoopOff - case closedLoopNotAllowed - - func color(from palette: StateColorPalette) -> Color { - switch self { - case .closedLoopOn: - return Color(palette.normal) - case .closedLoopOff: - return Color(palette.error) - case .closedLoopNotAllowed: - return Color(palette.warning) - } - } - } - - /// Determines whether a full ring or broken ring will show and which color the ring will be - /// - a value of `false` will show a broken ring that'll appear red if ``isClosedLoopAllowed`` is `true` and yellow if ``isClosedLoopAllowed`` is `false` - /// - a value of `true` will show an unbroken ring that'll appear green if ``isClosedLoopAllowed`` is `true` and yellow if ``isClosedLoopAllowed`` is `false` - @Binding private var closedLoop: Bool - - /// Determines whether the ring should be yellow to indicate the availability of closed loop - /// - a value of `false` will always show a yellow ring (broken or unbroken) - /// - a value of `true` will show green when ``closedLoop`` is `true` and red when ``closedLoop`` is `false` - private var isClosedLoopAllowed: Bool - - /// Determines the colors used for different states - private let colorPalette: StateColorPalette - - /// The aggregated ``Status`` derived from ``closedLoop`` and ``isClosedLoopAllowed`` - @State private var loopStatus: Status - - - /// Creates a LoopStatusCircleView - /// - Parameters: - /// - closedLoop: Binding to the current state of the user's closed loop setting - /// - isClosedLoopAllowed: Binding to whether closed loop therapy is currently allowed - /// - colorPalette: Determines the colors used for different states - public init( - closedLoop: Binding, - isClosedLoopAllowed: Bool, - colorPalette: StateColorPalette - ) { - self._closedLoop = closedLoop - self.isClosedLoopAllowed = isClosedLoopAllowed - self.colorPalette = colorPalette - self.loopStatus = !isClosedLoopAllowed ? .closedLoopNotAllowed : (closedLoop.wrappedValue ? .closedLoopOn : .closedLoopOff) - } - - public var body: some View { - Circle() - .trim(from: closedLoop ? 0 : 0.25, to: 1) - .rotation(.degrees(-135)) - .stroke(loopStatus.color(from: colorPalette), lineWidth: 6) - .frame(width: 30) - .onChange(of: closedLoop) { - if !isClosedLoopAllowed { - loopStatus = .closedLoopNotAllowed - } else { - loopStatus = $0 ? .closedLoopOn : .closedLoopOff - } - } - .onChange(of: isClosedLoopAllowed) { - if !$0 { - loopStatus = .closedLoopNotAllowed - } else { - loopStatus = closedLoop ? .closedLoopOn : .closedLoopOff - } - } - } -} From 4131fad71fdc385c26e9024528b1e1e0266ffabe Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 3 Jun 2024 23:10:17 -0700 Subject: [PATCH 062/184] [LOOP-4883] Simple Calculator UI Updates --- Loop/Views/SimpleBolusView.swift | 65 ++++++++++++++------------------ 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index 6d255f9fb0..903caf4c8c 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -17,7 +17,7 @@ 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 @@ -43,48 +43,35 @@ struct SimpleBolusView: View { } var body: some View { - GeometryReader { geometry in - VStack(spacing: 0) { - List() { - self.infoSection - self.summarySection - } - // As of iOS 13, we can't programmatically scroll to the Bolus entry text field. This ugly hack scoots the - // list up instead, so the summarySection is visible and the keyboard shows when you tap "Enter Bolus". - // Unfortunately, after entry, the field scoots back down and remains hidden. So this is not a great solution. - // TODO: Fix this in Xcode 12 when we're building for iOS 14. - .padding(.top, self.shouldAutoScroll(basedOn: geometry) ? -200 : 0) - .insetGroupedListStyle() - .navigationBarTitle(Text(self.title), displayMode: .inline) - - self.actionArea - .frame(height: self.isKeyboardVisible ? 0 : nil) - .opacity(self.isKeyboardVisible ? 0 : 1) + VStack(spacing: 0) { + List() { + self.infoSection + self.summarySection } - .onKeyboardStateChange { state in - self.isKeyboardVisible = state.height > 0 - - if state.height == 0 { - // Ensure tapping 'Enter Bolus' can make the text field the first responder again - self.shouldBolusEntryBecomeFirstResponder = false - } + .insetGroupedListStyle() + .navigationBarTitle(Text(self.title), displayMode: .inline) + + self.actionArea + .frame(height: self.isKeyboardVisible ? 0 : nil) + .opacity(self.isKeyboardVisible ? 0 : 1) + } + .onKeyboardStateChange { state in + self.isKeyboardVisible = state.height > 0 + + if state.height == 0 { + // Ensure tapping 'Enter Bolus' can make the text field the first responder again + self.shouldGlucoseEntryBecomeFirstResponder = false } - .keyboardAware() - .edgesIgnoringSafeArea(self.isKeyboardVisible ? [] : .bottom) - .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) } + .keyboardAware() + .edgesIgnoringSafeArea(self.isKeyboardVisible ? [] : .bottom) + .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) } private func formatGlucose(_ quantity: HKQuantity) -> String { return displayGlucosePreference.format(quantity) } - private func shouldAutoScroll(basedOn geometry: GeometryProxy) -> Bool { - // Taking a guess of 640 to cover iPhone SE, iPod Touch, and other smaller devices. - // Devices such as the iPhone 11 Pro Max do not need to auto-scroll. - shouldBolusEntryBecomeFirstResponder && geometry.size.height < 640 - } - private var infoSection: some View { HStack { Image("Open Loop") @@ -110,10 +97,10 @@ struct SimpleBolusView: View { private var summarySection: some View { Section { + glucoseEntryRow if viewModel.displayMealEntry { carbEntryRow } - glucoseEntryRow recommendedBolusRow bolusEntryRow } @@ -151,9 +138,13 @@ struct SimpleBolusView: View { font: .heavy(.title1), textAlignment: .right, keyboardType: .decimalPad, + shouldBecomeFirstResponder: shouldGlucoseEntryBecomeFirstResponder, maxLength: 4, doneButtonColor: .loopAccent ) + .onAppear { + shouldGlucoseEntryBecomeFirstResponder = true + } glucoseUnitsLabel } @@ -208,7 +199,6 @@ struct SimpleBolusView: View { textColor: .loopAccent, textAlignment: .right, keyboardType: .decimalPad, - shouldBecomeFirstResponder: shouldBolusEntryBecomeFirstResponder, maxLength: 5, doneButtonColor: .loopAccent ) @@ -222,6 +212,7 @@ struct SimpleBolusView: View { private var carbUnitsLabel: some View { Text(QuantityFormatter(for: .gram()).localizedUnitStringWithPlurality()) + .foregroundColor(Color(.secondaryLabel)) } private var glucoseUnitsLabel: some View { @@ -251,7 +242,7 @@ struct SimpleBolusView: View { Button( action: { if self.viewModel.actionButtonAction == .enterBolus { - self.shouldBolusEntryBecomeFirstResponder = true + self.shouldGlucoseEntryBecomeFirstResponder = true } else { Task { if await viewModel.saveAndDeliver() { From 7333fa33b7d2ade21210eb84dbd1e4a9b4add8ad Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 4 Jun 2024 12:58:50 -0700 Subject: [PATCH 063/184] [LOOP-4870] LoopCircleView Updates --- .../Components/LoopCircleEntryView.swift | 25 +++++++++ .../Components/LoopCircleView.swift | 40 -------------- .../Widgets/SystemStatusWidget.swift | 2 +- Loop.xcodeproj/project.pbxproj | 16 +++--- Loop/Managers/LoopDataManager.swift | 4 +- Loop/Models/AutomaticDosingStatus.swift | 8 +-- .../StatusTableViewController.swift | 7 +-- Loop/View Models/SettingsViewModel.swift | 38 ++++++++++---- Loop/Views/SettingsView.swift | 11 ++-- LoopCore/LoopCompletionFreshness.swift | 52 ------------------- LoopUI/Views/LoopCompletionHUDView.swift | 2 +- .../ComplicationController.swift | 1 - 12 files changed, 75 insertions(+), 131 deletions(-) create mode 100644 Loop Widget Extension/Components/LoopCircleEntryView.swift delete mode 100644 Loop Widget Extension/Components/LoopCircleView.swift delete mode 100644 LoopCore/LoopCompletionFreshness.swift diff --git a/Loop Widget Extension/Components/LoopCircleEntryView.swift b/Loop Widget Extension/Components/LoopCircleEntryView.swift new file mode 100644 index 0000000000..4b9451b19f --- /dev/null +++ b/Loop Widget Extension/Components/LoopCircleEntryView.swift @@ -0,0 +1,25 @@ +// +// LoopCircleEntryView.swift +// Loop +// +// Created by Noah Brauner on 8/15/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import SwiftUI + +struct LoopCircleEntryView: View { + var entry: StatusWidgetTimelimeEntry + + var body: some View { + let closedLoop = entry.closeLoop + let lastLoopCompleted = entry.lastLoopCompleted ?? Date().addingTimeInterval(.minutes(16)) + let age = abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) + let freshness = LoopCompletionFreshness(age: age) + + LoopCircleView(closedLoop: closedLoop, freshness: freshness) + .disabled(entry.contextIsStale) + } +} 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/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift index a64096d2ad..a5aa71336c 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -20,7 +20,7 @@ struct SystemStatusWidgetEntryView : View { HStack(alignment: .center, spacing: 5) { VStack(alignment: .center, spacing: 5) { HStack(alignment: .center, spacing: 15) { - LoopCircleView(entry: entry) + LoopCircleEntryView(entry: entry) GlucoseView(entry: entry) } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index d55f167ef6..c5ee2f2e83 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -28,7 +28,7 @@ 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 */; }; + 14B1737528AEDBF6006CCD7C /* LoopCircleEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1737128AEDBF6006CCD7C /* LoopCircleEntryView.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 */; }; @@ -465,8 +465,6 @@ C19C8C1E28663B040056D5E4 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628320A7A3BE00C4BE6F /* LoopKit.framework */; }; C19C8C1F28663B040056D5E4 /* LoopKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628320A7A3BE00C4BE6F /* LoopKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C19C8C21286776C20056D5E4 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C19C8C20286776C20056D5E4 /* LoopKit.framework */; }; - C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */; }; - C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */; }; C19F48742560ABFB003632D7 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; C1AD4200256D61E500164DDD /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AD41FF256D61E500164DDD /* Comparable.swift */; }; C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */; }; @@ -744,7 +742,7 @@ 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 = ""; }; + 14B1737128AEDBF6006CCD7C /* LoopCircleEntryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopCircleEntryView.swift; sourceTree = ""; }; 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsViewModel.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 = ""; }; @@ -1155,6 +1153,7 @@ 7DD382791F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Interface.strings; sourceTree = ""; }; 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetLoopManager.swift; sourceTree = ""; }; 80F864E52433BF5D0026EC26 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; + 840A2F0D2C0F978E003D5E90 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; @@ -1478,7 +1477,6 @@ C19E387C298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; C19E387D298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; C19E387E298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; - C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCompletionFreshness.swift; sourceTree = ""; }; C19F496225630504003632D7 /* Minizip.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Minizip.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C1AD41FF256D61E500164DDD /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = ""; }; C1AD48CE298639890013B994 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2128,7 +2126,6 @@ 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, 43C05CB721EBEA54006FB252 /* HKUnit.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, - C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, 431E73471FF95A900069B5F7 /* PersistenceController.swift */, 43D9FFD121EAE05D00AF44BF /* LoopCore.h */, @@ -2477,7 +2474,7 @@ children = ( 14B1736E28AEDBF6006CCD7C /* BasalView.swift */, 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */, - 14B1737128AEDBF6006CCD7C /* LoopCircleView.swift */, + 14B1737128AEDBF6006CCD7C /* LoopCircleEntryView.swift */, 84AA81E22A4A36FB000B658B /* SystemActionLink.swift */, 84AA81E62A4A4DEF000B658B /* PumpView.swift */, ); @@ -2629,6 +2626,7 @@ 968DCD53F724DE56FFE51920 /* Frameworks */ = { isa = PBXGroup; children = ( + 840A2F0D2C0F978E003D5E90 /* LoopKitUI.framework */, C159C82E286787EF00A86EC0 /* LoopKit.framework */, C159C8212867859800A86EC0 /* MockKitUI.framework */, C159C8192867857000A86EC0 /* LoopKitUI.framework */, @@ -3510,7 +3508,7 @@ 14B1737428AEDBF6006CCD7C /* GlucoseView.swift in Sources */, 14B1737328AEDBF6006CCD7C /* SystemStatusWidget.swift in Sources */, 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */, - 14B1737528AEDBF6006CCD7C /* LoopCircleView.swift in Sources */, + 14B1737528AEDBF6006CCD7C /* LoopCircleEntryView.swift in Sources */, 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3818,7 +3816,6 @@ E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */, C1D0B6312986D4D90098D215 /* LocalizedString.swift in Sources */, 43C05CB821EBEA54006FB252 /* HKUnit.swift in Sources */, - C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */, 43D9002021EB209400AF44BF /* NSTimeInterval.swift in Sources */, C16575762539FEF3004AE16E /* LoopCoreConstants.swift in Sources */, C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */, @@ -3838,7 +3835,6 @@ E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */, C1D0B6302986D4D90098D215 /* LocalizedString.swift in Sources */, 43C05CB921EBEA54006FB252 /* HKUnit.swift in Sources */, - C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */, 43D9FFFB21EAF3D300AF44BF /* NSTimeInterval.swift in Sources */, C16575752539FD60004AE16E /* LoopCoreConstants.swift in Sources */, C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 3fb89074e3..d922ccd245 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -75,7 +75,7 @@ enum LoopUpdateContext: Int { } @MainActor -final class LoopDataManager { +final class LoopDataManager: ObservableObject { nonisolated static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext" // Represents the current state of the loop algorithm for display @@ -98,7 +98,7 @@ final class LoopDataManager { displayState.output?.recommendation?.automatic } - private(set) var lastLoopCompleted: Date? + @Published private(set) var lastLoopCompleted: Date? var deliveryDelegate: DeliveryDelegate? diff --git a/Loop/Models/AutomaticDosingStatus.swift b/Loop/Models/AutomaticDosingStatus.swift index ae3930c122..f717a80c32 100644 --- a/Loop/Models/AutomaticDosingStatus.swift +++ b/Loop/Models/AutomaticDosingStatus.swift @@ -8,11 +8,11 @@ import Foundation -class AutomaticDosingStatus { - @Published var automaticDosingEnabled: Bool - @Published var isAutomaticDosingAllowed: Bool +public class AutomaticDosingStatus: ObservableObject { + @Published public var automaticDosingEnabled: Bool + @Published public var isAutomaticDosingAllowed: Bool - init(automaticDosingEnabled: Bool, + public init(automaticDosingEnabled: Bool, isAutomaticDosingAllowed: Bool) { self.automaticDosingEnabled = automaticDosingEnabled diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index c077076ff7..5d01031f5d 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1605,13 +1605,14 @@ final class StatusTableViewController: LoopChartsTableViewController { criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - initialDosingEnabled: self.settingsManager.settings.dosingEnabled, - isClosedLoopAllowed: automaticDosingStatus.$isAutomaticDosingAllowed, + initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, + lastLoopCompletion: loopManager.$lastLoopCompleted, availableSupports: supportManager.availableSupports, isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceManager, - delegate: self) + delegate: self + ) let hostingController = DismissibleHostingController( rootView: SettingsView(viewModel: viewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucosePreference) diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 665fecdf93..0d0b892bdd 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -79,8 +79,10 @@ public class SettingsViewModel: ObservableObject { let sensitivityOverridesEnabled: Bool let isOnboardingComplete: Bool let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? - - @Published private(set) var isClosedLoopAllowed: Bool + + @Published private(set) var automaticDosingStatus: AutomaticDosingStatus + + @Published private(set) var lastLoopCompletion: Date? var closedLoopDescriptiveText: String? { return delegate?.closedLoopDescriptiveText @@ -103,6 +105,12 @@ public class SettingsViewModel: ObservableObject { availableSupports.contains(where: { $0.showsDeleteTestDataUI }) } + var loopStatusCircleFreshness: LoopCompletionFreshness { + let lastLoopCompletion = lastLoopCompletion ?? Date().addingTimeInterval(.minutes(16)) + let age = abs(min(0, lastLoopCompletion.timeIntervalSinceNow)) + return LoopCompletionFreshness(age: age) + } + lazy private var cancellables = Set() public init(alertPermissionsChecker: AlertPermissionsChecker, @@ -115,8 +123,9 @@ public class SettingsViewModel: ObservableObject { therapySettings: @escaping () -> TherapySettings, sensitivityOverridesEnabled: Bool, initialDosingEnabled: Bool, - isClosedLoopAllowed: Published.Publisher, + automaticDosingStatus: AutomaticDosingStatus, automaticDosingStrategy: AutomaticDosingStrategy, + lastLoopCompletion: Published.Publisher, availableSupports: [SupportUI], isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, @@ -132,8 +141,9 @@ public class SettingsViewModel: ObservableObject { self.therapySettings = therapySettings self.sensitivityOverridesEnabled = sensitivityOverridesEnabled self.closedLoopPreference = initialDosingEnabled - self.isClosedLoopAllowed = false + self.automaticDosingStatus = automaticDosingStatus self.automaticDosingStrategy = automaticDosingStrategy + self.lastLoopCompletion = nil self.availableSupports = availableSupports self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate @@ -156,18 +166,22 @@ public class SettingsViewModel: ObservableObject { self?.objectWillChange.send() } .store(in: &cancellables) - - isClosedLoopAllowed - .assign(to: \.isClosedLoopAllowed, on: self) + automaticDosingStatus.objectWillChange.sink { [weak self] in + self?.objectWillChange.send() + } + .store(in: &cancellables) + lastLoopCompletion + .assign(to: \.lastLoopCompletion, on: self) .store(in: &cancellables) + } } // For previews only @MainActor extension SettingsViewModel { - fileprivate class FakeClosedLoopAllowedPublisher { - @Published var mockIsClosedLoopAllowed: Bool = false + fileprivate class FakeLastLoopCompletionPublisher { + @Published var mockLastLoopCompletion: Date? = nil } static var preview: SettingsViewModel { @@ -181,11 +195,13 @@ extension SettingsViewModel { therapySettings: { TherapySettings() }, sensitivityOverridesEnabled: false, initialDosingEnabled: true, - isClosedLoopAllowed: FakeClosedLoopAllowedPublisher().$mockIsClosedLoopAllowed, + automaticDosingStatus: AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true), automaticDosingStrategy: .automaticBolus, + lastLoopCompletion: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, availableSupports: [], isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, - delegate: nil) + delegate: nil + ) } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 32baff8852..1df094e56e 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -185,7 +185,7 @@ public struct SettingsView: View { private var closedLoopToggleState: Binding { Binding( - get: { self.viewModel.isClosedLoopAllowed && self.viewModel.closedLoopPreference }, + get: { self.viewModel.automaticDosingStatus.isAutomaticDosingAllowed && self.viewModel.closedLoopPreference }, set: { self.viewModel.closedLoopPreference = $0 } ) } @@ -231,10 +231,9 @@ extension SettingsView { confirmAction: .init(label: { Text("Yes, turn OFF") }) ) { HStack { - LoopStatusCircleView( - closedLoop: closedLoopToggleState, - isClosedLoopAllowed: viewModel.isClosedLoopAllowed, - colorPalette: .loopStatus + LoopCircleView( + closedLoop: viewModel.automaticDosingStatus.automaticDosingEnabled, + freshness: viewModel.loopStatusCircleFreshness ) .padding(.trailing) @@ -251,7 +250,7 @@ extension SettingsView { .fixedSize(horizontal: false, vertical: true) .padding() } - .disabled(!viewModel.isOnboardingComplete || !viewModel.isClosedLoopAllowed) + .disabled(!viewModel.isOnboardingComplete || !viewModel.automaticDosingStatus.isAutomaticDosingAllowed) } } 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/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index 6523f532d5..4794fda543 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 { diff --git a/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index 0343a17d94..a79ec10924 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -9,7 +9,6 @@ import ClockKit import WatchKit import LoopKit -import LoopCore import os.log import LoopAlgorithm From 198c7f5cce70e4f93c2d585e7c7b161666a8385a Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 4 Jun 2024 14:44:47 -0700 Subject: [PATCH 064/184] [LOOP-4870] LoopCircleView Updates --- .../Components/LoopCircleEntryView.swift | 25 ------------------- .../Widgets/SystemStatusWidget.swift | 10 +++++++- Loop.xcodeproj/project.pbxproj | 4 --- 3 files changed, 9 insertions(+), 30 deletions(-) delete mode 100644 Loop Widget Extension/Components/LoopCircleEntryView.swift diff --git a/Loop Widget Extension/Components/LoopCircleEntryView.swift b/Loop Widget Extension/Components/LoopCircleEntryView.swift deleted file mode 100644 index 4b9451b19f..0000000000 --- a/Loop Widget Extension/Components/LoopCircleEntryView.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// LoopCircleEntryView.swift -// Loop -// -// Created by Noah Brauner on 8/15/22. -// Copyright © 2022 LoopKit Authors. All rights reserved. -// - -import LoopKit -import LoopKitUI -import SwiftUI - -struct LoopCircleEntryView: View { - var entry: StatusWidgetTimelimeEntry - - var body: some View { - let closedLoop = entry.closeLoop - let lastLoopCompleted = entry.lastLoopCompleted ?? Date().addingTimeInterval(.minutes(16)) - let age = abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) - let freshness = LoopCompletionFreshness(age: age) - - LoopCircleView(closedLoop: closedLoop, freshness: freshness) - .disabled(entry.contextIsStale) - } -} diff --git a/Loop Widget Extension/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift index a5aa71336c..e7ae6054a0 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -6,6 +6,7 @@ // Copyright © 2022 LoopKit Authors. All rights reserved. // +import LoopKit import LoopUI import SwiftUI import WidgetKit @@ -15,12 +16,19 @@ struct SystemStatusWidgetEntryView : View { @Environment(\.widgetFamily) private var widgetFamily var entry: StatusWidgetTimelineProvider.Entry + + var freshness: LoopCompletionFreshness { + let lastLoopCompleted = entry.lastLoopCompleted ?? Date().addingTimeInterval(.minutes(16)) + let age = abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) + return LoopCompletionFreshness(age: age) + } var body: some View { HStack(alignment: .center, spacing: 5) { VStack(alignment: .center, spacing: 5) { HStack(alignment: .center, spacing: 15) { - LoopCircleEntryView(entry: entry) + LoopCircleView(closedLoop: entry.closeLoop, freshness: freshness) + .disabled(entry.contextIsStale) GlucoseView(entry: entry) } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index c5ee2f2e83..9bea905ea5 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -28,7 +28,6 @@ 14B1737228AEDBF6006CCD7C /* BasalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1736E28AEDBF6006CCD7C /* BasalView.swift */; }; 14B1737328AEDBF6006CCD7C /* SystemStatusWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1736F28AEDBF6006CCD7C /* SystemStatusWidget.swift */; }; 14B1737428AEDBF6006CCD7C /* GlucoseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */; }; - 14B1737528AEDBF6006CCD7C /* LoopCircleEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1737128AEDBF6006CCD7C /* LoopCircleEntryView.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 */; }; @@ -742,7 +741,6 @@ 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 /* LoopCircleEntryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopCircleEntryView.swift; sourceTree = ""; }; 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsViewModel.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 = ""; }; @@ -2474,7 +2472,6 @@ children = ( 14B1736E28AEDBF6006CCD7C /* BasalView.swift */, 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */, - 14B1737128AEDBF6006CCD7C /* LoopCircleEntryView.swift */, 84AA81E22A4A36FB000B658B /* SystemActionLink.swift */, 84AA81E62A4A4DEF000B658B /* PumpView.swift */, ); @@ -3508,7 +3505,6 @@ 14B1737428AEDBF6006CCD7C /* GlucoseView.swift in Sources */, 14B1737328AEDBF6006CCD7C /* SystemStatusWidget.swift in Sources */, 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */, - 14B1737528AEDBF6006CCD7C /* LoopCircleEntryView.swift in Sources */, 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; From 21a6d1c7ee9638ff84b85e732cff6af14ee51fb1 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 4 Jun 2024 15:31:48 -0700 Subject: [PATCH 065/184] [LOOP-4870] LoopCircleView Updates --- Loop Widget Extension/Widgets/SystemStatusWidget.swift | 2 ++ Loop/Views/SettingsView.swift | 8 ++++---- LoopUI/Extensions/GuidanceColors.swift | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Loop Widget Extension/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift index e7ae6054a0..b5c60a4d3e 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -7,6 +7,7 @@ // import LoopKit +import LoopKitUI import LoopUI import SwiftUI import WidgetKit @@ -28,6 +29,7 @@ struct SystemStatusWidgetEntryView : View { VStack(alignment: .center, spacing: 5) { HStack(alignment: .center, spacing: 15) { LoopCircleView(closedLoop: entry.closeLoop, freshness: freshness) + .environment(\.guidanceColors, .default) .disabled(entry.contextIsStale) GlucoseView(entry: entry) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 1df094e56e..a73070f478 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -230,12 +230,13 @@ extension SettingsView { 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 { + HStack(spacing: 12) { LoopCircleView( closedLoop: viewModel.automaticDosingStatus.automaticDosingEnabled, freshness: viewModel.loopStatusCircleFreshness ) - .padding(.trailing) + .frame(width: 36, height: 36) + .padding(12) VStack(alignment: .leading) { Text("Closed Loop", comment: "The title text for the looping enabled switch cell") @@ -247,10 +248,9 @@ extension SettingsView { } } } - .fixedSize(horizontal: false, vertical: true) - .padding() } .disabled(!viewModel.isOnboardingComplete || !viewModel.automaticDosingStatus.isAutomaticDosingAllowed) + .padding(.vertical) } } diff --git a/LoopUI/Extensions/GuidanceColors.swift b/LoopUI/Extensions/GuidanceColors.swift index 9613a90091..56ee48a3d4 100644 --- a/LoopUI/Extensions/GuidanceColors.swift +++ b/LoopUI/Extensions/GuidanceColors.swift @@ -10,6 +10,6 @@ import LoopKitUI extension GuidanceColors { public static var `default`: GuidanceColors { - return GuidanceColors(acceptable: .primary, warning: .warning, critical: .critical) + return GuidanceColors(acceptable: .fresh, warning: .warning, critical: .critical) } } From 7bbffacc269417e8bd803547ab8029a3e3863aa9 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 4 Jun 2024 16:24:17 -0700 Subject: [PATCH 066/184] [LOOP-4870] Fix tests --- Loop.xcodeproj/project.pbxproj | 12 ----- .../LoopCompletionFreshnessTests.swift | 50 ------------------- 2 files changed, 62 deletions(-) delete mode 100644 LoopTests/LoopCore/LoopCompletionFreshnessTests.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 9bea905ea5..b5a0165e2d 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -381,7 +381,6 @@ B4AC0D3F24B9005300CDB0A1 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEE31CDE5C0A003C8C80 /* UIImage.swift */; }; B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */; }; B4C9859425D5A3BB009FD9CA /* StatusBadgeHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */; }; - B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */; }; B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */; }; B4D620D424D9EDB900043B3C /* GuidanceColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D620D324D9EDB900043B3C /* GuidanceColors.swift */; }; B4D904412AA8989100CBD826 /* StatefulPluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */; }; @@ -1285,7 +1284,6 @@ B490A04224D055D900F509FA /* DeviceStatusHighlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusHighlight.swift; sourceTree = ""; }; B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDViewModelTests.swift; sourceTree = ""; }; B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadgeHUDView.swift; sourceTree = ""; }; - B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCompletionFreshnessTests.swift; sourceTree = ""; }; B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = ""; }; B4D620D324D9EDB900043B3C /* GuidanceColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidanceColors.swift; sourceTree = ""; }; B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPluginManager.swift; sourceTree = ""; }; @@ -2290,7 +2288,6 @@ A96DAC292838EF8A00D94E38 /* DiagnosticLogTests.swift */, E9C58A7624DB510500487A17 /* Fixtures */, 43E2D90F1D20C581004DA55F /* Info.plist */, - B4CAD8772549D2330057946B /* LoopCore */, A9DAE7CF2332D77F006AE942 /* LoopTests.swift */, 1DA7A83F24476E8C008257F0 /* Managers */, E93E86AC24DDE02C00FF40C8 /* Mock Stores */, @@ -2707,14 +2704,6 @@ path = ViewModels; sourceTree = ""; }; - B4CAD8772549D2330057946B /* LoopCore */ = { - isa = PBXGroup; - children = ( - B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */, - ); - path = LoopCore; - sourceTree = ""; - }; C13072B82A76AF0A009A7C58 /* live_capture */ = { isa = PBXGroup; children = ( @@ -3866,7 +3855,6 @@ A9F5F1F5251050EC00E7C8A4 /* ZipArchiveTests.swift in Sources */, E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */, C1DA43552B193BCB00CBD33F /* MockUploadEventListener.swift in Sources */, - B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */, C1DA43572B1A70BE00CBD33F /* SettingsManagerTests.swift in Sources */, 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */, C188599E2AF15FAB0010F21F /* AlertMocks.swift in Sources */, 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) - } -} From 23fc33efb517df5f925eabee838ca9c4d43a7fba Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 6 Jun 2024 15:21:53 -0700 Subject: [PATCH 067/184] [LOOP-4882] Mute App Sounds UI Updates --- .../StatusTableViewController.swift | 8 +- Loop/Views/AlertManagementView.swift | 151 ++++-------- Loop/Views/HowMuteAlertWorkView.swift | 216 ++++++++++-------- ...icationsCriticalAlertPermissionsView.swift | 10 +- Loop/Views/SettingsView.swift | 2 + 5 files changed, 180 insertions(+), 207 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 5d01031f5d..3677212346 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -910,7 +910,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let adjustViewForNarrowDisplay = bounds.width < 350 var contentConfig = defaultContentConfiguration().updated(for: state) - let title = NSMutableAttributedString(string: NSLocalizedString("All Alerts Muted", comment: "Warning text for when alerts are muted")) + let title = NSMutableAttributedString(string: NSLocalizedString("All App Sounds Muted", comment: "Warning text for when alerts are muted")) let image = UIImage(systemName: "speaker.slash.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 25, weight: .thin, scale: .large)) contentConfig.image = image contentConfig.imageProperties.tintColor = .white @@ -1285,10 +1285,10 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func presentUnmuteAlertConfirmation() { - let title = NSLocalizedString("Unmute Alerts?", comment: "The alert title for unmute alert confirmation") - let body = NSLocalizedString("Tap Unmute to resume sound for your alerts and alarms.", comment: "The alert body for unmute alert confirmation") + let title = NSLocalizedString("Unmute App Sounds?", comment: "The alert title for unmute alert confirmation") + let body = NSLocalizedString("Tap Unmute to resume app sounds for your alerts and alarms.", 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() } diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index e8568ba4d7..fa0c1b4810 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -18,7 +18,6 @@ struct AlertManagementView: View { @ObservedObject private var alertMuter: AlertMuter @State private var showMuteAlertOptions: Bool = false - @State private var showHowMuteAlertWork: Bool = false private var formatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -69,87 +68,18 @@ 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: 16) { - Image("phone") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 54) - - 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: 16) { - Image("hardware") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 54) - - 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: 16) { - Image(systemName: "moon.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 44) - .foregroundColor(.accentColor) - .padding(.horizontal, 5) - - 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() @@ -163,30 +93,45 @@ struct AlertManagementView: View { @ViewBuilder private var muteAlertsSection: some View { - Section(footer: footerView) { + Section( + header: Text(String(format: "%1$@", appName)), + footer: !alertMuter.configuration.shouldMute ? Text(String(format: NSLocalizedString("Temporarily silence all sounds from %1$@, including sounds for Critical Alerts such as Urgent Low, Sensor Fail, Pump Expiration and others.\n\nWhile sounds are muted, alerts from %1$@ will still vibrate if haptics are enabled. Your insulin pump and CGM hardware may still sound.", comment: ""), appName, appName)) : nil + ) { if !alertMuter.configuration.shouldMute { - howMuteAlertsWork Button(action: { showMuteAlertOptions = true }) { - HStack { + HStack(spacing: 12) { + Spacer() muteAlertIcon Text(NSLocalizedString("Mute All Alerts", comment: "Label for button to mute all alerts")) + .fontWeight(.semibold) + Spacer() } + .padding(.vertical, 6) } .actionSheet(isPresented: $showMuteAlertOptions) { muteAlertOptionsActionSheet } } else { Button(action: alertMuter.unmuteAlerts) { - HStack { + HStack(spacing: 12) { + Spacer() unmuteAlertIcon - Text(NSLocalizedString("Tap to Unmute Alerts", comment: "Label for button to unmute all alerts")) + Text(NSLocalizedString("Tap to Unmute App Sounds", comment: "Label for button to unmute all app sounds")) + .fontWeight(.semibold) + Spacer() } + .padding(.vertical, 6) } - HStack { - Text(NSLocalizedString("All alerts muted until", comment: "Label for when mute alert will end")) - Spacer() - Text(alertMuter.formattedEndTime) - .foregroundColor(.secondary) + 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 Critical Alerts such as Urgent Low, Sensor Fail, and Pump Expiration will NOT sound.", comment: "Warning label that all alerts will not sound") + .font(.footnote) } } } @@ -194,37 +139,25 @@ struct AlertManagementView: View { private var muteAlertIcon: some View { Image(systemName: "speaker.slash.fill") + .resizable() .foregroundColor(.white) .padding(5) - .background(guidanceColors.warning) + .frame(width: 22, height: 22) + .background(Color.accentColor) .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) } private var unmuteAlertIcon: some View { Image(systemName: "speaker.wave.2.fill") + .resizable() .foregroundColor(.white) .padding(.vertical, 5) .padding(.horizontal, 2) + .frame(width: 22, height: 22) .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) - Spacer() - Image(systemName: "info.circle") - .font(.body) - } - } - .sheet(isPresented: $showHowMuteAlertWork) { - HowMuteAlertWorkView() - } - } - private var muteAlertOptionsActionSheet: ActionSheet { var muteAlertDurationOptions: [SwiftUI.Alert.Button] = formatterDurations.map { muteAlertDuration in .default(Text(muteAlertDuration), @@ -233,8 +166,8 @@ struct AlertManagementView: View { 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")), + title: Text(NSLocalizedString("Set Time Duration", comment: "Title for mute alert duration selection action sheet")), + message: Text(NSLocalizedString("All app sounds, including sounds for Critical Alerts such as Urgent Low, Sensor Fail, and Pump Expiration will NOT sound.", comment: "Message for mute alert duration selection action sheet")), buttons: muteAlertDurationOptions) } @@ -243,6 +176,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/HowMuteAlertWorkView.swift b/Loop/Views/HowMuteAlertWorkView.swift index 22135e5f60..5405f4cf69 100644 --- a/Loop/Views/HowMuteAlertWorkView.swift +++ b/Loop/Views/HowMuteAlertWorkView.swift @@ -10,124 +10,148 @@ 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(.subheadline) - .foregroundColor(.primary.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 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( + "Silence your iPhone by turning down the volume or switching it to Silent mode, indicated by the orange color on the Ring/Silent switch.", + 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( + "Make sure to keep Notifications, Time Sensitive Notifications, and Critical Alerts turned ON in your iOS Settings to receive essential %1$@ safety and maintenance notifications.", + comment: "Time sensitive notifications callout title (1: app name)" + ), + appName + ) + ) + ) + .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 { + Text("TBD") + } 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/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index b9e1552036..47ebe947d7 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -68,7 +68,7 @@ public struct NotificationsCriticalAlertPermissionsView: View { notificationAndCriticalAlertPermissionSupportSection } .insetGroupedListStyle() - .navigationBarTitle(Text(NSLocalizedString("Alert Permissions", comment: "Notification & Critical Alert Permissions screen title"))) + .navigationBarTitle(Text(NSLocalizedString("iOS Permissions", comment: "Notification & Critical Alert Permissions screen title"))) } } @@ -89,7 +89,7 @@ extension NotificationsCriticalAlertPermissionsView { private var manageNotifications: some View { Button( action: { AlertPermissionsChecker.gotoSettings() } ) { HStack { - Text(NSLocalizedString("Manage Permissions in Settings", comment: "Manage Permissions in Settings button text")) + Text(NSLocalizedString("Manage iOS Permissions", comment: "Manage Permissions in Settings button text")) Spacer() Image(systemName: "chevron.right").foregroundColor(.gray).font(.footnote) } @@ -137,9 +137,9 @@ 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)) { + NavigationLink(destination: Text("Get help with iOS Permissions")) { + Text(NSLocalizedString("Get help with iOS Permissions", comment: "Get help with iOS Permissions support button text")) } } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index a73070f478..c9409f905c 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -283,8 +283,10 @@ extension SettingsView { .foregroundColor(.critical) } else if viewModel.alertMuter.configuration.shouldMute { Image(systemName: "speaker.slash.fill") + .resizable() .foregroundColor(.white) .padding(5) + .frame(width: 22, height: 22) .background(guidanceColors.warning) .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) } From 895e3826acab9e8d72c0927f347dc647c331801b Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 6 Jun 2024 15:37:12 -0700 Subject: [PATCH 068/184] [LOOP-4882] Mute App Sounds UI Updates --- Loop/Views/HowMuteAlertWorkView.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Loop/Views/HowMuteAlertWorkView.swift b/Loop/Views/HowMuteAlertWorkView.swift index 5405f4cf69..2d64c21b66 100644 --- a/Loop/Views/HowMuteAlertWorkView.swift +++ b/Loop/Views/HowMuteAlertWorkView.swift @@ -108,11 +108,17 @@ struct HowMuteAlertWorkView: View { title: Text( String( format: NSLocalizedString( - "Make sure to keep Notifications, Time Sensitive Notifications, and Critical Alerts turned ON in your iOS Settings to receive essential %1$@ safety and maintenance notifications.", + "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) From 727e09f6d57c635dbc0432a29c23fb0a6e4896a8 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 6 Jun 2024 16:47:29 -0700 Subject: [PATCH 069/184] [LOOP-4870] Fix tests --- LoopUITests/LoopUITests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopUITests/LoopUITests.swift b/LoopUITests/LoopUITests.swift index a998b807d0..71d0f14c2a 100644 --- a/LoopUITests/LoopUITests.swift +++ b/LoopUITests/LoopUITests.swift @@ -24,7 +24,7 @@ final class LoopUITests: XCTestCase { baseScreen = BaseScreen(app: app) homeScreen = HomeScreen(app: app) settingsScreen = SettingsScreen(app: app) - systemSettingsScreen = SystemSettingsScreen(app: app) + systemSettingsScreen = SystemSettingsScreen() pumpSimulatorScreen = PumpSimulatorScreen(app: app) } From 126b7c53dcc1a110a9077151fde5fec9cbe48005 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 7 Jun 2024 14:14:33 -0300 Subject: [PATCH 070/184] [LOOP-4801] adding pump failure and check during looping (#649) --- Loop/Managers/LoopDataManager.swift | 4 ++++ Loop/Models/LoopError.swift | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index d922ccd245..2cb75f53af 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -543,6 +543,10 @@ final class LoopDataManager: ObservableObject { dosingDecision.updateFrom(input: input, output: output) if self.automaticDosingStatus.automaticDosingEnabled { + if deliveryDelegate.basalDeliveryState == .pumpInoperable { + throw LoopError.pumpInoperable + } + if deliveryDelegate.isSuspended { throw LoopError.pumpSuspended } diff --git a/Loop/Models/LoopError.swift b/Loop/Models/LoopError.swift index 6cb28cb5bd..23e71dd7d4 100644 --- a/Loop/Models/LoopError.swift +++ b/Loop/Models/LoopError.swift @@ -99,6 +99,9 @@ enum LoopError: Error { // Recommendation Expired case recommendationExpired(date: Date) + // Pump Failure + case pumpInoperable + // Pump Suspended case pumpSuspended @@ -133,6 +136,8 @@ extension LoopError { return "pumpDataTooOld" case .recommendationExpired: return "recommendationExpired" + case .pumpInoperable: + return "pumpInoperable" case .pumpSuspended: return "pumpSuspended" case .pumpManagerError: @@ -205,6 +210,8 @@ 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 LoopError.pumpSuspended errors.") case .pumpManagerError(let pumpManagerError): From a59478a90e86eb76d3c65f4c7b09241fcc16616d Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 10 Jun 2024 08:14:56 -0300 Subject: [PATCH 071/184] [LOOP-4890] revert change to acceptable color (#652) --- LoopUI/Extensions/GuidanceColors.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopUI/Extensions/GuidanceColors.swift b/LoopUI/Extensions/GuidanceColors.swift index 56ee48a3d4..9613a90091 100644 --- a/LoopUI/Extensions/GuidanceColors.swift +++ b/LoopUI/Extensions/GuidanceColors.swift @@ -10,6 +10,6 @@ import LoopKitUI extension GuidanceColors { public static var `default`: GuidanceColors { - return GuidanceColors(acceptable: .fresh, warning: .warning, critical: .critical) + return GuidanceColors(acceptable: .primary, warning: .warning, critical: .critical) } } From 081fd920f703babb52e7430ff09e1958714b2d4d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 10 Jun 2024 10:14:54 -0500 Subject: [PATCH 072/184] LOOP-1169 Upload device logs (#644) * Update for remote data service protocol changes * Remote data service can fetch device logs * Update tests --- Loop/Managers/DeviceDataManager.swift | 14 +- Loop/Managers/LoopAppManager.swift | 17 ++- Loop/Managers/RemoteDataServicesManager.swift | 125 ++++++++++-------- .../Managers/DeviceDataManagerTests.swift | 14 ++ 4 files changed, 102 insertions(+), 68 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 8064890070..f028e6eccb 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -231,6 +231,7 @@ final class DeviceDataManager { private weak var displayGlucoseUnitBroadcaster: DisplayGlucoseUnitBroadcaster? init(pluginManager: PluginManager, + deviceLog: PersistentDeviceLog, alertManager: AlertManager, settingsManager: SettingsManager, healthStore: HKHealthStore, @@ -253,19 +254,8 @@ final class DeviceDataManager { displayGlucoseUnitBroadcaster: DisplayGlucoseUnitBroadcaster ) { - 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) - self.pluginManager = pluginManager + self.deviceLog = deviceLog self.alertManager = alertManager self.settingsManager = settingsManager self.healthStore = healthStore diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 32e5db704d..f3bd38c373 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -103,6 +103,7 @@ class LoopAppManager: NSObject { private var remoteDataServicesManager: RemoteDataServicesManager! private var statefulPluginManager: StatefulPluginManager! private var criticalEventLogExportManager: CriticalEventLogExportManager! + private var deviceLog: PersistentDeviceLog! // HealthStorePreferredGlucoseUnitDidChange will be notified once the user completes the health access form. Set to .milligramsPerDeciliter until then public private(set) var displayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) @@ -312,6 +313,18 @@ class LoopAppManager: NSObject { 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, @@ -322,7 +335,8 @@ class LoopAppManager: NSObject { cgmEventStore: cgmEventStore, settingsStore: settingsManager.settingsStore, overrideHistory: temporaryPresetsManager.overrideHistory, - insulinDeliveryStore: doseStore.insulinDeliveryStore + insulinDeliveryStore: doseStore.insulinDeliveryStore, + deviceLog: deviceLog ) settingsManager.remoteDataServicesManager = remoteDataServicesManager @@ -341,6 +355,7 @@ class LoopAppManager: NSObject { statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) deviceDataManager = DeviceDataManager(pluginManager: pluginManager, + deviceLog: deviceLog, alertManager: alertManager, settingsManager: settingsManager, healthStore: healthStore, diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index 9f89aeb1a8..41a3bd3ca7 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -53,6 +53,7 @@ final class RemoteDataServicesManager { private var unlockedRemoteDataServices = [RemoteDataService]() func addService(_ remoteDataService: RemoteDataService) { + remoteDataService.remoteDataServiceDelegate = self lock.withLock { unlockedRemoteDataServices.append(remoteDataService) } @@ -60,6 +61,7 @@ final class RemoteDataServicesManager { } func restoreService(_ remoteDataService: RemoteDataService) { + remoteDataService.remoteDataServiceDelegate = self lock.withLock { unlockedRemoteDataServices.append(remoteDataService) } @@ -140,6 +142,9 @@ final class RemoteDataServicesManager { private let overrideHistory: TemporaryScheduleOverrideHistory + private let deviceLog: PersistentDeviceLog + + init( alertStore: AlertStore, carbStore: CarbStore, @@ -149,7 +154,8 @@ final class RemoteDataServicesManager { cgmEventStore: CgmEventStore, settingsStore: SettingsStore, overrideHistory: TemporaryScheduleOverrideHistory, - insulinDeliveryStore: InsulinDeliveryStore + insulinDeliveryStore: InsulinDeliveryStore, + deviceLog: PersistentDeviceLog ) { self.alertStore = alertStore self.carbStore = carbStore @@ -161,6 +167,7 @@ final class RemoteDataServicesManager { self.settingsStore = settingsStore self.overrideHistory = overrideHistory self.lockedFailedUploads = Locked([]) + self.deviceLog = deviceLog } private func uploadExistingData(to remoteDataService: RemoteDataService) { @@ -242,14 +249,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() } @@ -278,15 +285,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() } @@ -320,15 +327,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) + } catch { + self.log.error("Error synchronizing dose data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -362,15 +369,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() } @@ -409,15 +417,15 @@ extension RemoteDataServicesManager { 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: + Task { + do { + try await remoteDataService.uploadGlucoseData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .glucose, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing glucose data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -451,15 +459,15 @@ extension RemoteDataServicesManager { 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: + Task { + do { + try await remoteDataService.uploadPumpEventData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .pumpEvent, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -493,15 +501,15 @@ extension RemoteDataServicesManager { self.log.error("Error querying settings data: %{public}@", String(describing: error)) semaphore.signal() case .success(let queryAnchor, let data): - remoteDataService.uploadSettingsData(data) { result in - switch result { - case .failure(let error): - self.log.error("Error synchronizing settings data: %{public}@", String(describing: error)) - self.uploadFailed(key) - case .success: + Task { + do { + try await remoteDataService.uploadSettingsData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .settings, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing settings data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -531,14 +539,14 @@ extension RemoteDataServicesManager { let (overrides, deletedOverrides, newAnchor) = self.overrideHistory.queryByAnchor(queryAnchor) - remoteDataService.uploadTemporaryOverrideData(updated: overrides, deleted: deletedOverrides) { result in - switch result { - case .failure(let error): - self.log.error("Error synchronizing temporary override data: %{public}@", String(describing: error)) - self.uploadFailed(key) - case .success: + Task { + do { + try await remoteDataService.uploadTemporaryOverrideData(updated: overrides, deleted: deletedOverrides) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .overrides, newAnchor) self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing temporary override data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -566,15 +574,15 @@ extension RemoteDataServicesManager { self.log.error("Error querying pump 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 pump event data: %{public}@", String(describing: error)) - self.uploadFailed(key) - case .success: + Task { + do { + try await remoteDataService.uploadCgmEventData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .cgmEvent, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) + self.uploadFailed(key) } semaphore.signal() } @@ -591,6 +599,13 @@ extension RemoteDataServicesManager { } } +// RemoteDataServiceDelegate +extension RemoteDataServicesManager: RemoteDataServiceDelegate { + func fetchDeviceLogs(startDate: Date, endDate: Date) async throws -> [LoopKit.StoredDeviceLogEntry] { + return try await deviceLog.fetch(startDate: startDate, endDate: endDate) + } +} + //Remote Commands extension RemoteDataServicesManager { diff --git a/LoopTests/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift index f8f68b841f..6c5c09cf5c 100644 --- a/LoopTests/Managers/DeviceDataManagerTests.swift +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -60,6 +60,19 @@ final class DeviceDataManagerTests: XCTestCase { 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 = GlucoseStore(cacheStore: persistenceController) let cgmEventStore = CgmEventStore(cacheStore: persistenceController) @@ -70,6 +83,7 @@ final class DeviceDataManagerTests: XCTestCase { deviceDataManager = DeviceDataManager( pluginManager: PluginManager(), + deviceLog: deviceLog, alertManager: alertManager, settingsManager: settingsManager, healthStore: healthStore, From 89fb05788dceb6acc81f8d4a4f882e271c177682 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 10 Jun 2024 13:19:36 -0300 Subject: [PATCH 073/184] [LOOP-4890] adding loop status color to the environment (#653) --- Loop/View Controllers/StatusTableViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 5d01031f5d..30bad76e94 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1616,7 +1616,8 @@ final class StatusTableViewController: LoopChartsTableViewController { let hostingController = DismissibleHostingController( rootView: SettingsView(viewModel: viewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucosePreference) - .environment(\.appName, Bundle.main.bundleDisplayName), + .environment(\.appName, Bundle.main.bundleDisplayName) + .environment(\.loopStatusColorPalette, .loopStatus), isModalInPresentation: false) present(hostingController, animated: true) } From 537471f407e802e164ef375353a25b171b21b430 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 11 Jun 2024 10:12:36 -0300 Subject: [PATCH 074/184] [PAL-638] report resume immediately after suspend (#650) --- .../DoseStore+SimulatedCoreData.swift | 29 +++++++++++++------ ...ersistentDeviceLog+SimulatedCoreData.swift | 2 +- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/Loop/Extensions/DoseStore+SimulatedCoreData.swift b/Loop/Extensions/DoseStore+SimulatedCoreData.swift index 6036f7d08c..151f0dbb3f 100644 --- a/Loop/Extensions/DoseStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DoseStore+SimulatedCoreData.swift @@ -19,6 +19,7 @@ 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) { var startDate = Calendar.current.startOfDay(for: cacheStartDate) @@ -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 diff --git a/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift b/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift index d39848337d..1651404078 100644 --- a/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift +++ b/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift @@ -14,7 +14,7 @@ import LoopKit extension PersistentDeviceLog { private var historicalEndDate: Date { Date(timeIntervalSinceNow: -.hours(24)) } - private var simulatedPerHour: Int { 250 } + private var simulatedPerHour: Int { 60 } private var simulatedLimit: Int { 10000 } func generateSimulatedHistoricalDeviceLogEntries(completion: @escaping (Error?) -> Void) { From 904e1aef9878e19b39b97307ec5717b74fd66e29 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 11 Jun 2024 13:54:35 -0300 Subject: [PATCH 075/184] [LOOP-4847] align COB value (#651) * align COB value * updated carb formatter * response to PR comments * change COB -> active carbs --- .../CarbAbsorptionViewController.swift | 22 ++++++++----------- Loop/en.lproj/Main.strings | 2 +- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index 1982b9977d..03b1b9acbd 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -164,7 +164,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif let review = try await loopDataManager.fetchCarbAbsorptionReview(start: listStart, end: listEnd) insulinCounteractionEffects = review.effectsVelocities.filterDateRange(chartStartDate, nil) carbStatuses = review.carbStatuses - carbsOnBoard = carbStatuses?.getClampedCarbsOnBoard() + carbsOnBoard = loopDataManager.activeCarbs carbEffects = review.carbEffects } catch { log.error("Failed to get carb absorption review: %{public}@", String(describing: error)) @@ -235,11 +235,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif static let count = 1 } - private lazy var carbFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .none - return formatter - }() + private lazy var carbFormatter: QuantityFormatter = QuantityFormatter(for: .gram()) private lazy var absorptionFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -301,7 +297,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif // Entry value let status = carbStatuses[indexPath.row] - let carbText = carbFormatter.string(from: status.entry.quantity.doubleValue(for: unit), unit: unit.unitString) + let carbText = carbFormatter.string(from: status.entry.quantity) if let carbText = carbText, let foodType = status.entry.foodType { cell.valueLabel?.text = String( @@ -328,9 +324,9 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif if let absorption = status.absorption { // Absorbed value let observedProgress = Float(absorption.observedProgress.doubleValue(for: .percent())) - let observedCarbs = max(0, absorption.observed.doubleValue(for: unit)) + let observedCarbs = absorption.observed - if let observedCarbsText = carbFormatter.string(from: observedCarbs, unit: unit.unitString) { + if let observedCarbsText = carbFormatter.string(from: observedCarbs) { cell.observedValueText = String( format: NSLocalizedString("%@ absorbed", comment: "Formats absorbed carb value"), observedCarbsText @@ -377,7 +373,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif format: NSLocalizedString("at %@", comment: "Format fragment for a specific time"), timeFormatter.string(from: carbsOnBoard.startDate) ) - cell.COBValueLabel.text = carbFormatter.string(from: carbsOnBoard.quantity.doubleValue(for: unit)) + cell.COBValueLabel.text = carbFormatter.string(from: carbsOnBoard.quantity, includeUnit: false) // Warn the user if the carbsOnBoard value isn't recent let textColor: UIColor @@ -393,7 +389,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif cell.COBDateLabel.textColor = textColor } else { cell.COBDateLabel.text = nil - cell.COBValueLabel.text = carbFormatter.string(from: 0.0) + cell.COBValueLabel.text = carbFormatter.string(from: HKQuantity(unit: .gram(), doubleValue: 0), includeUnit: false) } if let carbTotal = carbTotal { @@ -401,10 +397,10 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif format: NSLocalizedString("since %@", comment: "Format fragment for a start time"), timeFormatter.string(from: carbTotal.startDate) ) - cell.totalValueLabel.text = carbFormatter.string(from: carbTotal.quantity.doubleValue(for: unit)) + cell.totalValueLabel.text = carbFormatter.string(from: carbTotal.quantity, includeUnit: false) } else { cell.totalDateLabel.text = nil - cell.totalValueLabel.text = carbFormatter.string(from: 0.0) + cell.totalValueLabel.text = carbFormatter.string(from: HKQuantity(unit: .gram(), doubleValue: 0), includeUnit: false) } } diff --git a/Loop/en.lproj/Main.strings b/Loop/en.lproj/Main.strings index 032954b583..31ebb58355 100644 --- a/Loop/en.lproj/Main.strings +++ b/Loop/en.lproj/Main.strings @@ -1,3 +1,3 @@ /* Class = "UILabel"; text = "g Active Carbs"; ObjectID = "SQx-au-ZcM"; */ -"SQx-au-ZcM.text" = "g COB"; +"SQx-au-ZcM.text" = "g Active Carbs"; From f5f6ac5494b0ae774d440e37c4e0d665e89602b5 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 11 Jun 2024 18:12:33 -0400 Subject: [PATCH 076/184] [LOOP-4882] Mute App Sounds UI Updates --- Loop.xcodeproj/project.pbxproj | 4 + .../focus-mode-1.imageset/Contents.json | 12 +++ .../focus-mode-1.imageset/Focus.png | Bin 0 -> 103490 bytes .../focus-mode-2.imageset/Contents.json | 12 +++ .../focus-mode-2.imageset/Focus.png | Bin 0 -> 175217 bytes Loop/Views/HowMuteAlertWorkView.swift | 2 +- Loop/Views/IOSFocusModesView.swift | 96 ++++++++++++++++++ 7 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Focus.png create mode 100644 Loop/DefaultAssets.xcassets/focus-mode-2.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/focus-mode-2.imageset/Focus.png create mode 100644 Loop/Views/IOSFocusModesView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index b5a0165e2d..f78ca3d06b 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -250,6 +250,7 @@ 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 */; }; + 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.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 */; }; @@ -1160,6 +1161,7 @@ 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 = ""; }; + 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.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; }; @@ -2233,6 +2235,7 @@ C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, + 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */, ); path = Views; sourceTree = ""; @@ -3639,6 +3642,7 @@ E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, + 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */, DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */, 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, diff --git a/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Contents.json b/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Contents.json new file mode 100644 index 0000000000..8c6a6923f5 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Focus.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Focus.png b/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Focus.png new file mode 100644 index 0000000000000000000000000000000000000000..01cea7de97bf91521d9e877f4563f213b00fb1e1 GIT binary patch literal 103490 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%0A}wWT2RN}KM55Y~^X~Nm#dWjMPf$>V4Or3u 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 %^*_bAZ#arONq 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∾2Q!86vO?d}=ZB`oCoM;V>8he;rKN)5zX=(sK(|U#UqlYV2EdGsyC;fg#76*<&+fOo&c^*EvjKXYU}Ye!@iP*EM+(g&iTHKkuHb{s1UZ zY!EYnl!@Rm7v+@>*%g~TxxHl0RV{Pv0DDusdzD6^E&Utbi}};nf#}-b>i%y~m4L~5 zUf4$IMwimj_|ux&h^%s_)_*{WM&?_QFa&4-SwcmsRNHAGJ?{I-8NwgL?s{9Q53wHq zBd3q1r8G)n&6OAP&WAfnAXNsZDq)riRM@{_1v2aMF{n0!F%@#v10zW%!AVBcytyin z(y)D%z$;xXHhL41P($W{pm{pWt5R;S-EB`Xuk8x*Ynh*`({tQe;Q|H82aHBwCljn?(AmF?d)z`G2pM?ZWnAn?ulto z9RJG3pyB6RkQiA00c2txYFR`9G`dV0FA9-LMbNy1L04+(%5PJe(6+Lxx!oN&i@DB9 zrmsA9cUBff;PETw4~Qb7a>br&0x+`L?~%XYd9>)YJM)%Yd&;^Jw3%+Yo)LyHv>j?A z`Dpa1*0#lbFL4>3aIfIk+0MDgOHBbNE^9JYDu=%J%%1Z000tY#K6_z*2blTi3=ls(CNh&;GILjSSJ5oBu7iTsVQ_D2E6&Qw zyq?u-a=Wh1kkx|g%&DG$odLUgmr2-&o{K-(uSpZz(Xpudpx@UXz`96Tiqjx1Y6*JU zPLLtB6JORP<+fi=@}++$TOU5HH-lh;`@BWjE^S})Xze3i$by2*S5;-`C0~7q`pR!) zFQ*vQiMZcU*(tat)scp^qVUG8RdRRkw&;m-4~Oh!C+{AOGfWByFO+j=@#IJ_((W&? zN0g=d&Aa_@Pu%6892Ez%3w^W^5)r8YT(NvMxN7w*G`%|pA;LrMgL%O#D<7C9{iQ_Q zkWtTFW5~hfonBje+wXtz&e<15t;H&&Tv=pye}lg~AU!e0)&qrH7oVl}DH+#7sLj{X z)Rfz_#XZr`BE;Y9bn+7+5WpQk7z58Wv`mvm;(A2h)lV)HLCe?~M)ZUM@8#?G9LXTr zSUt2UasSOYvsNDq6NfbDN3H6gR?iO*LJ*vOgRCeY!miyEcbIY7l8e zh#@b8YPMOcmyintn30Q#qjvU#!gdjUdf{)`?owmiqGJ!udN48M&%}KQGHL^Lb!(xc zK|*fdtCgIe^nBUvEm53pb1mEtSX~^U4(u@Vig@o=`O^uG^ z^r<)REkuJY4j@G`Uxb!N<3nBx){^4Agg#yJnm2KDb)+zPg_HU)k&@{73He5o(UR3= z(Xh#tlKYGLBZ5yE`mrG8SfDa zW9mm^1_#)j3k{Gx?U8l9+Adq6dK&k{n&sF)*E^S}o3g05Nz7zead7h|(Zdn;QX)<8 ziowHd%@4fWd z@ARB-w1&_bpxxQ$( zZ<#�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%0A!;CIND3 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

k6}Wl9r$OAr=ZcN8@^o9ll82VYs2GBB4#q zN?<6?I~lClm(8L>Z(b_0r5CQkx%8!gNiW^LL<1+8n?<4+HEB|x&sBk{EMpXBc$U8} z3i7T_P*d6~PXed|0fGxjPeTNe+QAygv6s zruG?q7FTUn4|#OVdcuMktLv?)_pCXw7`i&u`9e!%FiP<#-v9fvVGLd8;>KoX30*hu zi>VANj5UL0r-+5vFwVdKSkp61M89FIE~pL4@RjKJb9U{a+tyNwnf{Bgwa}Bdf0za- zj>wr2lv5@-XWapo!_@Fw61x3}w>G8iQ1j1Mg(iNplrWL{MqUR99AOP)j}gt@9@PVR1;*FAHOZG10y zUU`nvhQhO`T}7+HCqnBfQ&Q|?DT_Bl&gPsPCTOPl+q?M3lL$%8#$9ZUz(BkJdd*4v z11x$H{GyjAqWzOD$)`(JGsbn_@F!ie6mjJ@ zvP@0Fxe%NnMhZg;rPq-wCVx3=$rLCP0-NJpenE%p^ZNz>|ydh5aO|AFM9MsFpoL8X@me(O-NL yaEah}L#>xfC&L)H>5mNJ9=Kk1zfA_Zb3()qhYn1|P+MLCJ~|o(>ZNM7A^!(9 Date: Wed, 12 Jun 2024 16:40:57 -0400 Subject: [PATCH 079/184] [LOOP-4882] Mute App Sounds Enhancements Design Review Feedback --- Loop/Views/HowMuteAlertWorkView.swift | 2 +- Loop/Views/IOSFocusModesView.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Loop/Views/HowMuteAlertWorkView.swift b/Loop/Views/HowMuteAlertWorkView.swift index 5e33b7dfb6..4394bbb2f9 100644 --- a/Loop/Views/HowMuteAlertWorkView.swift +++ b/Loop/Views/HowMuteAlertWorkView.swift @@ -89,7 +89,7 @@ struct HowMuteAlertWorkView: View { Text( NSLocalizedString( - "Silence your iPhone by turning down the volume or switching it to Silent mode, indicated by the orange color on the Ring/Silent switch.", + "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" ) ) diff --git a/Loop/Views/IOSFocusModesView.swift b/Loop/Views/IOSFocusModesView.swift index 21de55e09b..4188c19cb5 100644 --- a/Loop/Views/IOSFocusModesView.swift +++ b/Loop/Views/IOSFocusModesView.swift @@ -86,8 +86,8 @@ struct IOSFocusModesView: View { ) ) ) - .padding(.horizontal, -20) - .padding(.bottom, -22) + .padding(.horizontal, -16) + .padding(.bottom, -16) } } .insetGroupedListStyle() From 9aa88e74f9be033a6ca082241fd1cee45776f49e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 12 Jun 2024 17:30:34 -0500 Subject: [PATCH 080/184] DoseStore add reservoir updated to async (#656) --- Loop/Managers/DeviceDataManager.swift | 31 +-------------------------- 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index f028e6eccb..c7f3f1a811 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1096,7 +1096,7 @@ extension DeviceDataManager: PumpManagerDelegate { log.default("PumpManager:%{public}@ did read reservoir value", String(describing: type(of: pumpManager))) do { - let (newValue, lastValue, areStoredValuesContinuous) = try await addReservoirValue(units, at: date) + 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)) @@ -1105,35 +1105,6 @@ extension DeviceDataManager: PumpManagerDelegate { } } - /// 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) async throws -> (newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool) { - try await withCheckedThrowingContinuation { continuation in - doseStore.addReservoirValue(units, at: date) { (newValue, previousValue, areStoredValuesContinuous, error) in - if let error = error { - continuation.resume(throwing: error) - } else if let newValue = newValue { - continuation.resume(returning: ( - newValue: newValue, - lastValue: previousValue, - areStoredValuesContinuous: areStoredValuesContinuous - )) - } else { - assertionFailure() - } - } - } - } - - func startDateToFilterNewPumpEvents(for manager: PumpManager) -> Date { dispatchPrecondition(condition: .onQueue(.main)) return doseStore.pumpEventQueryAfterDate From ed0bc2e26a5d442e1380915020be443f0688fbeb Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 13 Jun 2024 05:19:35 -0300 Subject: [PATCH 081/184] [PAL-666] also defer retractions, since cooresponding alerts will not be found and retracted (#657) --- Loop/Managers/Alerts/AlertManager.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 808e70873f..ca0142193d 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -37,6 +37,7 @@ public final class AlertManager { // Defer issuance of new alerts until playback is done private var deferredAlerts: [Alert] = [] + private var deferredRetractions: [Alert.Identifier] = [] private var playbackFinished: Bool private let fileManager: FileManager @@ -370,6 +371,10 @@ extension AlertManager: AlertIssuer { } public func retractAlert(identifier: Alert.Identifier) { + guard playbackFinished else { + deferredRetractions.append(identifier) + return + } unscheduleAlertWithSchedulers(identifier: identifier) alertStore.recordRetraction(of: identifier) } @@ -487,6 +492,9 @@ extension AlertManager { for alert in self.deferredAlerts { self.issueAlert(alert) } + for identifier in self.deferredRetractions { + self.retractAlert(identifier: identifier) + } } } From c45d75bd3d4cfcd91af5f435622c126dd58e87b8 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 13 Jun 2024 08:51:07 -0400 Subject: [PATCH 082/184] [LOOP-4882] Mute App Sounds UI Updates --- Loop/Views/NotificationsCriticalAlertPermissionsView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index 47ebe947d7..43b5cf8ab4 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -65,7 +65,8 @@ public struct NotificationsCriticalAlertPermissionsView: View { } } } - notificationAndCriticalAlertPermissionSupportSection + // MARK: Disabled for Formative 3. To be Re-enabled once design has provided an appropriate screen to link to. +// notificationAndCriticalAlertPermissionSupportSection } .insetGroupedListStyle() .navigationBarTitle(Text(NSLocalizedString("iOS Permissions", comment: "Notification & Critical Alert Permissions screen title"))) From f901de567b23b9d1b0378f44a46b4fbb7d18b50f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 13 Jun 2024 21:08:46 -0500 Subject: [PATCH 083/184] LOOP-4849 Fix watch display bugs around handling GlucoseCondition (#659) * Fix watch display bugs around handling GlucoseCondition * Add localizedDescription for GlucoseCondition --- Loop.xcodeproj/project.pbxproj | 10 ++++++--- Loop/Models/WatchContext+LoopKit.swift | 1 + .../Controllers/HUDInterfaceController.swift | 14 ++++++++++--- .../Extensions/CLKComplicationTemplate.swift | 20 +++++++++++++++--- .../Extensions/GlucoseCondition.swift | 21 +++++++++++++++++++ 5 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 WatchApp Extension/Extensions/GlucoseCondition.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index f78ca3d06b..b53f183237 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -415,6 +415,7 @@ C129D3BF2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */; }; C13072BA2A76AF31009A7C58 /* live_capture_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */; }; C13255D6223E7BE2008AF50C /* BolusProgressTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */; }; + C138C6F82C1B8A2C00F08F1A /* GlucoseCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = C138C6F72C1B8A2C00F08F1A /* GlucoseCondition.swift */; }; C13DA2B024F6C7690098BB29 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13DA2AF24F6C7690098BB29 /* UIViewController.swift */; }; C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */; }; C159C825286785E000A86EC0 /* LoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; }; @@ -1403,6 +1404,7 @@ C12CB9B623106A6200F84978 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Intents.strings; sourceTree = ""; }; C12CB9B823106A6300F84978 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Intents.strings; sourceTree = ""; }; C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_predicted_glucose.json; sourceTree = ""; }; + C138C6F72C1B8A2C00F08F1A /* GlucoseCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseCondition.swift; sourceTree = ""; }; C13DA2AF24F6C7690098BB29 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryUncertaintyAlertManager.swift; sourceTree = ""; }; C14952142995822A0095AA84 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1868,17 +1870,18 @@ 4328E01F1CFBE2B100E199AA /* Extensions */ = { isa = PBXGroup; children = ( - 898ECA68218ABDA9001E9D35 /* CLKTextProvider+Compound.h */, - 898ECA66218ABDA8001E9D35 /* WatchApp Extension-Bridging-Header.h */, - 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */, 4344629120A7C19800C4BE6F /* ButtonGroup.swift */, 898ECA64218ABD9A001E9D35 /* CGRect.swift */, 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */, + 898ECA68218ABDA9001E9D35 /* CLKTextProvider+Compound.h */, + 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */, 89FE21AC24AC57E30033F501 /* Collection.swift */, 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 */, @@ -3750,6 +3753,7 @@ A9347F3324E7522900C99C34 /* WatchHistoricalCarbs.swift in Sources */, 895788AD242E69A2002CB114 /* AbsorptionTimeSelection.swift in Sources */, 89A605EF2432925D009C1096 /* CompletionCheckmark.swift in Sources */, + C138C6F82C1B8A2C00F08F1A /* GlucoseCondition.swift in Sources */, 89F9119624358E6900ECCAF3 /* BolusPickerValues.swift in Sources */, 4328E02B1CFBE2C500E199AA /* WKAlertAction.swift in Sources */, 4F7E8AC720E2AC0300AEA65E /* WatchPredictedGlucose.swift in Sources */, diff --git a/Loop/Models/WatchContext+LoopKit.swift b/Loop/Models/WatchContext+LoopKit.swift index c97c316a00..2235ec7b92 100644 --- a/Loop/Models/WatchContext+LoopKit.swift +++ b/Loop/Models/WatchContext+LoopKit.swift @@ -16,6 +16,7 @@ extension WatchContext { 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/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift index 7ee49de7b7..eca7b0424a 100644 --- a/WatchApp Extension/Controllers/HUDInterfaceController.swift +++ b/WatchApp Extension/Controllers/HUDInterfaceController.swift @@ -82,12 +82,20 @@ class HUDInterfaceController: WKInterfaceController { if let glucose = activeContext.glucose, let glucoseDate = activeContext.glucoseDate, let unit = activeContext.displayGlucoseUnit, glucoseDate.timeIntervalSinceNow > -LoopAlgorithm.inputDataRecencyInterval { let formatter = NumberFormatter.glucoseFormatter(for: unit) - - if let glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) { + + var glucoseValue: String? + + if let glucoseCondition = activeContext.glucoseCondition { + glucoseValue = glucoseCondition.localizedDescription + } else { + glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) + } + + if let glucoseValue { let trend = activeContext.glucoseTrend?.symbol ?? "" glucoseLabel.setText(glucoseValue + trend) } - + if showEventualGlucose, let eventualGlucose = activeContext.eventualGlucose, let eventualGlucoseValue = formatter.string(from: eventualGlucose.doubleValue(for: unit)) { eventualGlucoseLabel.setText(eventualGlucoseValue) eventualGlucoseLabel.setHidden(false) diff --git a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift index f49a9f2db0..2518644375 100644 --- a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift +++ b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift @@ -11,6 +11,7 @@ import HealthKit import LoopKit import Foundation import LoopCore +import LoopAlgorithm extension CLKComplicationTemplate { @@ -25,16 +26,19 @@ extension CLKComplicationTemplate { return nil } - return templateForFamily(family, + return templateForFamily( + family, glucose: glucose, unit: unit, glucoseDate: context.glucoseDate, trend: context.glucoseTrend, + glucoseCondition: context.glucoseCondition, eventualGlucose: context.eventualGlucose, at: date, loopLastRunDate: context.loopLastRunDate, recencyInterval: recencyInterval, - chartGenerator: makeChart) + chartGenerator: makeChart + ) } static func templateForFamily( @@ -43,6 +47,7 @@ extension CLKComplicationTemplate { unit: HKUnit, glucoseDate: Date?, trend: GlucoseTrend?, + glucoseCondition: GlucoseCondition?, eventualGlucose: HKQuantity?, at date: Date, loopLastRunDate: Date?, @@ -65,7 +70,15 @@ extension CLKComplicationTemplate { glucoseString = NSLocalizedString("---", comment: "No glucose value representation (3 dashes for mg/dL; no spaces as this will get truncated in the watch complication)") trendString = "" } else { - guard let formattedGlucose = formatter.string(from: glucose.doubleValue(for: unit)) else { + var formattedGlucose: String? + + if let glucoseCondition { + formattedGlucose = glucoseCondition.localizedDescription + } else { + formattedGlucose = formatter.string(from: glucose.doubleValue(for: unit)) + } + + guard let formattedGlucose else { return nil } glucoseString = formattedGlucose @@ -161,6 +174,7 @@ extension CLKComplicationTemplate { unit: unit, glucoseDate: glucoseDate, trend: trend, + glucoseCondition: glucoseCondition, eventualGlucose: eventualGlucose, at: date, loopLastRunDate: loopLastRunDate, diff --git a/WatchApp Extension/Extensions/GlucoseCondition.swift b/WatchApp Extension/Extensions/GlucoseCondition.swift new file mode 100644 index 0000000000..5dae0e1a7a --- /dev/null +++ b/WatchApp Extension/Extensions/GlucoseCondition.swift @@ -0,0 +1,21 @@ +// +// GlucoseCondition.swift +// WatchApp Extension +// +// Created by Pete Schwamb on 6/13/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopAlgorithm + +extension GlucoseCondition { + var localizedDescription: String { + switch self { + case .aboveRange: + return NSLocalizedString("HIGH", comment: "String displayed instead of a glucose value above the CGM range") + case .belowRange: + return NSLocalizedString("LOW", comment: "String displayed instead of a glucose value below the CGM range") + } + } +} From 543ea1732ef866d4f1e141c4340c145fc480d9aa Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 14 Jun 2024 10:47:57 -0400 Subject: [PATCH 084/184] [LOOP-4882] Mute App Sounds Button Label Update --- Loop/Views/AlertManagementView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index fa0c1b4810..ba07442c39 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -102,7 +102,7 @@ struct AlertManagementView: View { HStack(spacing: 12) { Spacer() muteAlertIcon - Text(NSLocalizedString("Mute All Alerts", comment: "Label for button to mute all alerts")) + Text(NSLocalizedString("Mute App Sounds", comment: "Label for button to mute app sounds")) .fontWeight(.semibold) Spacer() } From 44298fc3978712e0312c5848f0d52046ebe09f1a Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 14 Jun 2024 11:41:34 -0400 Subject: [PATCH 085/184] [LOOP-4882] Mute App Sounds iOS Permissions Button Changes --- .../NotificationsCriticalAlertPermissionsView.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index 43b5cf8ab4..d9a9f973df 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -65,8 +65,7 @@ public struct NotificationsCriticalAlertPermissionsView: View { } } } - // MARK: Disabled for Formative 3. To be Re-enabled once design has provided an appropriate screen to link to. -// notificationAndCriticalAlertPermissionSupportSection + notificationAndCriticalAlertPermissionSupportSection } .insetGroupedListStyle() .navigationBarTitle(Text(NSLocalizedString("iOS Permissions", comment: "Notification & Critical Alert Permissions screen title"))) @@ -139,8 +138,16 @@ extension NotificationsCriticalAlertPermissionsView { private var notificationAndCriticalAlertPermissionSupportSection: some View { Section(header: SectionHeader(label: NSLocalizedString("Support", comment: "Section title for Support")).padding(.leading, -16).padding(.bottom, 4)) { - NavigationLink(destination: Text("Get help with iOS Permissions")) { + 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) } } } From 0c9cf49f94862f6040c3db201dd80cc735441587 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 14 Jun 2024 11:52:43 -0400 Subject: [PATCH 086/184] [LOOP-4882] Mute App Sounds iOS Permissions Button Changes --- Loop/Views/NotificationsCriticalAlertPermissionsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index d9a9f973df..c1fe410b84 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -138,6 +138,7 @@ extension NotificationsCriticalAlertPermissionsView { private var notificationAndCriticalAlertPermissionSupportSection: some View { 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")) From 6b31bf4dc9074f96f11e68e517cf6ee0f172d6a2 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 14 Jun 2024 14:02:56 -0300 Subject: [PATCH 087/184] [LOOP-4863] updated handling of notification permissions (#661) * updated handling of notification permissions * clean up --- Loop/Managers/AlertPermissionsChecker.swift | 78 ++++++++++++------- ...icationsCriticalAlertPermissionsView.swift | 2 +- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index 1f90633ef2..885cad0ae9 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -75,7 +75,7 @@ public class AlertPermissionsChecker: ObservableObject { } if #available(iOS 15.0, *) { newSettings.scheduledDeliveryEnabled = settings.scheduledDeliverySetting == .enabled - newSettings.timeSensitiveNotificationsDisabled = settings.alertSetting != .disabled && settings.timeSensitiveSetting == .disabled + newSettings.timeSensitiveDisabled = settings.alertSetting != .disabled && settings.timeSensitiveSetting == .disabled } self.notificationCenterSettings = newSettings completion?() @@ -110,37 +110,39 @@ extension AlertPermissionsChecker { enum UnsafeNotificationPermissionAlert: Hashable, CaseIterable { case notificationsDisabled case criticalAlertsDisabled - case timeSensitiveNotificationsDisabled + case timeSensitiveDisabled + case criticalAlertsAndNotificationDisabled + case criticalAlertsAndTimeSensitiveDisabled var alertTitle: String { switch self { - case .notificationsDisabled: - NSLocalizedString("Turn On Critical Alerts and Other Safety Notifications", comment: "Notifications disabled alert title") + 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 .timeSensitiveNotificationsDisabled: + case .timeSensitiveDisabled, .notificationsDisabled: NSLocalizedString("Turn On Time Sensitive Notifications ", comment: "Time sensitive notifications disabled alert title") } } var notificationTitle: String { switch self { - case .notificationsDisabled: - NSLocalizedString("Turn On Critical Alerts and other safety notifications", comment: "Notifications disabled notification title") + 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 .timeSensitiveNotificationsDisabled: + case .timeSensitiveDisabled, .notificationsDisabled: NSLocalizedString("Turn On Time Sensitive Notifications", comment: "Time sensitive notifications disabled alert title") } } var bannerTitle: String { switch self { - case .notificationsDisabled: - NSLocalizedString("Critical Alerts and other safety notifications are turned OFF", comment: "Notifications disabled banner title") + 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 .timeSensitiveNotificationsDisabled: + NSLocalizedString("Critical Alerts are turned OFF", comment: "Critical alerts disabled banner title") + case .timeSensitiveDisabled, .notificationsDisabled: NSLocalizedString("Time Sensitive Alerts are turned OFF", comment: "Time sensitive notifications disabled banner title") } } @@ -148,21 +150,25 @@ extension AlertPermissionsChecker { var alertBody: String { switch self { case .notificationsDisabled: - NSLocalizedString("Critical Alerts and other safety 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: "Notifications disabled alert body") + NSLocalizedString("Time Sensitive 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 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 .timeSensitiveNotificationsDisabled: + case .timeSensitiveDisabled: NSLocalizedString("Time Sensitive 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 Time Sensitive Notifications are turned ON.", comment: "Time sensitive notifications disabled alert body") } } var notificationBody: String { switch self { - case .notificationsDisabled: - NSLocalizedString("Critical Alerts and other safety notifications are turned OFF. Go to the App to fix the issue now.", comment: "Notifications disabled notification body") + 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 .timeSensitiveNotificationsDisabled: + 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") } } @@ -170,11 +176,15 @@ extension AlertPermissionsChecker { var bannerBody: String { switch self { case .notificationsDisabled: - NSLocalizedString("Fix now by turning Notifications and Critical Alerts ON.", comment: "Notifications disabled banner body") + 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 .timeSensitiveNotificationsDisabled: - NSLocalizedString("Fix now by turning Time Sensitive alerts ON.", comment: "Time sensitive notifications disabled banner body") + case .timeSensitiveDisabled: + NSLocalizedString("Fix now by turning Time Sensitive Notifications ON.", comment: "Time sensitive notifications disabled banner body") } } @@ -182,9 +192,13 @@ extension AlertPermissionsChecker { 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 .timeSensitiveNotificationsDisabled: + case .timeSensitiveDisabled: Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeTimeSensitiveNotificationPermissionsAlert") } } @@ -208,12 +222,16 @@ extension AlertPermissionsChecker { init?(permissions: NotificationCenterSettingsFlags) { switch permissions { - case .notificationsDisabled, NotificationCenterSettingsFlags(rawValue: 3), NotificationCenterSettingsFlags(rawValue: 5): + case .notificationsDisabled: self = .notificationsDisabled - case .criticalAlertsDisabled, NotificationCenterSettingsFlags(rawValue: 6): + case .timeSensitiveDisabled, NotificationCenterSettingsFlags(rawValue: 5): + self = .timeSensitiveDisabled + case .criticalAlertsDisabled: self = .criticalAlertsDisabled - case .timeSensitiveNotificationsDisabled: - self = .timeSensitiveNotificationsDisabled + case NotificationCenterSettingsFlags(rawValue: 3): + self = .criticalAlertsAndNotificationDisabled + case NotificationCenterSettingsFlags(rawValue: 6): + self = .criticalAlertsAndTimeSensitiveDisabled default: return nil } @@ -277,10 +295,10 @@ struct NotificationCenterSettingsFlags: OptionSet { static let none = NotificationCenterSettingsFlags([]) static let notificationsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 0) static let criticalAlertsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 1) - static let timeSensitiveNotificationsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 2) + static let timeSensitiveDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 2) static let scheduledDeliveryEnabled = NotificationCenterSettingsFlags(rawValue: 1 << 3) - static let requiresRiskMitigation: NotificationCenterSettingsFlags = [ .notificationsDisabled, .criticalAlertsDisabled, .timeSensitiveNotificationsDisabled ] + static let requiresRiskMitigation: NotificationCenterSettingsFlags = [ .notificationsDisabled, .criticalAlertsDisabled, .timeSensitiveDisabled ] } extension NotificationCenterSettingsFlags { @@ -300,12 +318,12 @@ extension NotificationCenterSettingsFlags { update(.criticalAlertsDisabled, newValue) } } - var timeSensitiveNotificationsDisabled: Bool { + var timeSensitiveDisabled: Bool { get { - contains(.timeSensitiveNotificationsDisabled) + contains(.timeSensitiveDisabled) } set { - update(.timeSensitiveNotificationsDisabled, newValue) + update(.timeSensitiveDisabled, newValue) } } var scheduledDeliveryEnabled: Bool { diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index c1fe410b84..cefb160b03 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -118,7 +118,7 @@ extension NotificationsCriticalAlertPermissionsView { HStack { Text("Time Sensitive Notifications", comment: "Time Sensitive Status text") Spacer() - onOff(!checker.notificationCenterSettings.timeSensitiveNotificationsDisabled) + onOff(!checker.notificationCenterSettings.timeSensitiveDisabled) } } From b40a3681ca73c652a313f15ef4ee4e95083e9e04 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 14 Jun 2024 13:24:45 -0500 Subject: [PATCH 088/184] Update host identifier for plugins, which is used for dataset name in TidepoolService (#664) --- Loop/Managers/ServicesManager.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 78867235b3..0bf1125386 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -263,7 +263,13 @@ extension ServicesManager: StatefulPluggableDelegate { extension ServicesManager: ServiceDelegate { var hostIdentifier: String { - return "com.loopkit.Loop" + var identifier = Bundle.main.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.Looo" + } + return identifier } var hostVersion: String { From 9ead16d1f73c31b2c8e2a8b5126ddcde8c2484f5 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 17 Jun 2024 07:58:56 -0700 Subject: [PATCH 089/184] Support building Loop with Xcode 16 --- Loop/Managers/RemoteDataServicesManager.swift | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index 41a3bd3ca7..871be0148f 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -253,10 +253,10 @@ extension RemoteDataServicesManager { do { try await remoteDataService.uploadAlertData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .alert, queryAnchor) - self.uploadSucceeded(key) + await self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing alert data: %{public}@", String(describing: error)) - self.uploadFailed(key) + await self.uploadFailed(key) } semaphore.signal() } @@ -290,10 +290,10 @@ extension RemoteDataServicesManager { try await remoteDataService.uploadCarbData(created: created, updated: updated, deleted: deleted) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .carb, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor - self.uploadSucceeded(key) + await self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing carb data: %{public}@", String(describing: error)) - self.uploadFailed(key) + await self.uploadFailed(key) } semaphore.signal() } @@ -332,10 +332,10 @@ extension RemoteDataServicesManager { 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)) - self.uploadFailed(key) + await self.uploadFailed(key) } semaphore.signal() } @@ -374,11 +374,11 @@ extension RemoteDataServicesManager { try await remoteDataService.uploadDosingDecisionData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .dosingDecision, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor - self.uploadSucceeded(key) + await self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing dosing decision data: %{public}@", String(describing: error)) - self.uploadFailed(key) + await self.uploadFailed(key) } semaphore.signal() } @@ -422,10 +422,10 @@ extension RemoteDataServicesManager { try await remoteDataService.uploadGlucoseData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .glucose, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor - self.uploadSucceeded(key) + await self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing glucose data: %{public}@", String(describing: error)) - self.uploadFailed(key) + await self.uploadFailed(key) } semaphore.signal() } @@ -464,10 +464,10 @@ extension RemoteDataServicesManager { try await remoteDataService.uploadPumpEventData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .pumpEvent, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor - self.uploadSucceeded(key) + await self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) - self.uploadFailed(key) + await self.uploadFailed(key) } semaphore.signal() } @@ -506,10 +506,10 @@ extension RemoteDataServicesManager { 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)) - self.uploadFailed(key) + await self.uploadFailed(key) } semaphore.signal() } @@ -543,10 +543,10 @@ extension RemoteDataServicesManager { 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)) - self.uploadFailed(key) + await self.uploadFailed(key) } semaphore.signal() } @@ -579,10 +579,10 @@ extension RemoteDataServicesManager { 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)) - self.uploadFailed(key) + await self.uploadFailed(key) } semaphore.signal() } From 4f061b109dc37c146eba011cbc7a10ef128ddf08 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 17 Jun 2024 14:22:33 -0300 Subject: [PATCH 090/184] [PAL-679] divider is full width of list (#665) * divider is full width of list * allow deleting the pump manage from debug menu --- .../StatusTableViewController.swift | 6 ++ Loop/Views/AlertManagementView.swift | 81 +++++++++++-------- 2 files changed, 53 insertions(+), 34 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 410d3d0e02..e3d4eacc6d 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1911,6 +1911,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) diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index ba07442c39..2ecfebe0ee 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -98,42 +98,55 @@ struct AlertManagementView: View { footer: !alertMuter.configuration.shouldMute ? Text(String(format: NSLocalizedString("Temporarily silence all sounds from %1$@, including sounds for Critical Alerts such as Urgent Low, Sensor Fail, Pump Expiration and others.\n\nWhile sounds are muted, alerts from %1$@ will still vibrate if haptics are enabled. Your insulin pump and CGM hardware may still sound.", comment: ""), appName, appName)) : nil ) { if !alertMuter.configuration.shouldMute { - Button(action: { showMuteAlertOptions = true }) { - HStack(spacing: 12) { - Spacer() - muteAlertIcon - Text(NSLocalizedString("Mute App Sounds", comment: "Label for button to mute app sounds")) - .fontWeight(.semibold) - Spacer() - } - .padding(.vertical, 6) - } - .actionSheet(isPresented: $showMuteAlertOptions) { - muteAlertOptionsActionSheet - } + muteAlertsButton } else { - Button(action: alertMuter.unmuteAlerts) { - HStack(spacing: 12) { - Spacer() - unmuteAlertIcon - Text(NSLocalizedString("Tap to Unmute App Sounds", comment: "Label for button to unmute all app sounds")) - .fontWeight(.semibold) - Spacer() - } - .padding(.vertical, 6) - } - 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 Critical Alerts such as Urgent Low, Sensor Fail, and Pump Expiration will NOT sound.", comment: "Warning label that all alerts will not sound") - .font(.footnote) - } + unmuteAlertsButton + .listRowSeparator(.visible, edges: .all) + muteAlertsSummary + } + } + } + + private var muteAlertsButton: some View { + Button(action: { showMuteAlertOptions = true }) { + HStack(spacing: 12) { + Spacer() + muteAlertIcon + Text(NSLocalizedString("Mute App Sounds", comment: "Label for button to mute app sounds")) + .fontWeight(.semibold) + Spacer() + } + .padding(.vertical, 6) + } + .actionSheet(isPresented: $showMuteAlertOptions) { + muteAlertOptionsActionSheet + } + } + + private var unmuteAlertsButton: some View { + Button(action: alertMuter.unmuteAlerts) { + HStack(spacing: 12) { + Spacer() + unmuteAlertIcon + Text(NSLocalizedString("Tap to Unmute App Sounds", comment: "Label for button to unmute all app sounds")) + .fontWeight(.semibold) + Spacer() + } + .padding(.vertical, 6) + } + } + + 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 Critical Alerts such as Urgent Low, Sensor Fail, and Pump Expiration will NOT sound.", comment: "Warning label that all alerts will not sound") + .font(.footnote) } } From 638dd3c7ecb1d52010d2cc291012888550734418 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 17 Jun 2024 14:41:54 -0300 Subject: [PATCH 091/184] [LOOP-4884] notify that loop finished (#666) --- Loop/Managers/LoopDataManager.swift | 1 + Loop/View Controllers/StatusTableViewController.swift | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2cb75f53af..330210ace7 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -577,6 +577,7 @@ final class LoopDataManager: ObservableObject { dosingDecision.appendError(loopError) await dosingDecisionStore.storeDosingDecision(dosingDecision) analyticsServicesManager?.loopDidError(error: loopError) + NotificationCenter.default.post(name: .LoopCycleCompleted, object: self) } logger.default("Loop ended") } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index e3d4eacc6d..a98bad59e3 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -118,6 +118,11 @@ final class StatusTableViewController: LoopChartsTableViewController { self?.hudView?.loopCompletionHUD.loopInProgress = true } }, + 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() From 51982acdf854aa5351225fce4b96606a4dc99d95 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 18 Jun 2024 15:36:45 -0300 Subject: [PATCH 092/184] [LOOP-4863] corrected copy (#669) --- Loop/Managers/AlertPermissionsChecker.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index 885cad0ae9..f2bd2a7cee 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -143,14 +143,14 @@ extension AlertPermissionsChecker { case .criticalAlertsDisabled: NSLocalizedString("Critical Alerts are turned OFF", comment: "Critical alerts disabled banner title") case .timeSensitiveDisabled, .notificationsDisabled: - NSLocalizedString("Time Sensitive Alerts are turned OFF", comment: "Time sensitive notifications disabled banner title") + NSLocalizedString("Time Sensitive Notifications are turned OFF", comment: "Time sensitive notifications disabled banner title") } } var alertBody: String { switch self { case .notificationsDisabled: - NSLocalizedString("Time Sensitive 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 Notifications are turned ON.", comment: "Notifications disabled alert body") + 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: @@ -158,7 +158,7 @@ extension AlertPermissionsChecker { 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 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 Time Sensitive Notifications are turned ON.", comment: "Time sensitive notifications disabled alert body") + 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") } } From f05851f8e46de8435cac2cd9702cc55857143059 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 19 Jun 2024 05:14:05 -0300 Subject: [PATCH 093/184] [LOOP-4097] only upload data after onboarding is complete (#668) --- Loop/Managers/DeviceDataManager.swift | 4 ++-- Loop/Managers/RemoteDataServicesManager.swift | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index c7f3f1a811..7f96c906d9 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1288,9 +1288,9 @@ struct CancelTempBasalFailedMaximumBasalRateChangedError: LocalizedError { extension DeviceDataManager : RemoteDataServicesManagerDelegate { var shouldSyncToRemoteService: Bool { guard let cgmManager = cgmManager else { - return true + return onboardingManager?.isComplete == true } - return cgmManager.shouldSyncToRemoteService + return cgmManager.shouldSyncToRemoteService && (onboardingManager?.isComplete == true) } } diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index 871be0148f..59a4c4410e 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -235,6 +235,8 @@ final class RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadAlertData(to remoteDataService: RemoteDataService) { + guard delegate?.shouldSyncToRemoteService == true else { return } + uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .alert) @@ -270,6 +272,8 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadCarbData(to remoteDataService: RemoteDataService) { + guard delegate?.shouldSyncToRemoteService == true else { return } + uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .carb) @@ -312,6 +316,8 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadDoseData(to remoteDataService: RemoteDataService) { + guard delegate?.shouldSyncToRemoteService == true else { return } + uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .dose) @@ -354,6 +360,8 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadDosingDecisionData(to remoteDataService: RemoteDataService) { + guard delegate?.shouldSyncToRemoteService == true else { return } + uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .dosingDecision) @@ -397,11 +405,8 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadGlucoseData(to remoteDataService: RemoteDataService) { - - if delegate?.shouldSyncToRemoteService == false { - return - } - + guard delegate?.shouldSyncToRemoteService == true else { return } + uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .glucose) @@ -444,6 +449,8 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadPumpEventData(to remoteDataService: RemoteDataService) { + guard delegate?.shouldSyncToRemoteService == true else { return } + uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .pumpEvent) @@ -486,6 +493,8 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadSettingsData(to remoteDataService: RemoteDataService) { + guard delegate?.shouldSyncToRemoteService == true else { return } + uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .settings) From 991f7a930ec31dd40016f65581a1a862bdfc1f05 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 20 Jun 2024 13:34:52 -0700 Subject: [PATCH 094/184] [LOOP-4908] Bolus Status Banner UI Updates --- .../StatusTableViewController.swift | 50 ++---- Loop/Views/BolusProgressTableViewCell.swift | 107 +++++++----- Loop/Views/BolusProgressTableViewCell.xib | 155 ++++++++++-------- 3 files changed, 173 insertions(+), 139 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index a98bad59e3..7fd059360a 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -290,7 +290,9 @@ final class StatusTableViewController: LoopChartsTableViewController { private func updateBolusProgress() { if let cell = tableView.cellForRow(at: IndexPath(row: StatusRow.status.rawValue, section: Section.status.rawValue)) as? BolusProgressTableViewCell { - cell.deliveredUnits = bolusProgressReporter?.progress.deliveredUnits + if case let .bolusing(_, total) = cell.configuration { + cell.configuration = .bolusing(delivered: bolusProgressReporter?.progress.deliveredUnits, ofTotalVolume: total) + } } } @@ -1001,7 +1003,6 @@ final class StatusTableViewController: LoopChartsTableViewController { return cell case .status: - func getTitleSubtitleCell() -> TitleSubtitleTableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: TitleSubtitleTableViewCell.className, for: indexPath) as! TitleSubtitleTableViewCell cell.selectionStyle = .none @@ -1056,45 +1057,26 @@ final class StatusTableViewController: LoopChartsTableViewController { return cell case .enactingBolus: - let cell = getTitleSubtitleCell() - cell.titleLabel.text = NSLocalizedString("Starting Bolus", comment: "The title of the cell indicating a bolus is being sent") - - let indicatorView = UIActivityIndicatorView(style: .default) - indicatorView.startAnimating() - cell.accessoryView = indicatorView - return cell + let progressCell = tableView.dequeueReusableCell(withIdentifier: BolusProgressTableViewCell.className, for: indexPath) as! BolusProgressTableViewCell + progressCell.selectionStyle = .none + progressCell.configuration = .starting + return progressCell case .bolusing(let dose): let progressCell = tableView.dequeueReusableCell(withIdentifier: BolusProgressTableViewCell.className, for: indexPath) as! BolusProgressTableViewCell progressCell.selectionStyle = .none - progressCell.totalUnits = dose.programmedUnits + progressCell.configuration = .bolusing(delivered: bolusProgressReporter?.progress.deliveredUnits, ofTotalVolume: dose.programmedUnits) progressCell.tintColor = .insulinTintColor - progressCell.deliveredUnits = bolusProgressReporter?.progress.deliveredUnits - progressCell.backgroundColor = .secondarySystemBackground return progressCell case .cancelingBolus: - let cell = getTitleSubtitleCell() - cell.titleLabel.text = NSLocalizedString("Canceling Bolus", comment: "The title of the cell indicating a bolus is being canceled") - - let indicatorView = UIActivityIndicatorView(style: .default) - indicatorView.startAnimating() - cell.accessoryView = indicatorView - return cell + let progressCell = tableView.dequeueReusableCell(withIdentifier: BolusProgressTableViewCell.className, for: indexPath) as! BolusProgressTableViewCell + progressCell.selectionStyle = .none + progressCell.configuration = .canceling + return progressCell case .canceledBolus(let dose): - let cell = getTitleSubtitleCell() - - lazy var insulinFormatter: QuantityFormatter = { - let formatter = QuantityFormatter(for: .internationalUnit()) - formatter.numberFormatter.minimumFractionDigits = 2 - return formatter - }() - - let totalUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: dose.programmedUnits) - let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" - - let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: dose.deliveredUnits ?? 0) - let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" - cell.titleLabel.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) - return cell + 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") diff --git a/Loop/Views/BolusProgressTableViewCell.swift b/Loop/Views/BolusProgressTableViewCell.swift index 3752201d7d..5f28ea7794 100644 --- a/Loop/Views/BolusProgressTableViewCell.swift +++ b/Loop/Views/BolusProgressTableViewCell.swift @@ -14,6 +14,17 @@ import MKRingProgressView public class BolusProgressTableViewCell: UITableViewCell { + + public enum Configuration { + case starting + case bolusing(delivered: Double?, ofTotalVolume: Double) + case canceling + case canceled(delivered: Double, ofTotalVolume: Double) + } + + @IBOutlet weak var activityIndicator: UIActivityIndicatorView! + @IBOutlet weak var paddedView: UIView! + @IBOutlet weak var progressLabel: UILabel! @IBOutlet weak var tapToStopLabel: UILabel! { @@ -30,20 +41,12 @@ public class BolusProgressTableViewCell: UITableViewCell { @IBOutlet weak var progressIndicator: RingProgressView! - public var totalUnits: Double? { - didSet { - updateProgress() - } - } - - public var deliveredUnits: Double? { + public var configuration: Configuration? { didSet { updateProgress() } } - private lazy var gradient = CAGradientLayer() - private var doseTotalUnits: Double? private var disableUpdates: Bool = false @@ -57,17 +60,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,39 +83,70 @@ public class BolusProgressTableViewCell: UITableViewCell { progressIndicator.startColor = tintColor progressIndicator.endColor = tintColor stopSquare.backgroundColor = tintColor - gradient.colors = [ - UIColor.cellBackgroundColor.withAlphaComponent(0).cgColor, - UIColor.cellBackgroundColor.cgColor - ] } private func updateProgress() { - guard !disableUpdates, let totalUnits = totalUnits else { + guard let configuration else { + progressIndicator.isHidden = true + activityIndicator.isHidden = true + tapToStopLabel.isHidden = true return } - - let totalUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: totalUnits) - let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" - - if let deliveredUnits = deliveredUnits { - let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: deliveredUnits) - let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" - - progressLabel.text = String(format: NSLocalizedString("Bolused %1$@ of %2$@", comment: "The format string for bolus progress. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) - - let progress = deliveredUnits / totalUnits - UIView.animate(withDuration: 0.3) { - self.progressIndicator.progress = progress + + switch configuration { + case .starting: + progressIndicator.isHidden = true + activityIndicator.isHidden = false + tapToStopLabel.isHidden = true + + progressLabel.text = NSLocalizedString("Starting Bolus", comment: "The title of the cell indicating a bolus is being sent") + case let .bolusing(delivered, totalVolume): + progressIndicator.isHidden = false + activityIndicator.isHidden = true + tapToStopLabel.isHidden = false + + let totalUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: totalVolume) + let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" + + if let delivered { + let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: delivered) + let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" + + progressLabel.text = String(format: NSLocalizedString("Bolused %1$@ of %2$@", comment: "The format string for bolus progress. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) + + let progress = delivered / totalVolume + + UIView.animate(withDuration: 0.3) { + self.progressIndicator.progress = progress + } + } else { + progressLabel.text = String(format: NSLocalizedString("Bolusing %1$@", comment: "The format string for bolus in progress showing total volume. (1: total volume)"), totalUnitsString) } - } else { - progressLabel.text = String(format: NSLocalizedString("Bolusing %1$@", comment: "The format string for bolus in progress showing total volume. (1: total volume)"), totalUnitsString) + case .canceling: + progressIndicator.isHidden = true + activityIndicator.isHidden = false + tapToStopLabel.isHidden = true + + progressLabel.text = NSLocalizedString("Canceling Bolus", comment: "The title of the cell indicating a bolus is being canceled") + case let .canceled(delivered, totalVolume): + progressIndicator.isHidden = true + activityIndicator.isHidden = true + tapToStopLabel.isHidden = true + + let totalUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: totalVolume) + let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" + + let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: delivered) + let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" + + progressLabel.text = String(format: NSLocalizedString("Bolus Canceled: Delivered %1$@ of %2$@", comment: "The title of the cell indicating a bolus has been canceled. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) } } override public func prepareForReuse() { super.prepareForReuse() disableUpdates = true - deliveredUnits = 0 + configuration = nil disableUpdates = false progressIndicator.progress = 0 CATransaction.flush() 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 @@ - - + + - + + + - - + + - + - - - - - - + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - + - + - - - - + + + + - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - + + + + - + + - + + + + + + + From fd8d86dd6a09fed1293a30e9236ca4b658d06785 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 20 Jun 2024 13:38:24 -0700 Subject: [PATCH 095/184] [LOOP-4908] Bolus Status Banner UI Updates --- Loop/Views/BolusProgressTableViewCell.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Loop/Views/BolusProgressTableViewCell.swift b/Loop/Views/BolusProgressTableViewCell.swift index 5f28ea7794..3ac0af962b 100644 --- a/Loop/Views/BolusProgressTableViewCell.swift +++ b/Loop/Views/BolusProgressTableViewCell.swift @@ -24,7 +24,7 @@ public class BolusProgressTableViewCell: UITableViewCell { @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! { @@ -39,18 +39,12 @@ public class BolusProgressTableViewCell: UITableViewCell { } } - @IBOutlet weak var progressIndicator: RingProgressView! - public var configuration: Configuration? { didSet { updateProgress() } } - private var doseTotalUnits: Double? - - private var disableUpdates: Bool = false - lazy var insulinFormatter: QuantityFormatter = { let formatter = QuantityFormatter(for: .internationalUnit()) formatter.numberFormatter.minimumFractionDigits = 2 @@ -145,9 +139,7 @@ public class BolusProgressTableViewCell: UITableViewCell { override public func prepareForReuse() { super.prepareForReuse() - disableUpdates = true configuration = nil - disableUpdates = false progressIndicator.progress = 0 CATransaction.flush() progressLabel.text = "" From 9f2cc11b68653b9b1d6f697fb453e463189265e6 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 24 Jun 2024 12:49:05 -0700 Subject: [PATCH 096/184] [LOOP-4910] Mute All App Sounds Copy Update --- Loop/Views/AlertManagementView.swift | 2 +- Loop/Views/HowMuteAlertWorkView.swift | 2 +- Loop/Views/SettingsView.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 2ecfebe0ee..1f9332276c 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -112,7 +112,7 @@ struct AlertManagementView: View { HStack(spacing: 12) { Spacer() muteAlertIcon - Text(NSLocalizedString("Mute App Sounds", comment: "Label for button to mute app sounds")) + Text(NSLocalizedString("Mute All App Sounds", comment: "Label for button to mute all app sounds")) .fontWeight(.semibold) Spacer() } diff --git a/Loop/Views/HowMuteAlertWorkView.swift b/Loop/Views/HowMuteAlertWorkView.swift index 4394bbb2f9..30f72d574a 100644 --- a/Loop/Views/HowMuteAlertWorkView.swift +++ b/Loop/Views/HowMuteAlertWorkView.swift @@ -75,7 +75,7 @@ struct HowMuteAlertWorkView: View { Text( String( format: NSLocalizedString( - "Use the Mute 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.", + "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 diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index c9409f905c..f8469856cf 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -304,7 +304,7 @@ extension SettingsView { .frame(width: 30), secondaryImageView: alertWarning, label: NSLocalizedString("Alert Management", comment: "Alert Permissions button text"), - descriptiveText: NSLocalizedString("iOS Permissions and Mute App Sounds", comment: "Alert Permissions descriptive text") + descriptiveText: NSLocalizedString("iOS Permissions and Mute All App Sounds", comment: "Alert Permissions descriptive text") ) } } From 21c6e58f4ba3a749e7d35272feed35dea9f3289f Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 24 Jun 2024 13:00:06 -0700 Subject: [PATCH 097/184] [LOOP-4910] Mute All App Sounds Copy Update --- Loop/Views/AlertManagementView.swift | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 1f9332276c..474afe387a 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -95,7 +95,7 @@ struct AlertManagementView: View { private var muteAlertsSection: some View { Section( header: Text(String(format: "%1$@", appName)), - footer: !alertMuter.configuration.shouldMute ? Text(String(format: NSLocalizedString("Temporarily silence all sounds from %1$@, including sounds for Critical Alerts such as Urgent Low, Sensor Fail, Pump Expiration and others.\n\nWhile sounds are muted, alerts from %1$@ will still vibrate if haptics are enabled. Your insulin pump and CGM hardware may still sound.", comment: ""), appName, appName)) : nil + 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 { muteAlertsButton @@ -111,7 +111,6 @@ struct AlertManagementView: View { Button(action: { showMuteAlertOptions = true }) { HStack(spacing: 12) { Spacer() - muteAlertIcon Text(NSLocalizedString("Mute All App Sounds", comment: "Label for button to mute all app sounds")) .fontWeight(.semibold) Spacer() @@ -150,16 +149,6 @@ struct AlertManagementView: View { } } - private var muteAlertIcon: some View { - Image(systemName: "speaker.slash.fill") - .resizable() - .foregroundColor(.white) - .padding(5) - .frame(width: 22, height: 22) - .background(Color.accentColor) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) - } - private var unmuteAlertIcon: some View { Image(systemName: "speaker.wave.2.fill") .resizable() From 1ae3a5ff8ed88c88763c25e6d4241ac750cb4ccd Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 25 Jun 2024 05:25:31 -0300 Subject: [PATCH 098/184] [LOOP-4853] Date in event history (#634) * adding relative date to event history * using section headers * uppercase relative date * revert unintended change --- .../InsulinDeliveryTableViewController.swift | 66 ++++++++++++++++++- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/Loop/View Controllers/InsulinDeliveryTableViewController.swift b/Loop/View Controllers/InsulinDeliveryTableViewController.swift index 54ea7273d7..495ff8ba85 100644 --- a/Loop/View Controllers/InsulinDeliveryTableViewController.swift +++ b/Loop/View Controllers/InsulinDeliveryTableViewController.swift @@ -200,6 +200,11 @@ public final class InsulinDeliveryTableViewController: UITableViewController { case history([PersistedPumpEvent]) case manualEntryDoses([DoseEntry]) } + + private enum HistorySection: Int { + case today + case yesterday + } // Not thread-safe private var values = Values.reservoir([]) { @@ -282,6 +287,16 @@ 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 { @@ -382,7 +397,10 @@ public final class InsulinDeliveryTableViewController: UITableViewController { case .unknown, .unavailable: return 0 case .display: - return 1 + switch self.values { + case .history(let values): return values.valuesBeforeToday.isEmpty ? 1 : 2 + default: return 1 + } } } @@ -391,12 +409,36 @@ public final class InsulinDeliveryTableViewController: UITableViewController { case .reservoir(let values): return values.count case .history(let values): - return values.count + switch HistorySection(rawValue: section) { + case .today: return values.valuesFromToday.count + case .yesterday: return values.valuesBeforeToday.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 values): + switch HistorySection(rawValue: section) { + case .today: + guard let firstValue = values.valuesFromToday.first else { return nil } + return dateFormatter.string(from: firstValue.date).uppercased() + case .yesterday: + guard let firstValue = values.valuesBeforeToday.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) @@ -413,7 +455,13 @@ public final class InsulinDeliveryTableViewController: UITableViewController { cell.accessoryType = .none cell.selectionStyle = .none case .history(let values): - let entry = values[indexPath.row] + let filterValues: [PersistedPumpEvent] + if HistorySection(rawValue: indexPath.section) == .today { + filterValues = values.valuesFromToday + } else { + filterValues = values.valuesBeforeToday + } + let entry = filterValues[indexPath.row] let time = timeFormatter.string(from: entry.date) if let attributedText = entry.localizedAttributedDescription { @@ -635,3 +683,15 @@ extension PersistedPumpEvent { } extension InsulinDeliveryTableViewController: IdentifiableClass { } + +fileprivate extension Array where Element == PersistedPumpEvent { + var valuesFromToday: [PersistedPumpEvent] { + let startOfDay = Calendar.current.startOfDay(for: Date()) + return self.filter({ $0.date >= startOfDay}) + } + + var valuesBeforeToday: [PersistedPumpEvent] { + let startOfDay = Calendar.current.startOfDay(for: Date()) + return self.filter({ $0.date < startOfDay}) + } +} From ba3942e4c9caeecc88bd23d80319e9f690ad9d28 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 25 Jun 2024 22:44:06 +0200 Subject: [PATCH 099/184] Remove suspend effect from glucose prediction details page (#673) --- Loop/View Controllers/PredictionTableViewController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index 849e7e22d7..79ab21a35f 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -188,7 +188,9 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable private var eventualGlucoseDescription: String? - private var availableInputs: [PredictionInputEffect] = [.carbs, .insulin, .momentum, .retrospection, .suspend] + // Removed .suspend from this list; LoopAlgorithm needs updates to support this. Also review + // for better ways to support desired use cases. https://github.com/LoopKit/Loop/pull/2026 + private var availableInputs: [PredictionInputEffect] = [.carbs, .insulin, .momentum, .retrospection] private var selectedInputs = PredictionInputEffect.all From fec363511cb17496f14b21c950e910a0ac125258 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 27 Jun 2024 13:27:50 -0300 Subject: [PATCH 100/184] [LOOP-4683] align IOB (#660) --- .../View Controllers/StatusTableViewController.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 7fd059360a..cb9352030f 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -31,6 +31,12 @@ final class StatusTableViewController: LoopChartsTableViewController { private let log = OSLog(category: "StatusTableViewController") lazy var carbFormatter: QuantityFormatter = QuantityFormatter(for: .gram()) + + lazy var insulinFormatter: QuantityFormatter = { + let formatter = QuantityFormatter(for: .internationalUnit()) + formatter.numberFormatter.minimumFractionDigits = 2 + return formatter + }() var onboardingManager: OnboardingManager! @@ -550,10 +556,8 @@ final class StatusTableViewController: LoopChartsTableViewController { } // 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) + if let activeInsulin = loopManager.activeInsulin { + self.currentIOBDescription = insulinFormatter.string(from: activeInsulin.quantity, includeUnit: false) } else { self.currentIOBDescription = nil } From 19b6d0ab669c4c2a97112b91f071c7337f2cf396 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 27 Jun 2024 13:28:42 -0300 Subject: [PATCH 101/184] [PAL-612] protect selecdting carb entry when automative dosing off (#672) --- Loop/Models/AutomaticDosingStatus.swift | 2 +- .../CarbAbsorptionViewController.swift | 40 +++++++++++-------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/Loop/Models/AutomaticDosingStatus.swift b/Loop/Models/AutomaticDosingStatus.swift index f717a80c32..c5b66e955c 100644 --- a/Loop/Models/AutomaticDosingStatus.swift +++ b/Loop/Models/AutomaticDosingStatus.swift @@ -13,7 +13,7 @@ public class AutomaticDosingStatus: ObservableObject { @Published public var isAutomaticDosingAllowed: Bool public init(automaticDosingEnabled: Bool, - isAutomaticDosingAllowed: Bool) + isAutomaticDosingAllowed: Bool) { self.automaticDosingEnabled = automaticDosingEnabled self.isAutomaticDosingAllowed = isAutomaticDosingAllowed diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index 03b1b9acbd..88aefd7c5d 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -444,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: @@ -453,23 +453,29 @@ 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: loopDataManager, originalCarbEntry: originalCarbEntry) - viewModel.analyticsServicesManager = analyticsServicesManager - viewModel.deliveryDelegate = deviceManager - 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 = CarbEntryViewModel(delegate: loopDataManager, originalCarbEntry: originalCarbEntry) + viewModel.analyticsServicesManager = analyticsServicesManager + viewModel.deliveryDelegate = deviceManager + 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) + default: + return + } } @objc func carbEditWasCanceled() { From df5215f5dd26b97f1676d8f38ece96af4331a866 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 27 Jun 2024 18:38:38 -0300 Subject: [PATCH 102/184] [LOOP-4877] block UI updates for certaint bolus transitions (#674) --- Loop/View Controllers/StatusTableViewController.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index cb9352030f..31499c59e4 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -772,16 +772,23 @@ final class StatusTableViewController: LoopChartsTableViewController { switch (statusWasVisible, statusIsVisible) { case (true, true): switch (oldStatusRowMode, self.statusRowMode) { + case (.pumpSuspended(resuming: let wasResuming), .pumpSuspended(resuming: let isResuming)): + if isResuming != wasResuming { + tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) + } case (.enactingBolus, .enactingBolus): break case (.bolusing(let oldDose), .bolusing(let newDose)): if oldDose.syncIdentifier != newDose.syncIdentifier { tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } - case (.pumpSuspended(resuming: let wasResuming), .pumpSuspended(resuming: let isResuming)): - if isResuming != wasResuming { + case (.canceledBolus(let oldDose), .canceledBolus(let newDose)): + if oldDose != newDose { tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } + case (.cancelingBolus, .cancelingBolus), (.cancelingBolus, .bolusing(_)), (.canceledBolus(_), .cancelingBolus), (.canceledBolus(_), .bolusing(_)): + // these updates cause flickering and/or confusion. + break default: tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } From 2e188b3b03f577678cb457523dccea8d3429854c Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 28 Jun 2024 05:22:28 -0300 Subject: [PATCH 103/184] set max fractional digits to 2 --- Loop/View Controllers/StatusTableViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 31499c59e4..6ab834db41 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -34,7 +34,7 @@ final class StatusTableViewController: LoopChartsTableViewController { lazy var insulinFormatter: QuantityFormatter = { let formatter = QuantityFormatter(for: .internationalUnit()) - formatter.numberFormatter.minimumFractionDigits = 2 + formatter.numberFormatter.maximumFractionDigits = 2 return formatter }() From 09845cfd424d84aaae168725276cb307f41295a9 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 28 Jun 2024 05:45:40 -0300 Subject: [PATCH 104/184] removed commented out code --- Loop/Managers/DeviceDataManager.swift | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 7f96c906d9..e113cbf968 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -585,29 +585,6 @@ final class DeviceDataManager { await self.checkPumpDataAndLoop() } } - -//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() async { await refreshCGM() From 2f5b94c6734672f0f107e9a02fea528de0faaaba Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 1 Jul 2024 12:55:48 -0700 Subject: [PATCH 105/184] [LOOP-4390] Mute All App Sounds Picker UX Enhancement --- Loop/Views/AlertManagementView.swift | 63 +++++++++++++++++----------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 474afe387a..870a3147ae 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -17,7 +17,18 @@ struct AlertManagementView: View { @ObservedObject private var checker: AlertPermissionsChecker @ObservedObject private var alertMuter: AlertMuter - @State private var showMuteAlertOptions: 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() @@ -30,7 +41,7 @@ struct AlertManagementView: View { Binding( get: { formatter.string(from: alertMuter.configuration.duration)! }, set: { newValue in - guard let selectedDurationIndex = formatterDurations.firstIndex(of: newValue) + guard let selectedDurationIndex = AlertMuter.allowedDurations.compactMap({ formatter.string(from: $0) }).firstIndex(of: newValue) else { return } DispatchQueue.main.async { // avoid publishing during view update @@ -40,10 +51,6 @@ struct AlertManagementView: View { } ) } - - private var formatterDurations: [String] { - AlertMuter.allowedDurations.compactMap { formatter.string(from: $0) } - } private var missedMealNotificationsEnabled: Binding { Binding( @@ -108,17 +115,38 @@ struct AlertManagementView: View { } private var muteAlertsButton: some View { - Button(action: { showMuteAlertOptions = true }) { + 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() } - .padding(.vertical, 6) + .padding(.vertical, 8) } - .actionSheet(isPresented: $showMuteAlertOptions) { - muteAlertOptionsActionSheet + .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 + } } } @@ -131,7 +159,7 @@ struct AlertManagementView: View { .fontWeight(.semibold) Spacer() } - .padding(.vertical, 6) + .padding(.vertical, 8) } } @@ -159,19 +187,6 @@ struct AlertManagementView: View { .background(guidanceColors.warning) .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) } - - private var muteAlertOptionsActionSheet: ActionSheet { - var muteAlertDurationOptions: [SwiftUI.Alert.Button] = formatterDurations.map { muteAlertDuration in - .default(Text(muteAlertDuration), - action: { formattedSelectedDuration.wrappedValue = muteAlertDuration }) - } - muteAlertDurationOptions.append(.cancel()) - - return ActionSheet( - title: Text(NSLocalizedString("Set Time Duration", comment: "Title for mute alert duration selection action sheet")), - message: Text(NSLocalizedString("All app sounds, including sounds for Critical Alerts such as Urgent Low, Sensor Fail, and Pump Expiration will NOT sound.", comment: "Message for mute alert duration selection action sheet")), - buttons: muteAlertDurationOptions) - } private var missedMealAlertSection: some View { Section(footer: DescriptiveText(label: NSLocalizedString("When enabled, Loop can notify you when it detects a meal that wasn't logged.", comment: "Description of missed meal notifications."))) { From 7d36e70a32c1bd1c0d28348ed55a5ba823a6632b Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 1 Jul 2024 14:00:16 -0700 Subject: [PATCH 106/184] [LOOP-4390] Mute All App Sounds Picker UX Enhancement --- .../StatusTableViewController.swift | 4 +-- Loop/Views/AlertManagementView.swift | 27 +++++++------------ Loop/Views/SettingsView.swift | 6 ++--- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 7fd059360a..4c7fc2ab7b 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1272,8 +1272,8 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func presentUnmuteAlertConfirmation() { - let title = NSLocalizedString("Unmute App Sounds?", comment: "The alert title for unmute alert confirmation") - let body = NSLocalizedString("Tap Unmute to resume app sounds for your alerts and alarms.", comment: "The alert body for unmute alert confirmation") + let title = NSLocalizedString("Unmute All App Sounds?", comment: "The alert title for unmute all app sounds confirmation") + let body = NSLocalizedString("Tap Unmute to resume all app sounds for your alerts.", comment: "The alert body for unmute alert confirmation") let action = UIAlertAction( title: NSLocalizedString("Unmute", comment: "The title of the action used to unmute app sounds"), style: .default) { _ in diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 870a3147ae..d91e6c4991 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -152,14 +152,15 @@ struct AlertManagementView: View { private var unmuteAlertsButton: some View { Button(action: alertMuter.unmuteAlerts) { - HStack(spacing: 12) { - Spacer() - unmuteAlertIcon - Text(NSLocalizedString("Tap to Unmute App Sounds", comment: "Label for button to unmute all app sounds")) + Group { + Text(Image(systemName: "speaker.slash.fill")) + .foregroundColor(guidanceColors.warning) + + Text(" ") + + Text(NSLocalizedString("Tap to Unmute All App Sounds", comment: "Label for button to unmute all app sounds")) .fontWeight(.semibold) - Spacer() } - .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .padding(8) } } @@ -172,21 +173,11 @@ struct AlertManagementView: View { .foregroundColor(.secondary) } - Text("All app sounds, including sounds for Critical Alerts such as Urgent Low, Sensor Fail, and Pump Expiration will NOT sound.", comment: "Warning label that all alerts will not sound") + 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) } } - - private var unmuteAlertIcon: some View { - Image(systemName: "speaker.wave.2.fill") - .resizable() - .foregroundColor(.white) - .padding(.vertical, 5) - .padding(.horizontal, 2) - .frame(width: 22, height: 22) - .background(guidanceColors.warning) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) - } private var missedMealAlertSection: some View { Section(footer: DescriptiveText(label: NSLocalizedString("When enabled, Loop can notify you when it detects a meal that wasn't logged.", comment: "Description of missed meal notifications."))) { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index f8469856cf..850c0df68e 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -284,11 +284,9 @@ extension SettingsView { } else if viewModel.alertMuter.configuration.shouldMute { Image(systemName: "speaker.slash.fill") .resizable() - .foregroundColor(.white) + .aspectRatio(contentMode: .fit) + .foregroundColor(guidanceColors.warning) .padding(5) - .frame(width: 22, height: 22) - .background(guidanceColors.warning) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) } } From 249cc6c0520f07802d80caea0f0fd38521d1e845 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 2 Jul 2024 12:00:08 -0700 Subject: [PATCH 107/184] [PAL-653] Investigation Device Warning --- Common/Extensions/NSBundle.swift | 4 ++++ Loop/Info.plist | 2 ++ .../StatusTableViewController.swift | 1 + Loop/Views/SettingsView.swift | 19 ++++++++++++++++--- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Common/Extensions/NSBundle.swift b/Common/Extensions/NSBundle.swift index 57b7d6ad88..6eb68302af 100644 --- a/Common/Extensions/NSBundle.swift +++ b/Common/Extensions/NSBundle.swift @@ -40,6 +40,10 @@ extension Bundle { var appStoreURL: String? { return object(forInfoDictionaryKey: "AppStoreURL") as? String } + + var isInvestigationalDevice: Bool { + return object(forInfoDictionaryKey: "IsInvestigationalDevice") as? String == "YES" + } var isAppExtension: Bool { return bundleURL.pathExtension == "appex" diff --git a/Loop/Info.plist b/Loop/Info.plist index ddad5426ac..a6a7ca27ca 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -6,6 +6,8 @@ $(APP_GROUP_IDENTIFIER) AppStoreURL $(APP_STORE_URL) + IsInvestigationalDevice + $(IS_INVESTIGATIONAL_DEVICE) BGTaskSchedulerPermittedIdentifiers com.loopkit.background-task.critical-event-log.historical-export diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 1fddcc2131..19bc526cb5 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1615,6 +1615,7 @@ final class StatusTableViewController: LoopChartsTableViewController { rootView: SettingsView(viewModel: viewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucosePreference) .environment(\.appName, Bundle.main.bundleDisplayName) + .environment(\.isInvestigationalDevice, Bundle.main.isInvestigationalDevice) .environment(\.loopStatusColorPalette, .loopStatus), isModalInPresentation: false) present(hostingController, animated: true) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 850c0df68e..86fdc840cb 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -22,6 +22,7 @@ public struct SettingsView: View { @Environment(\.carbTintColor) private var carbTintColor @Environment(\.glucoseTintColor) private var glucoseTintColor @Environment(\.insulinTintColor) private var insulinTintColor + @Environment(\.isInvestigationalDevice) private var isInvestigationalDevice @ObservedObject var viewModel: SettingsViewModel @ObservedObject var versionUpdateViewModel: VersionUpdateViewModel @@ -219,9 +220,21 @@ extension SettingsView { private var loopSection: some View { Section( - header: SectionHeader( - label: localizedAppNameAndVersion.description - ) + header: + VStack(alignment: .leading, spacing: 8) { + SectionHeader(label: localizedAppNameAndVersion.description) + + Group { + Text(Image(systemName: "exclamationmark.triangle.fill")) + .foregroundColor(guidanceColors.warning) + + Text(" ") + + Text("Caution: For Investigational Use Only") + } + .font(.callout) + .textCase(nil) + .foregroundColor(.primary) + } + .padding(.bottom, 6) ) { ConfirmationToggle( isOn: closedLoopToggleState, From 4501dae48e0aff187f007f858d11e6bae2cdfa66 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 2 Jul 2024 12:09:11 -0700 Subject: [PATCH 108/184] [PAL-653] Investigation Device Warning --- Loop/Views/SettingsView.swift | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 86fdc840cb..bb8455c42a 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -224,17 +224,19 @@ extension SettingsView { VStack(alignment: .leading, spacing: 8) { SectionHeader(label: localizedAppNameAndVersion.description) - Group { - Text(Image(systemName: "exclamationmark.triangle.fill")) - .foregroundColor(guidanceColors.warning) + - Text(" ") + - Text("Caution: For Investigational Use Only") + if isInvestigationalDevice { + Group { + Text(Image(systemName: "exclamationmark.triangle.fill")) + .foregroundColor(guidanceColors.warning) + + Text(" ") + + Text("Caution: For Investigational Use Only") + } + .font(.callout) + .textCase(nil) + .foregroundColor(.primary) + .padding(.bottom, 6) } - .font(.callout) - .textCase(nil) - .foregroundColor(.primary) } - .padding(.bottom, 6) ) { ConfirmationToggle( isOn: closedLoopToggleState, From 7952770951cd273e0eff185e215c94062b0aa2c2 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 3 Jul 2024 11:45:32 -0700 Subject: [PATCH 109/184] [PAL-653] Investigation Device Warning --- Loop/Views/SettingsView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index bb8455c42a..31a4f1bbaa 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -229,9 +229,9 @@ extension SettingsView { Text(Image(systemName: "exclamationmark.triangle.fill")) .foregroundColor(guidanceColors.warning) + Text(" ") + - Text("Caution: For Investigational Use Only") + Text("CAUTION - Investigational device. Limited by Federal (or United States) law to investigational use.") } - .font(.callout) + .font(.footnote) .textCase(nil) .foregroundColor(.primary) .padding(.bottom, 6) From eb70fac69ef2a5d1cbaa42d80cdc20262c93afe3 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 4 Jul 2024 06:52:58 -0300 Subject: [PATCH 110/184] [LOOP-4884] when loop opens and loop status icon is animated, stop animating (#677) * when loop opens and loop status icon is animated, stop animating * added condition to protect animating when open --- LoopUI/Views/LoopStateView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index eedc483de4..95991048b7 100644 --- a/LoopUI/Views/LoopStateView.swift +++ b/LoopUI/Views/LoopStateView.swift @@ -24,6 +24,9 @@ final class LoopStateView: UIView { var open = false { didSet { if open != oldValue { + if open, animated { + animated = false + } shapeLayer.path = drawPath() } } @@ -87,7 +90,7 @@ final class LoopStateView: UIView { var animated: Bool = false { didSet { if animated != oldValue { - if animated { + if animated, !open { let path = CABasicAnimation(keyPath: "path") path.fromValue = shapeLayer.path ?? drawPath() path.toValue = drawPath(lineWidth: 16) From d11d435c85be7f61a1f0a2fa2396a0b574d3bc81 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 8 Jul 2024 14:05:22 -0300 Subject: [PATCH 111/184] allow insulin model selection configuration (#654) Co-authored-by: Pete Schwamb --- Loop/Managers/OnboardingManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index c8918a351d..781d4272d4 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -147,7 +147,7 @@ class OnboardingManager { } private func displayOnboarding(_ onboarding: OnboardingUI, resuming: Bool) -> Bool { - var onboardingViewController = onboarding.onboardingViewController(onboardingProvider: self, displayGlucosePreference: deviceDataManager.displayGlucosePreference, colorPalette: .default) + var onboardingViewController = onboarding.onboardingViewController(onboardingProvider: self, displayGlucosePreference: deviceDataManager.displayGlucosePreference, colorPalette: .default, adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled) onboardingViewController.cgmManagerOnboardingDelegate = deviceDataManager onboardingViewController.pumpManagerOnboardingDelegate = deviceDataManager onboardingViewController.serviceOnboardingDelegate = servicesManager From af3a02d75523b970236fbb326aea20a924a5a96f Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 8 Jul 2024 10:45:28 -0700 Subject: [PATCH 112/184] [PAL-653] Investigation Device Warning --- Common/Extensions/NSBundle.swift | 4 ---- Common/FeatureFlags.swift | 11 +++++++++-- Loop/Info.plist | 2 -- Loop/View Controllers/StatusTableViewController.swift | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Common/Extensions/NSBundle.swift b/Common/Extensions/NSBundle.swift index 6eb68302af..57b7d6ad88 100644 --- a/Common/Extensions/NSBundle.swift +++ b/Common/Extensions/NSBundle.swift @@ -40,10 +40,6 @@ extension Bundle { var appStoreURL: String? { return object(forInfoDictionaryKey: "AppStoreURL") as? String } - - var isInvestigationalDevice: Bool { - return object(forInfoDictionaryKey: "IsInvestigationalDevice") as? String == "YES" - } var isAppExtension: Bool { return bundleURL.pathExtension == "appex" diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 0c6440b564..44d8a84e4b 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -39,7 +39,7 @@ struct FeatureFlagConfiguration: Decodable { let profileExpirationSettingsViewEnabled: Bool let missedMealNotifications: Bool let allowAlgorithmExperiments: Bool - + let isInvestigationalDevice: Bool fileprivate init() { // Swift compiler config is inverse, since the default state is enabled. @@ -232,6 +232,12 @@ struct FeatureFlagConfiguration: Decodable { #else self.allowAlgorithmExperiments = false #endif + + #if INVESTIGATIONAL_DEVICE + self.isInvestigationalDevice = true + #else + self.isInvestigationalDevice = false + #endif } } @@ -267,7 +273,8 @@ extension FeatureFlagConfiguration : CustomDebugStringConvertible { "* profileExpirationSettingsViewEnabled: \(profileExpirationSettingsViewEnabled)", "* missedMealNotifications: \(missedMealNotifications)", "* allowAlgorithmExperiments: \(allowAlgorithmExperiments)", - "* allowExperimentalFeatures: \(allowExperimentalFeatures)" + "* allowExperimentalFeatures: \(allowExperimentalFeatures)", + "* isInvestigationalDevice: \(isInvestigationalDevice)" ].joined(separator: "\n") } } diff --git a/Loop/Info.plist b/Loop/Info.plist index a6a7ca27ca..ddad5426ac 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -6,8 +6,6 @@ $(APP_GROUP_IDENTIFIER) AppStoreURL $(APP_STORE_URL) - IsInvestigationalDevice - $(IS_INVESTIGATIONAL_DEVICE) BGTaskSchedulerPermittedIdentifiers com.loopkit.background-task.critical-event-log.historical-export diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 58b6c32d25..d3b9d1dce6 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1615,7 +1615,7 @@ final class StatusTableViewController: LoopChartsTableViewController { rootView: SettingsView(viewModel: viewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucosePreference) .environment(\.appName, Bundle.main.bundleDisplayName) - .environment(\.isInvestigationalDevice, Bundle.main.isInvestigationalDevice) + .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) .environment(\.loopStatusColorPalette, .loopStatus), isModalInPresentation: false) present(hostingController, animated: true) From def824f665d014ce8c3e26b17e77d8aa0e354667 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 8 Jul 2024 12:41:04 -0700 Subject: [PATCH 113/184] [LOOP-4942] Use proper guidanceColors for DismissableHostingController --- Loop/View Controllers/StatusTableViewController.swift | 3 ++- Loop/View Models/SettingsViewModel.swift | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index d3b9d1dce6..9969a67c22 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1431,7 +1431,8 @@ final class StatusTableViewController: LoopChartsTableViewController { let hostingController = DismissibleHostingController( content: bolusEntryView( enableManualGlucoseEntry: enableManualGlucoseEntry - ) + ), + guidanceColors: .default ) let navigationWrapper = UINavigationController(rootViewController: hostingController) diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 0d0b892bdd..7a123ad400 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -187,7 +187,7 @@ extension SettingsViewModel { 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, From ece83ccdc1492f30825070f98837eaf2a6168a66 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 9 Jul 2024 07:05:17 -0700 Subject: [PATCH 114/184] [LOOP-4942] Use proper guidanceColors for DismissableHostingController --- Loop/View Controllers/StatusTableViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 9969a67c22..8044467a5a 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1429,10 +1429,10 @@ final class StatusTableViewController: LoopChartsTableViewController { func presentBolusEntryView(enableManualGlucoseEntry: Bool = false) { let hostingController = DismissibleHostingController( - content: bolusEntryView( + rootView: bolusEntryView( enableManualGlucoseEntry: enableManualGlucoseEntry ), - guidanceColors: .default + isModalInPresentation: false ) let navigationWrapper = UINavigationController(rootViewController: hostingController) From a82befbf1999ca5aa303c2f22ba6c3360066aa39 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 10 Jul 2024 12:31:04 -0300 Subject: [PATCH 115/184] [LOOP-4905] Separating new data from uploads to remove blocking queues (#681) * separating new data from uploads to remove blocking queues * putting the performUpload task on the main thread * removing unneeded awaits * corrected function call --- Loop/Managers/RemoteDataServicesManager.swift | 52 +++++++++++-------- Loop/Managers/ServicesManager.swift | 8 +-- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index 59a4c4410e..097cab00bd 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -187,8 +187,14 @@ final class RemoteDataServicesManager { } } } - + 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})) @@ -217,16 +223,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: ()) } } @@ -255,10 +261,10 @@ extension RemoteDataServicesManager { do { try await remoteDataService.uploadAlertData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .alert, queryAnchor) - await self.uploadSucceeded(key) + self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing alert data: %{public}@", String(describing: error)) - await self.uploadFailed(key) + self.uploadFailed(key) } semaphore.signal() } @@ -294,10 +300,10 @@ extension RemoteDataServicesManager { try await remoteDataService.uploadCarbData(created: created, updated: updated, deleted: deleted) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .carb, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor - await self.uploadSucceeded(key) + self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing carb data: %{public}@", String(describing: error)) - await self.uploadFailed(key) + self.uploadFailed(key) } semaphore.signal() } @@ -338,10 +344,10 @@ extension RemoteDataServicesManager { try await remoteDataService.uploadDoseData(created: created, deleted: deleted) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .dose, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor - await self.uploadSucceeded(key) + self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing dose data: %{public}@", String(describing: error)) - await self.uploadFailed(key) + self.uploadFailed(key) } semaphore.signal() } @@ -382,11 +388,11 @@ extension RemoteDataServicesManager { try await remoteDataService.uploadDosingDecisionData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .dosingDecision, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor - await self.uploadSucceeded(key) + self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing dosing decision data: %{public}@", String(describing: error)) - await self.uploadFailed(key) + self.uploadFailed(key) } semaphore.signal() } @@ -427,10 +433,10 @@ extension RemoteDataServicesManager { try await remoteDataService.uploadGlucoseData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .glucose, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor - await self.uploadSucceeded(key) + self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing glucose data: %{public}@", String(describing: error)) - await self.uploadFailed(key) + self.uploadFailed(key) } semaphore.signal() } @@ -471,10 +477,10 @@ extension RemoteDataServicesManager { try await remoteDataService.uploadPumpEventData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .pumpEvent, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor - await self.uploadSucceeded(key) + self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) - await self.uploadFailed(key) + self.uploadFailed(key) } semaphore.signal() } @@ -515,10 +521,10 @@ extension RemoteDataServicesManager { try await remoteDataService.uploadSettingsData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .settings, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor - await self.uploadSucceeded(key) + self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing settings data: %{public}@", String(describing: error)) - await self.uploadFailed(key) + self.uploadFailed(key) } semaphore.signal() } @@ -552,10 +558,10 @@ extension RemoteDataServicesManager { do { try await remoteDataService.uploadTemporaryOverrideData(updated: overrides, deleted: deletedOverrides) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .overrides, newAnchor) - await self.uploadSucceeded(key) + self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing temporary override data: %{public}@", String(describing: error)) - await self.uploadFailed(key) + self.uploadFailed(key) } semaphore.signal() } @@ -588,10 +594,10 @@ extension RemoteDataServicesManager { try await remoteDataService.uploadCgmEventData(data) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .cgmEvent, queryAnchor) continueUpload = queryAnchor != previousQueryAnchor - await self.uploadSucceeded(key) + self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) - await self.uploadFailed(key) + self.uploadFailed(key) } semaphore.signal() } diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 0bf1125386..26c27f44a1 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -305,7 +305,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 { @@ -325,14 +325,14 @@ 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) NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) - await remoteDataServicesManager.triggerUpload(for: .carb) + await remoteDataServicesManager.performUpload(for: .carb) analyticsServicesManager.didAddCarbs(source: "Remote", amount: amountInGrams) } catch { NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) @@ -357,7 +357,7 @@ extension ServicesManager: ServiceDelegate { try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) - await remoteDataServicesManager.triggerUpload(for: .dose) + await remoteDataServicesManager.performUpload(for: .dose) analyticsServicesManager.didBolus(source: "Remote", units: amountInUnits) } catch { NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) From 578ad3d495e19f580a4815bfa4f7f53886c97647 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 10 Jul 2024 14:04:31 -0500 Subject: [PATCH 116/184] Only show pump events with doses (#682) --- .../InsulinDeliveryTableViewController.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Loop/View Controllers/InsulinDeliveryTableViewController.swift b/Loop/View Controllers/InsulinDeliveryTableViewController.swift index 495ff8ba85..0eb7e52916 100644 --- a/Loop/View Controllers/InsulinDeliveryTableViewController.swift +++ b/Loop/View Controllers/InsulinDeliveryTableViewController.swift @@ -253,7 +253,7 @@ public final class InsulinDeliveryTableViewController: UITableViewController { case .reservoir: self.values = .reservoir(try await doseStore.getReservoirValues(since: sinceDate, limit: nil)) case .history: - self.values = .history(try await doseStore.getPumpEventValues(since: sinceDate)) + self.values = .history(try await self.getPumpEvents(since: sinceDate)) case .manualEntryDose: self.values = .manualEntryDoses(try await doseStore.getManuallyEnteredDoses(since: sinceDate)) } @@ -266,6 +266,13 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } } + 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() } From af0c417d08add374be93edd75f34dc76521f6540 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 11 Jul 2024 07:54:04 -0700 Subject: [PATCH 117/184] [LOOP-4683] Add unit to IOB --- Loop/View Controllers/StatusTableViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 8044467a5a..73cd528a6d 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -557,7 +557,7 @@ final class StatusTableViewController: LoopChartsTableViewController { // Show the larger of the value either before or after the current date if let activeInsulin = loopManager.activeInsulin { - self.currentIOBDescription = insulinFormatter.string(from: activeInsulin.quantity, includeUnit: false) + self.currentIOBDescription = insulinFormatter.string(from: activeInsulin.quantity, includeUnit: true) } else { self.currentIOBDescription = nil } From e2898dee4bb99702e0ccdcee6365df2540f64673 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 11 Jul 2024 14:49:38 -0300 Subject: [PATCH 118/184] [LOOP-4877] need to keep track of bolusState during transitions (#683) --- .../StatusTableViewController.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 73cd528a6d..76ebeb7402 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -786,8 +786,14 @@ final class StatusTableViewController: LoopChartsTableViewController { if oldDose != newDose { tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } - case (.cancelingBolus, .cancelingBolus), (.cancelingBolus, .bolusing(_)), (.canceledBolus(_), .cancelingBolus), (.canceledBolus(_), .bolusing(_)): - // these updates cause flickering and/or confusion. + // these updates cause flickering and/or confusion. + case (.cancelingBolus, .cancelingBolus): + break + case (.cancelingBolus, .bolusing(_)): + break + case (.canceledBolus(_), .cancelingBolus): + break + case (.canceledBolus(_), .bolusing(_)): break default: tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) @@ -1082,6 +1088,7 @@ final class StatusTableViewController: LoopChartsTableViewController { 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 @@ -1234,6 +1241,7 @@ final class StatusTableViewController: LoopChartsTableViewController { show(vc, sender: tableView.cellForRow(at: indexPath)) } case .bolusing(var dose): + bolusState = .canceling updateBannerAndHUDandStatusRows(statusRowMode: .cancelingBolus, newSize: nil, animated: true) Task { try? await Task.sleep(nanoseconds: NSEC_PER_SEC) @@ -1243,6 +1251,8 @@ final class StatusTableViewController: LoopChartsTableViewController { DispatchQueue.main.async { switch result { case .success: + self.updateBannerAndHUDandStatusRows(statusRowMode: .canceledBolus(dose: dose), newSize: nil, animated: true) + self.bolusState = .noBolus Task { try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 10) self.canceledDose = nil From ae58355632df76d3a342e54f3ddc6f765b245edd Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 15 Jul 2024 19:36:09 -0500 Subject: [PATCH 119/184] Fix issue with target override application (#685) --- .../LoopDataManager+CarbAbsorption.swift | 30 +++++++------------ Loop/Managers/LoopDataManager.swift | 20 +++---------- 2 files changed, 14 insertions(+), 36 deletions(-) diff --git a/Loop/Managers/LoopDataManager+CarbAbsorption.swift b/Loop/Managers/LoopDataManager+CarbAbsorption.swift index d5ea04cba2..8f532a4e02 100644 --- a/Loop/Managers/LoopDataManager+CarbAbsorption.swift +++ b/Loop/Managers/LoopDataManager+CarbAbsorption.swift @@ -50,41 +50,31 @@ extension LoopDataManager { let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: sensitivityStart, endDate: end) - var overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: end) + let overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: end) guard !sensitivity.isEmpty else { throw LoopError.configurationError(.insulinSensitivitySchedule) } - let sensitivityWithOverrides = overrides.apply(over: sensitivity) { (quantity, override) in - let value = quantity.doubleValue(for: .milligramsPerDeciliter) - return HKQuantity( - unit: .milligramsPerDeciliter, - doubleValue: value / override.settings.effectiveInsulinNeedsScaleFactor - ) - } + let sensitivityWithOverrides = overrides.applySensitivity(over: sensitivity) guard !basal.isEmpty else { throw LoopError.configurationError(.basalRateSchedule) } - let basalWithOverrides = overrides.apply(over: basal) { (value, override) in - value * override.settings.effectiveInsulinNeedsScaleFactor - } + let basalWithOverrides = overrides.applyBasal(over: basal) guard !carbRatio.isEmpty else { throw LoopError.configurationError(.carbRatioSchedule) } - let carbRatioWithOverrides = overrides.apply(over: carbRatio) { (value, override) in - value * override.settings.effectiveInsulinNeedsScaleFactor - } + let carbRatioWithOverrides = overrides.applyCarbRatio(over: carbRatio) let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear // Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal - let annotatedDoses = doses.annotated(with: basal) + let annotatedDoses = doses.annotated(with: basalWithOverrides) let insulinEffects = annotatedDoses.glucoseEffects( - insulinSensitivityHistory: sensitivity, + insulinSensitivityHistory: sensitivityWithOverrides, from: start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval).dateFlooredToTimeInterval(GlucoseMath.defaultDelta), to: nil) @@ -94,15 +84,15 @@ extension LoopDataManager { // Carb Effects let carbStatus = carbEntries.map( to: insulinCounteractionEffects, - carbRatio: carbRatio, - insulinSensitivity: sensitivity + carbRatio: carbRatioWithOverrides, + insulinSensitivity: sensitivityWithOverrides ) let carbEffects = carbStatus.dynamicGlucoseEffects( from: end, to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), - carbRatios: carbRatio, - insulinSensitivities: sensitivity, + carbRatios: carbRatioWithOverrides, + insulinSensitivities: sensitivityWithOverrides, absorptionModel: carbModel.model ) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 330210ace7..c92c6eacfe 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -349,34 +349,22 @@ final class LoopDataManager: ObservableObject { throw LoopError.configurationError(.insulinSensitivitySchedule) } - let sensitivityWithOverrides = overrides.apply(over: sensitivity) { (quantity, override) in - let value = quantity.doubleValue(for: .milligramsPerDeciliter) - return HKQuantity( - unit: .milligramsPerDeciliter, - doubleValue: value / override.settings.effectiveInsulinNeedsScaleFactor - ) - } + let sensitivityWithOverrides = overrides.applySensitivity(over: sensitivity) guard !basal.isEmpty else { throw LoopError.configurationError(.basalRateSchedule) } - let basalWithOverrides = overrides.apply(over: basal) { (value, override) in - value * override.settings.effectiveInsulinNeedsScaleFactor - } + let basalWithOverrides = overrides.applyBasal(over: basal) guard !carbRatio.isEmpty else { throw LoopError.configurationError(.carbRatioSchedule) } - let carbRatioWithOverrides = overrides.apply(over: carbRatio) { (value, override) in - value * override.settings.effectiveInsulinNeedsScaleFactor - } + let carbRatioWithOverrides = overrides.applyCarbRatio(over: carbRatio) guard !target.isEmpty else { throw LoopError.configurationError(.glucoseTargetRangeSchedule) } - let targetWithOverrides = overrides.apply(over: target) { (range, override) in - override.settings.targetRange ?? range - } + let targetWithOverrides = overrides.applyTarget(over: target, at: baseTime) // Create dosing strategy based on user setting let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled From 8e6158f6eb23375d700f010f26d90791f55ba03d Mon Sep 17 00:00:00 2001 From: Noah Brauner <66573062+SwiftlyNoah@users.noreply.github.com> Date: Wed, 24 Jul 2024 13:52:39 -0400 Subject: [PATCH 120/184] [LOOP-4954, LOOP-4957] UI Enhancement for favorite foods in Carb Entry Screens (#686) * [LOOP-4954] Save favoriteFoodID in CoreData and HealthKit * [LOOP-4957 Update Food Type Row w/ Favorite Food --- .../Store Protocols/CarbStoreProtocol.swift | 8 +++++++- Loop/View Models/CarbEntryViewModel.swift | 13 ++++++++++++- Loop/Views/BolusEntryView.swift | 12 ------------ Loop/Views/CarbEntryView.swift | 3 ++- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index 0904631016..58655a542f 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -11,7 +11,7 @@ import HealthKit protocol CarbStoreProtocol: AnyObject { - func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] + func getCarbEntries(start: Date?, end: Date?, dateAscending: Bool, with favoriteFoodID: String?) async throws -> [StoredCarbEntry] func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry) async throws -> StoredCarbEntry @@ -21,4 +21,10 @@ protocol CarbStoreProtocol: AnyObject { } +extension CarbStoreProtocol { + func getCarbEntries(start: Date?, end: Date?, dateAscending: Bool = true, with favoriteFoodID: String? = nil) async throws -> [StoredCarbEntry] { + try await getCarbEntries(start: start, end: end, dateAscending: dateAscending, with: favoriteFoodID) + } +} + extension CarbStore: CarbStoreProtocol { } diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 53b1d3b1d0..bab5256c5c 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -87,6 +87,10 @@ final class CarbEntryViewModel: ObservableObject { @Published var favoriteFoods = UserDefaults.standard.favoriteFoods @Published var selectedFavoriteFoodIndex = -1 + var selectedFavoriteFood: StoredFavoriteFood? { + let foodExistsForIndex = 0.. some View { - content - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(Color(.systemGray6)) - ) - } -} diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index 97082a9b59..1307732972 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -110,7 +110,8 @@ 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) + let selectedFavoriteFoodBinding = Binding(get: { viewModel.selectedFavoriteFood }, set: { _ in }) + FoodTypeRow(selectedFavoriteFood: selectedFavoriteFoodBinding, foodType: $viewModel.foodType, absorptionTime: $viewModel.absorptionTime, selectedDefaultAbsorptionTimeEmoji: $viewModel.selectedDefaultAbsorptionTimeEmoji, usesCustomFoodType: $viewModel.usesCustomFoodType, absorptionTimeWasEdited: $viewModel.absorptionTimeWasEdited, isFocused: foodTypeFocused, defaultAbsorptionTimes: viewModel.defaultAbsorptionTimes) CardSectionDivider() From 030035b89192fef81d8b6fee7d9112a1e33a853b Mon Sep 17 00:00:00 2001 From: Noah Brauner <66573062+SwiftlyNoah@users.noreply.github.com> Date: Wed, 24 Jul 2024 17:46:47 -0400 Subject: [PATCH 121/184] Fix cyclical loop in tests (#688) --- Loop/Managers/Store Protocols/CarbStoreProtocol.swift | 4 ++-- LoopTests/Mock Stores/MockCarbStore.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index 58655a542f..afe64da736 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -22,8 +22,8 @@ protocol CarbStoreProtocol: AnyObject { } extension CarbStoreProtocol { - func getCarbEntries(start: Date?, end: Date?, dateAscending: Bool = true, with favoriteFoodID: String? = nil) async throws -> [StoredCarbEntry] { - try await getCarbEntries(start: start, end: end, dateAscending: dateAscending, with: favoriteFoodID) + func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] { + try await getCarbEntries(start: start, end: end, dateAscending: true, with: nil) } } diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 83ef9dc4d4..2c2155ce25 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -16,7 +16,7 @@ class MockCarbStore: CarbStoreProtocol { var carbHistory: [StoredCarbEntry] = [] - func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] { + func getCarbEntries(start: Date?, end: Date?, dateAscending: Bool, with favoriteFoodID: String?) async throws -> [StoredCarbEntry] { return carbHistory.filterDateRange(start, end) } From 909370cb95de3ff1c17d07c79ec66bbaad5ce6a7 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 24 Jul 2024 15:21:05 -0700 Subject: [PATCH 122/184] [LOOP-4884] Use LoopCircleView for LoopStateView / SwiftUI Interop --- .../StatusTableViewController.swift | 1 + LoopUI/Views/LoopCompletionHUDView.swift | 28 +-- LoopUI/Views/LoopStateView.swift | 167 +++++++++--------- 3 files changed, 89 insertions(+), 107 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 76ebeb7402..ed5a727d8c 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -982,6 +982,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case .hud: let cell = tableView.dequeueReusableCell(withIdentifier: HUDViewTableViewCell.className, for: indexPath) as! HUDViewTableViewCell hudView = cell.hudView + cell.hudView.loopCompletionHUD.loopStatusColors = .loopStatus return cell case .charts: diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index 4794fda543..ac2ec3b721 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -20,7 +20,7 @@ public final class LoopCompletionHUDView: BaseHUDView { private(set) var freshness = LoopCompletionFreshness.stale { didSet { - updateTintColor() + loopStateView.freshness = freshness } } @@ -30,6 +30,12 @@ public final class LoopCompletionHUDView: BaseHUDView { updateDisplay(nil) } + public var loopStatusColors: StateColorPalette = StateColorPalette(unknown: .black, normal: .black, warning: .black, error: .black) { + didSet { + loopStateView.loopStatusColors = loopStatusColors + } + } + public var loopIconClosed = false { didSet { loopStateView.open = !loopIconClosed @@ -65,26 +71,6 @@ public final class LoopCompletionHUDView: BaseHUDView { } } - override public func stateColorsDidUpdate() { - super.stateColorsDidUpdate() - updateTintColor() - } - - private var _tintColor: UIColor? { - switch freshness { - case .fresh: - return stateColors?.normal - case .aging: - return stateColors?.warning - case .stale: - return stateColors?.error - } - } - - private func updateTintColor() { - self.tintColor = _tintColor - } - private func initTimer(_ startDate: Date) { let updateInterval = TimeInterval(minutes: 1) diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index 95991048b7..7cea5c2c7d 100644 --- a/LoopUI/Views/LoopStateView.swift +++ b/LoopUI/Views/LoopStateView.swift @@ -6,113 +6,108 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // +import LoopKit +import LoopKitUI +import SwiftUI import UIKit -final class LoopStateView: UIView { - var firstDataUpdate = true +class WrappedLoopStateViewModel: ObservableObject { + @Published var loopStatusColors: StateColorPalette + @Published var closedLoop: Bool + @Published var freshness: LoopCompletionFreshness + @Published var animating: Bool - override func tintColorDidChange() { - super.tintColorDidChange() - - updateTintColor() - } - - private func updateTintColor() { - shapeLayer.strokeColor = tintColor.cgColor + init( + loopStatusColors: StateColorPalette = StateColorPalette(unknown: .black, normal: .black, warning: .black, error: .black), + closedLoop: Bool = true, + freshness: LoopCompletionFreshness = .stale, + animating: Bool = false + ) { + self.loopStatusColors = loopStatusColors + self.closedLoop = closedLoop + self.freshness = freshness + self.animating = animating } +} - var open = false { - didSet { - if open != oldValue { - if open, animated { - animated = false - } - shapeLayer.path = drawPath() - } - } +struct WrappedLoopCircleView: View { + + @ObservedObject var viewModel: WrappedLoopStateViewModel + + var body: some View { + LoopCircleView(closedLoop: $viewModel.closedLoop, freshness: $viewModel.freshness, animating: $viewModel.animating) + .environment(\.loopStatusColorPalette, viewModel.loopStatusColors) } +} - override class var layerClass : AnyClass { - return CAShapeLayer.self +class LoopCircleHostingController: UIHostingController { + init(viewModel: WrappedLoopStateViewModel) { + super.init( + rootView: WrappedLoopCircleView( + viewModel: viewModel + ) + ) } - - private var shapeLayer: CAShapeLayer { - return layer as! CAShapeLayer + + required init?(coder aDecoder: NSCoder) { + fatalError() } +} +final class LoopStateView: UIView { + override init(frame: CGRect) { super.init(frame: frame) - - shapeLayer.lineWidth = 8 - shapeLayer.fillColor = UIColor.clear.cgColor - updateTintColor() - - shapeLayer.path = drawPath() + + setupViews() } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - - shapeLayer.lineWidth = 8 - shapeLayer.fillColor = UIColor.clear.cgColor - updateTintColor() - - shapeLayer.path = drawPath() + + required init?(coder: NSCoder) { + super.init(coder: coder) + + setupViews() } - - override func layoutSubviews() { - super.layoutSubviews() - - shapeLayer.path = drawPath() + + var loopStatusColors: StateColorPalette = StateColorPalette(unknown: .black, normal: .black, warning: .black, error: .black) { + didSet { + viewModel.loopStatusColors = loopStatusColors + } } - private func drawPath(lineWidth: CGFloat? = nil) -> CGPath { - let center = CGPoint(x: bounds.midX, y: bounds.midY) - let lineWidth = lineWidth ?? shapeLayer.lineWidth - let radius = min(bounds.width / 2, bounds.height / 2) - lineWidth / 2 - - let startAngle = open ? -CGFloat.pi / 4 : 0 - let endAngle = open ? 5 * CGFloat.pi / 4 : 2 * CGFloat.pi - - let path = UIBezierPath( - arcCenter: center, - radius: radius, - startAngle: startAngle, - endAngle: endAngle, - clockwise: true - ) - - return path.cgPath + var freshness: LoopCompletionFreshness = .stale { + didSet { + viewModel.freshness = freshness + } + } + + var open = false { + didSet { + viewModel.closedLoop = !open + } } - - private static let AnimationKey = "com.loudnate.Naterade.breatheAnimation" var animated: Bool = false { didSet { - if animated != oldValue { - if animated, !open { - let path = CABasicAnimation(keyPath: "path") - path.fromValue = shapeLayer.path ?? drawPath() - path.toValue = drawPath(lineWidth: 16) - - let width = CABasicAnimation(keyPath: "lineWidth") - width.fromValue = shapeLayer.lineWidth - width.toValue = 10 - - let group = CAAnimationGroup() - group.animations = [path, width] - group.duration = firstDataUpdate ? 0 : 1 - group.repeatCount = HUGE - group.autoreverses = true - group.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - - shapeLayer.add(group, forKey: type(of: self).AnimationKey) - } else { - shapeLayer.removeAnimation(forKey: type(of: self).AnimationKey) - } - } - firstDataUpdate = false + viewModel.animating = animated } } + + private let viewModel = WrappedLoopStateViewModel() + + private func setupViews() { + let hostingController = LoopCircleHostingController(viewModel: viewModel) + + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + + addSubview(hostingController.view) + + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } } From c04b368562c6d1e8c65f67c7ac8f2e65009425ac Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 24 Jul 2024 21:29:55 -0700 Subject: [PATCH 123/184] [LOOP-4884] Use LoopCircleView for LoopStateView / SwiftUI Interop --- LoopUI/Views/LoopStateView.swift | 33 ++++++++------------------------ 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index 7cea5c2c7d..6d7d6091a7 100644 --- a/LoopUI/Views/LoopStateView.swift +++ b/LoopUI/Views/LoopStateView.swift @@ -30,30 +30,6 @@ class WrappedLoopStateViewModel: ObservableObject { } } -struct WrappedLoopCircleView: View { - - @ObservedObject var viewModel: WrappedLoopStateViewModel - - var body: some View { - LoopCircleView(closedLoop: $viewModel.closedLoop, freshness: $viewModel.freshness, animating: $viewModel.animating) - .environment(\.loopStatusColorPalette, viewModel.loopStatusColors) - } -} - -class LoopCircleHostingController: UIHostingController { - init(viewModel: WrappedLoopStateViewModel) { - super.init( - rootView: WrappedLoopCircleView( - viewModel: viewModel - ) - ) - } - - required init?(coder aDecoder: NSCoder) { - fatalError() - } -} - final class LoopStateView: UIView { override init(frame: CGRect) { @@ -95,7 +71,14 @@ final class LoopStateView: UIView { private let viewModel = WrappedLoopStateViewModel() private func setupViews() { - let hostingController = LoopCircleHostingController(viewModel: viewModel) + let hostingController = UIHostingController( + rootView: LoopCircleView( + closedLoop: viewModel.closedLoop, + freshness: viewModel.freshness, + animating: viewModel.animating + ) + .environment(\.loopStatusColorPalette, viewModel.loopStatusColors) + ) hostingController.view.backgroundColor = .clear hostingController.view.translatesAutoresizingMaskIntoConstraints = false From aaf56cc730f79532d6d9949261e0100a054ca010 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 24 Jul 2024 21:35:00 -0700 Subject: [PATCH 124/184] Merge branch 'dev' into cameron/LOOP-4884-pulsing-loop-status --- LoopUI/Views/LoopStateView.swift | 34 ++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index 6d7d6091a7..60fa4faf99 100644 --- a/LoopUI/Views/LoopStateView.swift +++ b/LoopUI/Views/LoopStateView.swift @@ -30,6 +30,31 @@ class WrappedLoopStateViewModel: ObservableObject { } } +struct WrappedLoopCircleView: View { + + @ObservedObject var viewModel: WrappedLoopStateViewModel + + var body: some View { + LoopCircleView(closedLoop: $viewModel.closedLoop, freshness: $viewModel.freshness, animating: $viewModel.animating) + .environment(\.loopStatusColorPalette, viewModel.loopStatusColors) + } +} + +class LoopCircleHostingController: UIHostingController { + init(viewModel: WrappedLoopStateViewModel) { + super.init( + rootView: WrappedLoopCircleView( + viewModel: viewModel + ) + ) + } + + required init?(coder aDecoder: NSCoder) { + fatalError() + } +} + + final class LoopStateView: UIView { override init(frame: CGRect) { @@ -71,14 +96,7 @@ final class LoopStateView: UIView { private let viewModel = WrappedLoopStateViewModel() private func setupViews() { - let hostingController = UIHostingController( - rootView: LoopCircleView( - closedLoop: viewModel.closedLoop, - freshness: viewModel.freshness, - animating: viewModel.animating - ) - .environment(\.loopStatusColorPalette, viewModel.loopStatusColors) - ) + let hostingController = LoopCircleHostingController(viewModel: viewModel) hostingController.view.backgroundColor = .clear hostingController.view.translatesAutoresizingMaskIntoConstraints = false From a601a2f31a27ed24bc215065ba5d56a550384d82 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 25 Jul 2024 09:30:42 -0700 Subject: [PATCH 125/184] [LOOP-4884] Use LoopCircleView for LoopStateView / SwiftUI Interop --- LoopUI/Views/LoopStateView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index 60fa4faf99..508d9a53b8 100644 --- a/LoopUI/Views/LoopStateView.swift +++ b/LoopUI/Views/LoopStateView.swift @@ -35,7 +35,7 @@ struct WrappedLoopCircleView: View { @ObservedObject var viewModel: WrappedLoopStateViewModel var body: some View { - LoopCircleView(closedLoop: $viewModel.closedLoop, freshness: $viewModel.freshness, animating: $viewModel.animating) + LoopCircleView(closedLoop: viewModel.closedLoop, freshness: viewModel.freshness, animating: viewModel.animating) .environment(\.loopStatusColorPalette, viewModel.loopStatusColors) } } From d7149e154d6610748a6c4868b90286196b7b948c Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 30 Jul 2024 14:35:41 -0300 Subject: [PATCH 126/184] [PAL-694] issue report includes 100 most recent alerts (#690) * issue report includes 100 most recent alerts * added default value to keep unit tests the same --- Loop/Managers/Alerts/AlertManager.swift | 2 +- Loop/Managers/Alerts/AlertStore.swift | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index ca0142193d..e30acf9d69 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -507,7 +507,7 @@ extension AlertManager { await withCheckedContinuation { continuation in let startDate = Date() - .days(3.5) // Report the last 3 and half days of alerts let header = "## Alerts\n" - alertStore.executeQuery(since: startDate, limit: 100) { result in + alertStore.executeQuery(since: startDate, limit: 100, ascending: false) { result in switch result { case .failure: continuation.resume(returning: header) diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift index d8d6db7e5c..cc2e7837eb 100644 --- a/Loop/Managers/Alerts/AlertStore.swift +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -422,15 +422,15 @@ extension AlertStore { case failure(Error) } - func executeQuery(fromQueryAnchor queryAnchor: QueryAnchor? = nil, since date: Date, excludingFutureAlerts: Bool = true, now: Date = Date(), limit: Int, completion: @escaping (AlertQueryResult) -> Void) { + func executeQuery(fromQueryAnchor queryAnchor: QueryAnchor? = nil, since date: Date, excludingFutureAlerts: Bool = true, now: Date = Date(), limit: Int, ascending: Bool = true, completion: @escaping (AlertQueryResult) -> Void) { let sinceDateFilter = SinceDateFilter(predicateExpressionNotYetExpired: predicateExpressionNotYetExpired, date: date, excludingFutureAlerts: excludingFutureAlerts, now: now) - executeAlertQuery(fromQueryAnchor: queryAnchor, queryFilter: sinceDateFilter, limit: limit, completion: completion) + executeAlertQuery(fromQueryAnchor: queryAnchor, queryFilter: sinceDateFilter, limit: limit, ascending: ascending, completion: completion) } - func executeAlertQuery(fromQueryAnchor queryAnchor: QueryAnchor?, queryFilter: QueryFilter? = nil, limit: Int, completion: @escaping (AlertQueryResult) -> Void) { + func executeAlertQuery(fromQueryAnchor queryAnchor: QueryAnchor?, queryFilter: QueryFilter? = nil, limit: Int, ascending: Bool = true, completion: @escaping (AlertQueryResult) -> Void) { var queryAnchor = queryAnchor ?? QueryAnchor() var queryResult = [SyncAlertObject]() var queryError: Error? @@ -449,7 +449,7 @@ extension AlertStore { } else { storedRequest.predicate = queryAnchorPredicate } - storedRequest.sortDescriptors = [NSSortDescriptor(key: "modificationCounter", ascending: true)] + storedRequest.sortDescriptors = [NSSortDescriptor(key: "modificationCounter", ascending: ascending)] storedRequest.fetchLimit = limit do { From 7ef44d13dd0ca68259d09e6ef0d90efb9cb43e3a Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 30 Jul 2024 11:59:28 -0700 Subject: [PATCH 127/184] Merge branch 'dev' into cameron/LOOP-4793 --- Loop/Managers/DeviceDataManager.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 7f96c906d9..34c1036ef0 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -938,11 +938,10 @@ extension DeviceDataManager: CGMManagerDelegate { // MARK: - CGMManagerOnboardingDelegate extension DeviceDataManager: CGMManagerOnboardingDelegate { + @MainActor func cgmManagerOnboarding(didCreateCGMManager cgmManager: CGMManagerUI) { - Task { @MainActor in - log.default("CGM manager with identifier '%{public}@' created", cgmManager.pluginIdentifier) - self.cgmManager = cgmManager - } + log.default("CGM manager with identifier '%{public}@' created", cgmManager.pluginIdentifier) + self.cgmManager = cgmManager } func cgmManagerOnboarding(didOnboardCGMManager cgmManager: CGMManagerUI) { From 22ebe68fe4ab3bd80e0143ea3a3b3e503a3a9e53 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 30 Jul 2024 12:39:01 -0700 Subject: [PATCH 128/184] Merge branch 'dev' into cameron/LOOP-4793 --- Loop.xcodeproj/project.pbxproj | 2 +- Loop/Managers/DeviceDataManager.swift | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 2aeda03963..febccde068 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -1171,6 +1171,7 @@ 7DD382791F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Interface.strings; sourceTree = ""; }; 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetLoopManager.swift; sourceTree = ""; }; 80F864E52433BF5D0026EC26 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; + 840A2F0D2C0F978E003D5E90 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 840B7A7E2B7BFF58000ED932 /* LoopUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoopUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 847434C72B7C17800084BE98 /* LoopUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopUITests.swift; sourceTree = ""; }; 847434DC2B7C34F70084BE98 /* LoopUITestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = LoopUITestPlan.xctestplan; sourceTree = ""; }; @@ -1178,7 +1179,6 @@ 847434F52B7C41D30084BE98 /* DIYLoopUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DIYLoopUITests.swift; sourceTree = ""; }; 847435022B7C42300084BE98 /* DIYLoopUITestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUITestPlan.xctestplan; sourceTree = ""; }; 847435042B7C4F8D0084BE98 /* OnboardingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreen.swift; sourceTree = ""; }; - 840A2F0D2C0F978E003D5E90 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index ff86ea79e5..3011623458 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -915,7 +915,6 @@ extension DeviceDataManager: CGMManagerDelegate { // MARK: - CGMManagerOnboardingDelegate extension DeviceDataManager: CGMManagerOnboardingDelegate { - @MainActor func cgmManagerOnboarding(didCreateCGMManager cgmManager: CGMManagerUI) { log.default("CGM manager with identifier '%{public}@' created", cgmManager.pluginIdentifier) self.cgmManager = cgmManager From 5a7a7ffb9207880ddc48744c648abcf2af57127d Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 30 Jul 2024 13:09:07 -0700 Subject: [PATCH 129/184] Merge branch 'dev' into cameron/LOOP-4793 --- DIYLoopUITests/DIYLoopUITests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DIYLoopUITests/DIYLoopUITests.swift b/DIYLoopUITests/DIYLoopUITests.swift index e2e2cb328b..d25e9c293d 100644 --- a/DIYLoopUITests/DIYLoopUITests.swift +++ b/DIYLoopUITests/DIYLoopUITests.swift @@ -26,7 +26,7 @@ final class DIYLoopUITests: XCTestCase { baseScreen = BaseScreen(app: app) homeScreen = HomeScreen(app: app) settingsScreen = SettingsScreen(app: app) - systemSettingsScreen = SystemSettingsScreen(app: app) + systemSettingsScreen = SystemSettingsScreen() pumpSimulatorScreen = PumpSimulatorScreen(app: app) onboardingScreen = OnboardingScreen(app: app) } From 24156a2df6f1f07205c99290be3ca303128ed326 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 30 Jul 2024 14:48:58 -0700 Subject: [PATCH 130/184] Merge branch 'dev' into cameron/LOOP-4793 --- DIYLoopUITests/DIYLoopUITests.swift | 9 +++++++-- DIYLoopUITests/Screens/OnboardingScreen.swift | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/DIYLoopUITests/DIYLoopUITests.swift b/DIYLoopUITests/DIYLoopUITests.swift index d25e9c293d..8316ccd445 100644 --- a/DIYLoopUITests/DIYLoopUITests.swift +++ b/DIYLoopUITests/DIYLoopUITests.swift @@ -32,8 +32,13 @@ final class DIYLoopUITests: XCTestCase { } func testSkippingOnboarding() async throws { - baseScreen.deleteApp() - app.launch() onboardingScreen.skipAllOfOnboarding() + homeScreen.openSettings() + settingsScreen.openPumpManager() + waitForExistence(settingsScreen.pumpSimulatorButton) + settingsScreen.pumpSimulatorButton.tap() + settingsScreen.openCGMManager() + waitForExistence(settingsScreen.cgmSimulatorButton) + settingsScreen.cgmSimulatorButton.tap() } } diff --git a/DIYLoopUITests/Screens/OnboardingScreen.swift b/DIYLoopUITests/Screens/OnboardingScreen.swift index 970cbd7b91..191e611a72 100644 --- a/DIYLoopUITests/Screens/OnboardingScreen.swift +++ b/DIYLoopUITests/Screens/OnboardingScreen.swift @@ -54,7 +54,9 @@ class OnboardingScreen: BaseScreen { private func allowSiri() { waitForExistence(alertAllowButton) - alertAllowButton.tap() + if alertAllowButton.exists { + alertAllowButton.tap() + } } private func skipOnboarding() { From 26c798fb781cbf0625a49beb06823f1dcc1caebc Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Wed, 31 Jul 2024 10:26:59 -0400 Subject: [PATCH 131/184] LoopChartView + refactored PredictedGlucoseChartView --- Loop.xcodeproj/project.pbxproj | 14 ++++- .../LoopChartView.swift} | 59 +++++++------------ .../Charts/PredictedGlucoseChartView.swift | 37 ++++++++++++ 3 files changed, 72 insertions(+), 38 deletions(-) rename Loop/Views/{PredictedGlucoseChartView.swift => Charts/LoopChartView.swift} (55%) create mode 100644 Loop/Views/Charts/PredictedGlucoseChartView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index b53f183237..fb059fb206 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 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 */; }; 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */; }; 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */; }; 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219C2469F1F5000EBBDE /* AlertStore.swift */; }; @@ -742,6 +743,7 @@ 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 = ""; }; + 14C970672C5991CD00E8A01B /* LoopChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopChartView.swift; sourceTree = ""; }; 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsViewModel.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 = ""; }; @@ -1804,6 +1806,15 @@ path = "Loop Widget Extension"; sourceTree = ""; }; + 14C970662C59918100E8A01B /* Charts */ = { + isa = PBXGroup; + children = ( + 14C970672C5991CD00E8A01B /* LoopChartView.swift */, + 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */, + ); + path = Charts; + sourceTree = ""; + }; 1DA6499D2441266400F61E75 /* Alerts */ = { isa = PBXGroup; children = ( @@ -2213,6 +2224,7 @@ C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */, 43B260481ED248FB008CAA77 /* CarbEntryTableViewCell.swift */, 149A28BC2A853E6C00052EDF /* CarbEntryView.swift */, + 14C970662C59918100E8A01B /* Charts */, 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */, A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */, C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */, @@ -2227,7 +2239,6 @@ 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */, 899433B723FE129700FA4BEA /* OverrideBadgeView.swift */, 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */, - 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */, 438D42FA1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift */, 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, @@ -3574,6 +3585,7 @@ 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 */, diff --git a/Loop/Views/PredictedGlucoseChartView.swift b/Loop/Views/Charts/LoopChartView.swift similarity index 55% rename from Loop/Views/PredictedGlucoseChartView.swift rename to Loop/Views/Charts/LoopChartView.swift index d8a0041fb8..be6965c9b5 100644 --- a/Loop/Views/PredictedGlucoseChartView.swift +++ b/Loop/Views/Charts/LoopChartView.swift @@ -1,83 +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 -import LoopAlgorithm - -struct PredictedGlucoseChartView: UIViewRepresentable { +struct LoopChartView: UIViewRepresentable { let chartManager: ChartsManager - var glucoseUnit: HKUnit - var glucoseValues: [GlucoseValue] - var predictedGlucoseValues: [GlucoseValue] - var targetGlucoseSchedule: GlucoseRangeSchedule? - var preMealOverride: TemporaryScheduleOverride? - var scheduleOverride: TemporaryScheduleOverride? - var dateInterval: DateInterval - + let dateInterval: DateInterval @Binding var isInteractingWithChart: Bool + var configuration = { (view: Chart) in } func makeUIView(context: Context) -> ChartContainerView { + guard let chartIndex = chartManager.charts.firstIndex(where: { $0 is Chart }) else { + fatalError("Expected exactly one matching chart in ChartsManager") + } + let view = ChartContainerView() view.chartGenerator = { [chartManager] frame in - chartManager.chart(atIndex: 0, frame: frame)?.view + chartManager.chart(atIndex: chartIndex, frame: frame)?.view } let gestureRecognizer = UILongPressGestureRecognizer() gestureRecognizer.minimumPressDuration = 0.1 gestureRecognizer.addTarget(context.coordinator, action: #selector(Coordinator.handlePan(_:))) - chartManager.gestureRecognizer = gestureRecognizer view.addGestureRecognizer(gestureRecognizer) return view } func updateUIView(_ chartContainerView: ChartContainerView, context: Context) { - chartManager.invalidateChart(atIndex: 0) + guard let chartIndex = chartManager.charts.firstIndex(where: { $0 is Chart }), + let chart = chartManager.charts[chartIndex] as? Chart else { + fatalError("Expected exactly one matching chart in ChartsManager") + } + + chartManager.invalidateChart(atIndex: chartIndex) chartManager.startDate = dateInterval.start chartManager.maxEndDate = dateInterval.end chartManager.updateEndDate(dateInterval.end) - predictedGlucoseChart.glucoseUnit = glucoseUnit - predictedGlucoseChart.targetGlucoseSchedule = targetGlucoseSchedule - predictedGlucoseChart.preMealOverride = preMealOverride - predictedGlucoseChart.scheduleOverride = scheduleOverride - predictedGlucoseChart.setGlucoseValues(glucoseValues) - predictedGlucoseChart.setPredictedGlucoseValues(predictedGlucoseValues) + configuration(chart) chartManager.prerender() chartContainerView.reloadChart() } - var predictedGlucoseChart: PredictedGlucoseChart { - guard chartManager.charts.count == 1, let predictedGlucoseChart = chartManager.charts.first as? PredictedGlucoseChart else { - fatalError("Expected exactly one predicted glucose chart in ChartsManager") - } - - return predictedGlucoseChart - } - func makeCoordinator() -> Coordinator { Coordinator(self) } final class Coordinator { - var parent: PredictedGlucoseChartView + var parent: LoopChartView - init(_ parent: PredictedGlucoseChartView) { + init(_ parent: LoopChartView) { self.parent = parent } - + @objc func handlePan(_ recognizer: UIPanGestureRecognizer) { switch recognizer.state { case .began: + parent.chartManager.gestureRecognizer = recognizer withAnimation(.easeInOut(duration: 0.2)) { parent.isInteractingWithChart = true } diff --git a/Loop/Views/Charts/PredictedGlucoseChartView.swift b/Loop/Views/Charts/PredictedGlucoseChartView.swift new file mode 100644 index 0000000000..2d6725cbed --- /dev/null +++ b/Loop/Views/Charts/PredictedGlucoseChartView.swift @@ -0,0 +1,37 @@ +// +// PredictedGlucoseChartView.swift +// Loop +// +// Created by Michael Pangburn on 7/22/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import HealthKit +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct PredictedGlucoseChartView: View { + let chartManager: ChartsManager + var glucoseUnit: HKUnit + var glucoseValues: [GlucoseValue] + var predictedGlucoseValues: [GlucoseValue] = [] + var targetGlucoseSchedule: GlucoseRangeSchedule? = nil + var preMealOverride: TemporaryScheduleOverride? = nil + var scheduleOverride: TemporaryScheduleOverride? = nil + var dateInterval: DateInterval + + @Binding var isInteractingWithChart: Bool + + var body: some View { + LoopChartView(chartManager: chartManager, dateInterval: dateInterval, isInteractingWithChart: $isInteractingWithChart) { predictedGlucoseChart in + predictedGlucoseChart.glucoseUnit = glucoseUnit + predictedGlucoseChart.targetGlucoseSchedule = targetGlucoseSchedule + predictedGlucoseChart.preMealOverride = preMealOverride + predictedGlucoseChart.scheduleOverride = scheduleOverride + predictedGlucoseChart.setGlucoseValues(glucoseValues) + predictedGlucoseChart.setPredictedGlucoseValues(predictedGlucoseValues) + } + } +} From 923a5eb808330b70e61444c434f1b12fe63a13a2 Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Wed, 31 Jul 2024 10:38:52 -0400 Subject: [PATCH 132/184] [LOOP-4956] SwiftUI views for rest of loop charts --- Loop.xcodeproj/project.pbxproj | 12 ++++++++ Loop/Views/Charts/CarbEffectChartView.swift | 32 +++++++++++++++++++++ Loop/Views/Charts/DoseChartView.swift | 26 +++++++++++++++++ Loop/Views/Charts/IOBChartView.swift | 26 +++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 Loop/Views/Charts/CarbEffectChartView.swift create mode 100644 Loop/Views/Charts/DoseChartView.swift create mode 100644 Loop/Views/Charts/IOBChartView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index fb059fb206..db60ba2e60 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -41,6 +41,9 @@ 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 */; }; 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */; }; 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */; }; 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219C2469F1F5000EBBDE /* AlertStore.swift */; }; @@ -744,6 +747,9 @@ 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 = ""; }; 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 = ""; }; 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsViewModel.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 = ""; }; @@ -1809,6 +1815,9 @@ 14C970662C59918100E8A01B /* Charts */ = { isa = PBXGroup; children = ( + 14C970692C5A833100E8A01B /* CarbEffectChartView.swift */, + 14C9706B2C5A836000E8A01B /* DoseChartView.swift */, + 14C9706D2C5A83AF00E8A01B /* IOBChartView.swift */, 14C970672C5991CD00E8A01B /* LoopChartView.swift */, 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */, ); @@ -3531,6 +3540,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 */, @@ -3647,6 +3657,7 @@ 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */, 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.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 */, @@ -3697,6 +3708,7 @@ 892A5D59222F0A27008961AB /* Debug.swift in Sources */, 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */, 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, + 14C9706A2C5A833100E8A01B /* CarbEffectChartView.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */, DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */, diff --git a/Loop/Views/Charts/CarbEffectChartView.swift b/Loop/Views/Charts/CarbEffectChartView.swift new file mode 100644 index 0000000000..8a6f4eda1f --- /dev/null +++ b/Loop/Views/Charts/CarbEffectChartView.swift @@ -0,0 +1,32 @@ +// +// CarbEffectChartView.swift +// Loop +// +// Created by Noah Brauner on 7/25/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import HealthKit +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct CarbEffectChartView: View { + let chartManager: ChartsManager + var glucoseUnit: HKUnit + var carbAbsorptionReview: CarbAbsorptionReview? + var dateInterval: DateInterval + + @Binding var isInteractingWithChart: Bool + + var body: some View { + LoopChartView(chartManager: chartManager, dateInterval: dateInterval, isInteractingWithChart: $isInteractingWithChart) { carbEffectChart in + carbEffectChart.glucoseUnit = glucoseUnit + if let carbAbsorptionReview { + carbEffectChart.setCarbEffects(carbAbsorptionReview.carbEffects.filterDateRange(dateInterval.start, dateInterval.end)) + carbEffectChart.setInsulinCounteractionEffects(carbAbsorptionReview.effectsVelocities.filterDateRange(dateInterval.start, dateInterval.end)) + } + } + } +} diff --git a/Loop/Views/Charts/DoseChartView.swift b/Loop/Views/Charts/DoseChartView.swift new file mode 100644 index 0000000000..44f4de087e --- /dev/null +++ b/Loop/Views/Charts/DoseChartView.swift @@ -0,0 +1,26 @@ +// +// DoseChartView.swift +// Loop +// +// Created by Noah Brauner on 7/22/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct DoseChartView: View { + let chartManager: ChartsManager + var doses: [BasalRelativeDose] + var dateInterval: DateInterval + + @Binding var isInteractingWithChart: Bool + + var body: some View { + LoopChartView(chartManager: chartManager, dateInterval: dateInterval, isInteractingWithChart: $isInteractingWithChart) { doseChart in + doseChart.doseEntries = doses + } + } +} diff --git a/Loop/Views/Charts/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) + } + } +} From a23baa5d92564986f9c5125ca6af5684c9f75cd5 Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Wed, 31 Jul 2024 13:02:56 -0400 Subject: [PATCH 133/184] [LOOP-4956] SwiftUI view for GlucoseCarbChart --- Loop.xcodeproj/project.pbxproj | 4 +++ Loop/Views/Charts/GlucoseCarbChartView.swift | 33 ++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 Loop/Views/Charts/GlucoseCarbChartView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index db60ba2e60..7acd190610 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -44,6 +44,7 @@ 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 */; }; 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */; }; 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */; }; 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219C2469F1F5000EBBDE /* AlertStore.swift */; }; @@ -750,6 +751,7 @@ 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 = ""; }; 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsViewModel.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 = ""; }; @@ -1817,6 +1819,7 @@ children = ( 14C970692C5A833100E8A01B /* CarbEffectChartView.swift */, 14C9706B2C5A836000E8A01B /* DoseChartView.swift */, + 14C9707D2C5A9EB600E8A01B /* GlucoseCarbChartView.swift */, 14C9706D2C5A83AF00E8A01B /* IOBChartView.swift */, 14C970672C5991CD00E8A01B /* LoopChartView.swift */, 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */, @@ -3656,6 +3659,7 @@ 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */, 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */, 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */, + 14C9707E2C5A9EB600E8A01B /* GlucoseCarbChartView.swift in Sources */, 43E93FB61E469A4000EAB8DB /* NumberFormatter.swift in Sources */, 14C9706C2C5A836000E8A01B /* DoseChartView.swift in Sources */, C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */, diff --git a/Loop/Views/Charts/GlucoseCarbChartView.swift b/Loop/Views/Charts/GlucoseCarbChartView.swift new file mode 100644 index 0000000000..7b6ed91b37 --- /dev/null +++ b/Loop/Views/Charts/GlucoseCarbChartView.swift @@ -0,0 +1,33 @@ +// +// GlucoseCarbChartView.swift +// Loop +// +// Created by Noah Brauner on 7/29/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import HealthKit +import LoopKit +import LoopKitUI +import LoopAlgorithm + +struct GlucoseCarbChartView: View { + let chartManager: ChartsManager + var glucoseUnit: HKUnit + var glucoseValues: [GlucoseValue] + var carbEntries: [StoredCarbEntry] + var dateInterval: DateInterval + + @Binding var isInteractingWithChart: Bool + + var body: some View { + LoopChartView(chartManager: chartManager, dateInterval: dateInterval, isInteractingWithChart: $isInteractingWithChart) { glucoseCarbChart in + glucoseCarbChart.glucoseUnit = glucoseUnit + glucoseCarbChart.setGlucoseValues(glucoseValues) + glucoseCarbChart.carbEntries = carbEntries + glucoseCarbChart.carbEntryImage = UIImage(named: "carbs") + glucoseCarbChart.carbEntryFavoriteFoodImage = UIImage(named: "Favorite Foods Icon") + } + } +} From 3db82da2b5c9ecb83e60c6245bac207eddf7073d Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 31 Jul 2024 10:24:58 -0700 Subject: [PATCH 134/184] Merge branch 'dev' into cameron/LOOP-4793 --- DIYLoopUITests/DIYLoopUITestPlan.xctestplan | 1 + 1 file changed, 1 insertion(+) diff --git a/DIYLoopUITests/DIYLoopUITestPlan.xctestplan b/DIYLoopUITests/DIYLoopUITestPlan.xctestplan index 499bdd7419..1e2fd4403d 100644 --- a/DIYLoopUITests/DIYLoopUITestPlan.xctestplan +++ b/DIYLoopUITests/DIYLoopUITestPlan.xctestplan @@ -40,6 +40,7 @@ } }, { + "enabled" : false, "target" : { "containerPath" : "container:..\/Loop\/Loop.xcodeproj", "identifier" : "840B7A7D2B7BFF58000ED932", From 75006ce23ec20cd5d93278e0a3a322808f0724d5 Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Thu, 1 Aug 2024 13:03:04 -0400 Subject: [PATCH 135/184] [LOOP-4978] Favorite Food Insights card in CarbEntryView --- Loop/Managers/LoopDataManager.swift | 4 ++ .../Store Protocols/CarbStoreProtocol.swift | 4 +- Loop/View Models/CarbEntryViewModel.swift | 28 +++++++- Loop/Views/CarbEntryView.swift | 68 ++++++++++++++++++- LoopTests/Mock Stores/MockCarbStore.swift | 2 +- 5 files changed, 101 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index c92c6eacfe..a4f2602aa8 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1127,6 +1127,10 @@ extension LoopDataManager: CarbEntryViewModelDelegate { var defaultAbsorptionTimes: DefaultAbsorptionTimes { LoopCoreConstants.defaultCarbAbsorptionTimes } + + 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 getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] { try await glucoseStore.getGlucoseSamples(start: start, end: end) diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index afe64da736..a565cb703f 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -11,7 +11,7 @@ import HealthKit protocol CarbStoreProtocol: AnyObject { - func getCarbEntries(start: Date?, end: Date?, dateAscending: Bool, with favoriteFoodID: String?) async throws -> [StoredCarbEntry] + 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 @@ -23,7 +23,7 @@ protocol CarbStoreProtocol: AnyObject { extension CarbStoreProtocol { func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] { - try await getCarbEntries(start: start, end: end, dateAscending: true, with: nil) + try await getCarbEntries(start: start, end: end, dateAscending: true, fetchLimit: nil, with: nil) } } diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index bab5256c5c..24eed5da22 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -16,6 +16,7 @@ import LoopAlgorithm protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate { var defaultAbsorptionTimes: DefaultAbsorptionTimes { get } func scheduleOverrideEnabled(at date: Date) -> Bool + func selectedFavoriteFoodLastEaten(_ favoriteFood: StoredFavoriteFood) async throws -> Date? func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] } @@ -86,11 +87,22 @@ 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.. 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 + } +} + // MARK: - Other UI Elements extension CarbEntryView { private var dismissButton: some View { diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 2c2155ce25..df0f7d8b21 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -16,7 +16,7 @@ class MockCarbStore: CarbStoreProtocol { var carbHistory: [StoredCarbEntry] = [] - func getCarbEntries(start: Date?, end: Date?, dateAscending: Bool, with favoriteFoodID: String?) async throws -> [StoredCarbEntry] { + func getCarbEntries(start: Date?, end: Date?, dateAscending: Bool, fetchLimit: Int?, with favoriteFoodID: String?) async throws -> [StoredCarbEntry] { return carbHistory.filterDateRange(start, end) } From 8d7652a09b21d8eaa853d60d44a05ae03fdbbde3 Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Thu, 1 Aug 2024 21:51:22 -0400 Subject: [PATCH 136/184] [LOOP-4953] Favorite Foods Insights Page --- Loop.xcodeproj/project.pbxproj | 16 ++ Loop/Managers/LoopDataManager.swift | 101 ++++++++++- Loop/View Models/CarbEntryViewModel.swift | 9 +- .../FavoriteFoodInsightsViewModel.swift | 166 ++++++++++++++++++ Loop/Views/CarbEntryView.swift | 9 +- .../FavoriteFoodInsightsChartsView.swift | 139 +++++++++++++++ Loop/Views/FavoriteFoodInsightsView.swift | 144 +++++++++++++++ Loop/Views/HowCarbEffectsWorksView.swift | 33 ++++ 8 files changed, 608 insertions(+), 9 deletions(-) create mode 100644 Loop/View Models/FavoriteFoodInsightsViewModel.swift create mode 100644 Loop/Views/FavoriteFoodInsightsChartsView.swift create mode 100644 Loop/Views/FavoriteFoodInsightsView.swift create mode 100644 Loop/Views/HowCarbEffectsWorksView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 7acd190610..bf4a931af3 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -45,6 +45,10 @@ 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 */; }; 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */; }; 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219C2469F1F5000EBBDE /* AlertStore.swift */; }; @@ -752,6 +756,10 @@ 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 = ""; }; 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredAlert.swift; sourceTree = ""; }; 1D05219C2469F1F5000EBBDE /* AlertStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStore.swift; sourceTree = ""; }; @@ -2241,9 +2249,12 @@ A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */, C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */, 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */, + 14C970852C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift */, + 14C970812C5C2EC100E8A01B /* FavoriteFoodInsightsView.swift */, 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */, 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */, 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */, + 14C970832C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift */, B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */, 430D85881F44037000AF2D4F /* HUDViewTableViewCell.swift */, A91D2A3E26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift */, @@ -2608,6 +2619,7 @@ 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */, 149A28BA2A853E5100052EDF /* CarbEntryViewModel.swift */, A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */, + 14C9707F2C5C0A1500E8A01B /* FavoriteFoodInsightsViewModel.swift */, 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */, C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */, 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */, @@ -3561,9 +3573,11 @@ C1F7822627CC056900C0919A /* SettingsManager.swift in Sources */, 1452F4AD2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift in Sources */, C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */, + 14C970822C5C2EC100E8A01B /* FavoriteFoodInsightsView.swift in Sources */, 1D080CBD2473214A00356610 /* AlertStore.xcdatamodeld in Sources */, C11BD0552523CFED00236B08 /* SimpleBolusViewModel.swift in Sources */, C1F2CAAA2B76B3EE00D7F581 /* TempBasalRecommendation.swift in Sources */, + 14C970802C5C0A1500E8A01B /* FavoriteFoodInsightsViewModel.swift in Sources */, 149A28BB2A853E5100052EDF /* CarbEntryViewModel.swift in Sources */, C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, @@ -3664,6 +3678,7 @@ 14C9706C2C5A836000E8A01B /* DoseChartView.swift in Sources */, C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */, B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */, + 14C970842C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift in Sources */, 84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */, 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */, @@ -3732,6 +3747,7 @@ 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */, B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */, 43C2FAE11EB656A500364AFF /* GlucoseEffectVelocity.swift in Sources */, + 14C970862C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index a4f2602aa8..0e575eab23 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1127,13 +1127,108 @@ extension LoopDataManager: CarbEntryViewModelDelegate { 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 getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] { - try await glucoseStore.getGlucoseSamples(start: start, end: end) + + func getFavoriteFoodCarbEntries(_ favoriteFood: StoredFavoriteFood) async throws -> [LoopKit.StoredCarbEntry] { + try await carbStore.getCarbEntries(start: nil, end: nil, dateAscending: false, fetchLimit: nil, with: favoriteFood.id) + } + + func getHistoricalChartsData(start: Date, end: Date) async throws -> HistoricalChartsData { + // Need to get insulin data from any active doses that might affect this time range + var dosesStart = start.addingTimeInterval(-InsulinMath.defaultInsulinActivityDuration) + let doses = try await doseStore.getNormalizedDoseEntries( + start: dosesStart, + end: end + ).map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) } + + dosesStart = doses.map { $0.startDate }.min() ?? dosesStart + + let basal = try await settingsProvider.getBasalHistory(startDate: dosesStart, endDate: end) + + let carbEntries = try await carbStore.getCarbEntries(start: start, end: end) + + let carbRatio = try await settingsProvider.getCarbRatioHistory(startDate: start, endDate: end) + + let glucose = try await glucoseStore.getGlucoseSamples(start: start, end: end) + + let sensitivityStart = min(start, dosesStart) + + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: sensitivityStart, endDate: end) + + let overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: end) + + guard !sensitivity.isEmpty else { + throw LoopError.configurationError(.insulinSensitivitySchedule) + } + + let sensitivityWithOverrides = overrides.applySensitivity(over: sensitivity) + + guard !basal.isEmpty else { + throw LoopError.configurationError(.basalRateSchedule) + } + let basalWithOverrides = overrides.applyBasal(over: basal) + + guard !carbRatio.isEmpty else { + throw LoopError.configurationError(.carbRatioSchedule) + } + let carbRatioWithOverrides = overrides.applyCarbRatio(over: carbRatio) + + let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + + // Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal + let annotatedDoses = doses.annotated(with: basalWithOverrides) + + let insulinEffects = annotatedDoses.glucoseEffects( + insulinSensitivityHistory: sensitivityWithOverrides, + from: start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval).dateFlooredToTimeInterval(GlucoseMath.defaultDelta), + to: nil) + + // ICE + let insulinCounteractionEffects = glucose.counteractionEffects(to: insulinEffects) + + // Carb Effects + let carbStatus = carbEntries.map( + to: insulinCounteractionEffects, + carbRatio: carbRatioWithOverrides, + insulinSensitivity: sensitivityWithOverrides + ) + + let carbEffects = carbStatus.dynamicGlucoseEffects( + from: end, + to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), + carbRatios: carbRatioWithOverrides, + insulinSensitivities: sensitivityWithOverrides, + absorptionModel: carbModel.model + ) + + let carbAbsorptionReview = CarbAbsorptionReview( + carbEntries: carbEntries, + carbStatuses: carbStatus, + effectsVelocities: insulinCounteractionEffects, + carbEffects: carbEffects + ) + + let trimmedDoses = annotatedDoses.filterDateRange(start, end) + let trimmedIOBValues = annotatedDoses.insulinOnBoardTimeline().filterDateRange(start, end) + + let historicalChartsData = HistoricalChartsData( + glucoseValues: glucose, + carbEntries: carbEntries, + doses: trimmedDoses, + iobValues: trimmedIOBValues, + carbAbsorptionReview: carbAbsorptionReview + ) + + return historicalChartsData } } diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 24eed5da22..a1268dc962 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -12,11 +12,11 @@ import HealthKit import Combine import LoopCore import LoopAlgorithm +import os.log -protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate { +protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate, FavoriteFoodInsightsViewModelDelegate { var defaultAbsorptionTimes: DefaultAbsorptionTimes { get } func scheduleOverrideEnabled(at date: Date) -> Bool - func selectedFavoriteFoodLastEaten(_ favoriteFood: StoredFavoriteFood) async throws -> Date? func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] } @@ -104,6 +104,8 @@ final class CarbEntryViewModel: ObservableObject { return formatter }() + private let log = OSLog(category: "CarbEntryViewModel") + weak var delegate: CarbEntryViewModelDelegate? weak var analyticsServicesManager: AnalyticsServicesManager? weak var deliveryDelegate: DeliveryDelegate? @@ -145,7 +147,6 @@ final class CarbEntryViewModel: ObservableObject { } var originalCarbEntry: StoredCarbEntry? = nil - private var favoriteFood: FavoriteFood? = nil private var updatedCarbEntry: NewCarbEntry? { if let quantity = carbsQuantity, quantity != 0 { @@ -293,7 +294,7 @@ final class CarbEntryViewModel: ObservableObject { } } catch { - print("could not fetch carb entries: \(error.localizedDescription)") + log.error("Failed to fetch last eaten date for favorite food: %{public}@, %{public}@", String(describing: selectedFavoriteFood), String(describing: error)) } } } diff --git a/Loop/View Models/FavoriteFoodInsightsViewModel.swift b/Loop/View Models/FavoriteFoodInsightsViewModel.swift new file mode 100644 index 0000000000..e7403c2c48 --- /dev/null +++ b/Loop/View Models/FavoriteFoodInsightsViewModel.swift @@ -0,0 +1,166 @@ +// +// FavoriteFoodInsightsViewModel.swift +// Loop +// +// Created by Noah Brauner on 7/15/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import LoopAlgorithm +import os.log +import Combine +import HealthKit + +protocol FavoriteFoodInsightsViewModelDelegate: AnyObject { + func selectedFavoriteFoodLastEaten(_ favoriteFood: StoredFavoriteFood) async throws -> Date? + func getFavoriteFoodCarbEntries(_ favoriteFood: StoredFavoriteFood) async throws -> [StoredCarbEntry] + func getHistoricalChartsData(start: Date, end: Date) async throws -> HistoricalChartsData +} + +struct HistoricalChartsData { + let glucoseValues: [GlucoseValue] + let carbEntries: [StoredCarbEntry] + let doses: [BasalRelativeDose] + let iobValues: [InsulinValue] + let carbAbsorptionReview: CarbAbsorptionReview? +} + +class FavoriteFoodInsightsViewModel: ObservableObject { + let food: StoredFavoriteFood + var carbEntries: [StoredCarbEntry] = [] + @Published var carbEntryIndex = 0 + var carbEntry: StoredCarbEntry? { + let entryExistsForIndex = 0..() + + init(delegate: FavoriteFoodInsightsViewModelDelegate?, food: StoredFavoriteFood) { + self.delegate = delegate + self.food = food + fetchCarbEntries(food) + observeCarbEntryIndexChange() + } + + private func fetchCarbEntries(_ food: StoredFavoriteFood) { + Task { @MainActor in + do { + if let entries = try await delegate?.getFavoriteFoodCarbEntries(food), !entries.isEmpty { + self.carbEntries = entries + updateStartDateAndRefreshCharts(from: entries.first!) + } + } + catch { + log.error("Failed to fetch carb entries for favorite food: %{public}@", String(describing: error)) + } + } + } + + private func updateStartDateAndRefreshCharts(from entry: StoredCarbEntry) { + var components = DateComponents() + components.minute = 0 + let minimumStartDate = entry.startDate.addingTimeInterval(-FavoriteFoodInsightsViewModel.minTimeIntervalPrecedingFoodEaten) + let hourRoundedStartDate = Calendar.current.nextDate(after: minimumStartDate, matching: components, matchingPolicy: .strict, direction: .backward) ?? minimumStartDate + + startDate = hourRoundedStartDate + refreshCharts() + } + + private func refreshCharts() { + Task { @MainActor in + do { + if let historicalChartsData = try await delegate?.getHistoricalChartsData(start: dateInterval.start, end: dateInterval.end) { + var carbEntriesWithCorrectedFavoriteFoods = historicalChartsData.carbEntries.map({ historicalCarbEntry in + // only show a favorite food icon in the glcuose-carb chart if carb entry is currently viewed favorite food + StoredCarbEntry( + startDate: historicalCarbEntry.startDate, + quantity: historicalCarbEntry.quantity, + favoriteFoodID: historicalCarbEntry.uuid == carbEntry?.uuid ? historicalCarbEntry.favoriteFoodID : nil + ) + }) + self.historicalGlucoseValues = historicalChartsData.glucoseValues + self.historicalCarbEntries = carbEntriesWithCorrectedFavoriteFoods + self.historicalDoses = historicalChartsData.doses + self.historicalIOBValues = historicalChartsData.iobValues + self.historicalCarbAbsorptionReview = historicalChartsData.carbAbsorptionReview + } + } catch { + log.error("Failed to fetch historical data in date interval: %{public}@, %{public}@", String(describing: dateInterval), String(describing: error)) + } + } + } + + private func observeCarbEntryIndexChange() { + $carbEntryIndex + .receive(on: RunLoop.main) + .dropFirst() + .sink { [weak self] index in + guard let strongSelf = self else { return } + strongSelf.updateStartDateAndRefreshCharts(from: strongSelf.carbEntries[strongSelf.carbEntryIndex]) + } + .store(in: &cancellables) + } +} diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index 98f2b418d6..4eb9373204 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -99,6 +99,11 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { .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 { @@ -273,11 +278,11 @@ extension CarbEntryView { .padding(.horizontal) .background(CardBackground()) .padding(.horizontal) - .onChange(of: viewModel.selectedFavoriteFoodIndex, perform: contractFavoriteFoodsRowIfNeeded(_:)) + .onChange(of: viewModel.selectedFavoriteFoodIndex, perform: collapseFavoriteFoodsRowIfNeeded(_:)) } } - private func contractFavoriteFoodsRowIfNeeded(_ newIndex: Int) { + private func collapseFavoriteFoodsRowIfNeeded(_ newIndex: Int) { if newIndex != -1 { withAnimation { clearExpandedRow() diff --git a/Loop/Views/FavoriteFoodInsightsChartsView.swift b/Loop/Views/FavoriteFoodInsightsChartsView.swift new file mode 100644 index 0000000000..080ea6628b --- /dev/null +++ b/Loop/Views/FavoriteFoodInsightsChartsView.swift @@ -0,0 +1,139 @@ +// +// FavoriteFoodInsightsChartsView.swift +// Loop +// +// Created by Noah Brauner on 7/30/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopAlgorithm +import HealthKit +import Combine + +struct FavoriteFoodsInsightsChartsView: View { + private enum ChartRow: Int, CaseIterable { + case glucose + case iob + case dose + case carbEffects + + var title: String { + switch self { + case .glucose: "Glucose" + case .iob: "Active Insulin" + case .dose: "Insulin Delivery" + case .carbEffects: "Glucose Change" + } + } + } + + @ObservedObject var viewModel: FavoriteFoodInsightsViewModel + @Binding var showHowCarbEffectsWorks: Bool + + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + + @State private var isInteractingWithChart = false + + var body: some View { + VStack(spacing: 10) { + let charts = ChartRow.allCases + ForEach(charts, id: \.rawValue) { chart in + ZStack(alignment: .topLeading) { + HStack { + Text(chart.title) + .font(.subheadline) + .bold() + + if chart == .carbEffects { + explainCarbEffectsButton + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(isInteractingWithChart ? 0 : 1) + + Group { + switch chart { + case .glucose: + glucoseChart + case .iob: + iobChart + case .dose: + doseChart + case .carbEffects: + carbEffectsChart + } + } + } + } + } + } + + private var glucoseChart: some View { + GlucoseCarbChartView( + chartManager: viewModel.chartManager, + glucoseUnit: displayGlucosePreference.unit, + glucoseValues: viewModel.historicalGlucoseValues, + carbEntries: viewModel.historicalCarbEntries, + dateInterval: viewModel.dateInterval, + isInteractingWithChart: $isInteractingWithChart + ) + .modifier(ChartModifier(horizontalPadding: 4, fractionOfScreenHeight: 1/4)) + } + + private var iobChart: some View { + IOBChartView( + chartManager: viewModel.chartManager, + iobValues: viewModel.historicalIOBValues, + dateInterval: viewModel.dateInterval, + isInteractingWithChart: $isInteractingWithChart + ) + .modifier(ChartModifier()) + } + + private var doseChart: some View { + DoseChartView( + chartManager: viewModel.chartManager, + doses: viewModel.historicalDoses, + dateInterval: viewModel.dateInterval, + isInteractingWithChart: $isInteractingWithChart + ) + .modifier(ChartModifier()) + } + + private var carbEffectsChart: some View { + CarbEffectChartView( + chartManager: viewModel.chartManager, + glucoseUnit: displayGlucosePreference.unit, + carbAbsorptionReview: viewModel.historicalCarbAbsorptionReview, + dateInterval: viewModel.dateInterval, + isInteractingWithChart: $isInteractingWithChart + ) + .modifier(ChartModifier()) + } + + private var explainCarbEffectsButton: some View { + Button(action: { showHowCarbEffectsWorks = true }) { + Image(systemName: "info.circle") + .font(.body) + .foregroundColor(.accentColor) + } + .buttonStyle(BorderlessButtonStyle()) + } +} + +fileprivate struct ChartModifier: ViewModifier { + var horizontalPadding: CGFloat = 8 + var fractionOfScreenHeight: CGFloat = 1/6 + + func body(content: Content) -> some View { + content + .padding(.horizontal, -4) + .padding(.top, UIFont.preferredFont(forTextStyle: .subheadline).lineHeight + 8) + .clipped() + .frame(height: floor(UIScreen.main.bounds.height * fractionOfScreenHeight)) + } +} + diff --git a/Loop/Views/FavoriteFoodInsightsView.swift b/Loop/Views/FavoriteFoodInsightsView.swift new file mode 100644 index 0000000000..eebf6c2d1d --- /dev/null +++ b/Loop/Views/FavoriteFoodInsightsView.swift @@ -0,0 +1,144 @@ +// +// 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 + + init(viewModel: FavoriteFoodInsightsViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + NavigationView { + List { + historicalCarbEntriesSection + historicalDataReviewSection + } + .padding(.top, -28) + .insetGroupedListStyle() + .navigationTitle("Favorite Food Insights") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + dismissButton + } + .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/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") + } + } +} From f007ee79cb4a5b32796693ed2a728317a626d163 Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Mon, 5 Aug 2024 11:19:58 -0400 Subject: [PATCH 137/184] Fix for landscape CarbEntryView/FavoriteFoodInsightsView --- Loop/Views/CarbEntryView.swift | 2 +- Loop/Views/FavoriteFoodInsightsChartsView.swift | 2 +- Loop/Views/FavoriteFoodInsightsView.swift | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index 4eb9373204..d5d8f39c9d 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -48,8 +48,8 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { continueButton } } - } + .navigationViewStyle(.stack) } else { content diff --git a/Loop/Views/FavoriteFoodInsightsChartsView.swift b/Loop/Views/FavoriteFoodInsightsChartsView.swift index 080ea6628b..71205485f7 100644 --- a/Loop/Views/FavoriteFoodInsightsChartsView.swift +++ b/Loop/Views/FavoriteFoodInsightsChartsView.swift @@ -133,7 +133,7 @@ fileprivate struct ChartModifier: ViewModifier { .padding(.horizontal, -4) .padding(.top, UIFont.preferredFont(forTextStyle: .subheadline).lineHeight + 8) .clipped() - .frame(height: floor(UIScreen.main.bounds.height * fractionOfScreenHeight)) + .frame(height: floor(max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) * fractionOfScreenHeight)) } } diff --git a/Loop/Views/FavoriteFoodInsightsView.swift b/Loop/Views/FavoriteFoodInsightsView.swift index eebf6c2d1d..ffee9f95f7 100644 --- a/Loop/Views/FavoriteFoodInsightsView.swift +++ b/Loop/Views/FavoriteFoodInsightsView.swift @@ -32,7 +32,6 @@ struct FavoriteFoodInsightsView: View { historicalDataReviewSection } .padding(.top, -28) - .insetGroupedListStyle() .navigationTitle("Favorite Food Insights") .navigationBarTitleDisplayMode(.inline) .toolbar { From 2c914f873e01b1b25057eb0caccfb60bbbdadf28 Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Mon, 5 Aug 2024 11:44:26 -0400 Subject: [PATCH 138/184] Renaming/organizing favorite foods --- Loop.xcodeproj/project.pbxproj | 32 ++++++++++++------- ...ift => FavoriteFoodAddEditViewModel.swift} | 6 ++-- Loop/Views/CarbEntryView.swift | 2 +- .../FavoriteFoodAddEditView.swift} | 16 +++++----- .../FavoriteFoodDetailView.swift | 0 .../FavoriteFoodInsightsChartsView.swift | 0 .../FavoriteFoodInsightsView.swift | 0 .../FavoriteFoodsView.swift | 4 +-- 8 files changed, 34 insertions(+), 26 deletions(-) rename Loop/View Models/{AddEditFavoriteFoodViewModel.swift => FavoriteFoodAddEditViewModel.swift} (95%) rename Loop/Views/{AddEditFavoriteFoodView.swift => Favorite Foods/FavoriteFoodAddEditView.swift} (92%) rename Loop/Views/{ => Favorite Foods}/FavoriteFoodDetailView.swift (100%) rename Loop/Views/{ => Favorite Foods}/FavoriteFoodInsightsChartsView.swift (100%) rename Loop/Views/{ => Favorite Foods}/FavoriteFoodInsightsView.swift (100%) rename Loop/Views/{ => Favorite Foods}/FavoriteFoodsView.swift (97%) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index bf4a931af3..f114f02aa0 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -12,8 +12,8 @@ 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 */; }; 147EFE8E2A8BCC5500272438 /* DefaultAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 147EFE8D2A8BCC5500272438 /* DefaultAssets.xcassets */; }; 147EFE902A8BCD8000272438 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 147EFE8F2A8BCD8000272438 /* DerivedAssets.xcassets */; }; @@ -734,8 +734,8 @@ /* 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 = ""; }; 147EFE8D2A8BCC5500272438 /* DefaultAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DefaultAssets.xcassets; sourceTree = ""; }; 147EFE8F2A8BCD8000272438 /* DerivedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssets.xcassets; sourceTree = ""; }; @@ -1822,6 +1822,18 @@ path = "Loop Widget Extension"; sourceTree = ""; }; + 14BBB3AE2C61274400ECB800 /* Favorite Foods */ = { + isa = PBXGroup; + children = ( + 1452F4AA2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift */, + 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */, + 14C970852C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift */, + 14C970812C5C2EC100E8A01B /* FavoriteFoodInsightsView.swift */, + 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */, + ); + path = "Favorite Foods"; + sourceTree = ""; + }; 14C970662C59918100E8A01B /* Charts */ = { isa = PBXGroup; children = ( @@ -2237,7 +2249,6 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( - 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */, B4001CED28CBBC82002FB414 /* AlertManagementView.swift */, 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */, C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */, @@ -2248,10 +2259,7 @@ 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */, A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */, C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */, - 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */, - 14C970852C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift */, - 14C970812C5C2EC100E8A01B /* FavoriteFoodInsightsView.swift */, - 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */, + 14BBB3AE2C61274400ECB800 /* Favorite Foods */, 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */, 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */, 14C970832C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift */, @@ -2615,10 +2623,10 @@ 897A5A9724C22DCE00C4E71D /* View Models */ = { isa = PBXGroup; children = ( - 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */, 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */, 149A28BA2A853E5100052EDF /* CarbEntryViewModel.swift */, A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */, + 1452F4A82A851C9400F8B9E4 /* FavoriteFoodAddEditViewModel.swift */, 14C9707F2C5C0A1500E8A01B /* FavoriteFoodInsightsViewModel.swift */, 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */, C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */, @@ -3596,7 +3604,7 @@ A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */, - 1452F4AB2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift in Sources */, + 1452F4AB2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift in Sources */, C1742332259BEADC00399C9D /* ManualEntryDoseView.swift in Sources */, 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */, 43FCEEA9221A615B0013DD30 /* StatusChartsManager.swift in Sources */, @@ -3739,7 +3747,7 @@ 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, 8968B1122408B3520074BB48 /* UIFont.swift in Sources */, - 1452F4A92A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift in Sources */, + 1452F4A92A851C9400F8B9E4 /* FavoriteFoodAddEditViewModel.swift in Sources */, 438D42FB1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift in Sources */, 89A1B66E24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */, C1C660D1252E4DD5009B5C32 /* LoopConstants.swift in Sources */, diff --git a/Loop/View Models/AddEditFavoriteFoodViewModel.swift b/Loop/View Models/FavoriteFoodAddEditViewModel.swift similarity index 95% rename from Loop/View Models/AddEditFavoriteFoodViewModel.swift rename to Loop/View Models/FavoriteFoodAddEditViewModel.swift index 5bd6eb8775..ede583c4e1 100644 --- a/Loop/View Models/AddEditFavoriteFoodViewModel.swift +++ b/Loop/View Models/FavoriteFoodAddEditViewModel.swift @@ -1,5 +1,5 @@ // -// AddEditFavoriteFoodViewModel.swift +// FavoriteFoodAddEditViewModel.swift // Loop // // Created by Noah Brauner on 7/31/23. @@ -10,7 +10,7 @@ import SwiftUI import LoopKit import HealthKit -final class AddEditFavoriteFoodViewModel: ObservableObject { +final class FavoriteFoodAddEditViewModel: ObservableObject { enum Alert: Identifiable { var id: Self { return self @@ -36,7 +36,7 @@ final class AddEditFavoriteFoodViewModel: ObservableObject { return minAbsorptionTime...maxAbsorptionTime } - @Published var alert: AddEditFavoriteFoodViewModel.Alert? + @Published var alert: FavoriteFoodAddEditViewModel.Alert? private let onSave: (NewFavoriteFood) -> () diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index d5d8f39c9d..56ac5045be 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -94,7 +94,7 @@ 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() diff --git a/Loop/Views/AddEditFavoriteFoodView.swift b/Loop/Views/Favorite Foods/FavoriteFoodAddEditView.swift similarity index 92% rename from Loop/Views/AddEditFavoriteFoodView.swift rename to Loop/Views/Favorite Foods/FavoriteFoodAddEditView.swift index b647523a13..69e52b2d46 100644 --- a/Loop/Views/AddEditFavoriteFoodView.swift +++ b/Loop/Views/Favorite Foods/FavoriteFoodAddEditView.swift @@ -10,10 +10,10 @@ import SwiftUI import LoopKit import LoopKitUI -struct AddEditFavoriteFoodView: View { +struct FavoriteFoodAddEditView: View { @Environment(\.dismiss) var dismiss - @StateObject private var viewModel: AddEditFavoriteFoodViewModel + @StateObject private var viewModel: FavoriteFoodAddEditViewModel @State private var expandedRow: Row? @State private var showHowAbsorptionTimeWorks = false @@ -22,13 +22,13 @@ struct AddEditFavoriteFoodView: View { /// Initializer for adding a new favorite food or editing a `StoredFavoriteFood` init(originalFavoriteFood: StoredFavoriteFood? = nil, onSave: @escaping (NewFavoriteFood) -> Void) { - self._viewModel = StateObject(wrappedValue: AddEditFavoriteFoodViewModel(originalFavoriteFood: originalFavoriteFood, onSave: onSave)) + self._viewModel = StateObject(wrappedValue: FavoriteFoodAddEditViewModel(originalFavoriteFood: originalFavoriteFood, onSave: onSave)) self.isNewEntry = originalFavoriteFood == nil } - /// Initializer for presenting the `AddEditFavoriteFoodView` prepopulated from the `CarbEntryView` + /// Initializer for presenting the `FavoriteFoodAddEditView` prepopulated from the `CarbEntryView` init(carbsQuantity: Double?, foodType: String, absorptionTime: TimeInterval, onSave: @escaping (NewFavoriteFood) -> Void) { - self._viewModel = StateObject(wrappedValue: AddEditFavoriteFoodViewModel(carbsQuantity: carbsQuantity, foodType: foodType, absorptionTime: absorptionTime, onSave: onSave)) + self._viewModel = StateObject(wrappedValue: FavoriteFoodAddEditViewModel(carbsQuantity: carbsQuantity, foodType: foodType, absorptionTime: absorptionTime, onSave: onSave)) } var body: some View { @@ -114,7 +114,7 @@ struct AddEditFavoriteFoodView: View { .padding(.horizontal) } - private func alert(for alert: AddEditFavoriteFoodViewModel.Alert) -> SwiftUI.Alert { + private func alert(for alert: FavoriteFoodAddEditViewModel.Alert) -> SwiftUI.Alert { switch alert { case .maxQuantityExceded: let message = String( @@ -142,7 +142,7 @@ struct AddEditFavoriteFoodView: View { } } -extension AddEditFavoriteFoodView { +extension FavoriteFoodAddEditView { private var dismissButton: some View { Button(action: dismiss.callAsFunction) { Text("Cancel") @@ -166,7 +166,7 @@ extension AddEditFavoriteFoodView { } } -extension AddEditFavoriteFoodView { +extension FavoriteFoodAddEditView { enum Row { case name, carbQuantity, foodType, absorptionTime } diff --git a/Loop/Views/FavoriteFoodDetailView.swift b/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift similarity index 100% rename from Loop/Views/FavoriteFoodDetailView.swift rename to Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift diff --git a/Loop/Views/FavoriteFoodInsightsChartsView.swift b/Loop/Views/Favorite Foods/FavoriteFoodInsightsChartsView.swift similarity index 100% rename from Loop/Views/FavoriteFoodInsightsChartsView.swift rename to Loop/Views/Favorite Foods/FavoriteFoodInsightsChartsView.swift diff --git a/Loop/Views/FavoriteFoodInsightsView.swift b/Loop/Views/Favorite Foods/FavoriteFoodInsightsView.swift similarity index 100% rename from Loop/Views/FavoriteFoodInsightsView.swift rename to Loop/Views/Favorite Foods/FavoriteFoodInsightsView.swift diff --git a/Loop/Views/FavoriteFoodsView.swift b/Loop/Views/Favorite Foods/FavoriteFoodsView.swift similarity index 97% rename from Loop/Views/FavoriteFoodsView.swift rename to Loop/Views/Favorite Foods/FavoriteFoodsView.swift index c2bb941c26..f76040fe01 100644 --- a/Loop/Views/FavoriteFoodsView.swift +++ b/Loop/Views/Favorite Foods/FavoriteFoodsView.swift @@ -48,7 +48,7 @@ struct FavoriteFoodsView: View { .insetGroupedListStyle() - NavigationLink(destination: AddEditFavoriteFoodView(originalFavoriteFood: viewModel.selectedFood, onSave: viewModel.onFoodSave(_:)), isActive: $viewModel.isEditViewActive) { + NavigationLink(destination: FavoriteFoodAddEditView(originalFavoriteFood: viewModel.selectedFood, onSave: viewModel.onFoodSave(_:)), isActive: $viewModel.isEditViewActive) { EmptyView() } @@ -64,7 +64,7 @@ struct FavoriteFoodsView: View { .navigationBarTitle("Favorite Foods", displayMode: .large) } .sheet(isPresented: $viewModel.isAddViewActive) { - AddEditFavoriteFoodView(onSave: viewModel.onFoodSave(_:)) + FavoriteFoodAddEditView(onSave: viewModel.onFoodSave(_:)) } .onChange(of: editMode) { newValue in if !newValue.isEditing { From bfa714b72e98f42662102b9181cf4c7d74cf9470 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 5 Aug 2024 11:09:52 -0700 Subject: [PATCH 139/184] [LOOP-4942] Fix path to BolusEntryView with missing guidanceColors --- Loop/View Controllers/CarbAbsorptionViewController.swift | 2 +- Loop/Views/CarbEntryView.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index 88aefd7c5d..31f06e96a2 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -467,7 +467,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif let carbEntryView = CarbEntryView(viewModel: viewModel) .environmentObject(deviceManager.displayGlucosePreference) .environment(\.dismissAction, carbEditWasCanceled) - let hostingController = UIHostingController(rootView: carbEntryView) + 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)) diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index 1307732972..63e832d841 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -14,6 +14,7 @@ import HealthKit struct CarbEntryView: View, HorizontalSizeClassOverride { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.dismissAction) private var dismiss + @Environment(\.guidanceColors) private var guidanceColors @ObservedObject var viewModel: CarbEntryViewModel @@ -130,6 +131,7 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { BolusEntryView(viewModel: viewModel) .environmentObject(displayGlucosePreference) .environment(\.dismissAction, dismiss) + .environment(\.guidanceColors, guidanceColors) } } From cee1369505b89d1dca7e7e51143a79af17db3496 Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Mon, 5 Aug 2024 16:24:46 -0400 Subject: [PATCH 140/184] Allow editing of favorite foods on detail view --- Loop/View Models/FavoriteFoodsViewModel.swift | 13 ++- .../FavoriteFoodDetailView.swift | 104 ++++++++++-------- .../Favorite Foods/FavoriteFoodsView.swift | 6 +- 3 files changed, 71 insertions(+), 52 deletions(-) diff --git a/Loop/View Models/FavoriteFoodsViewModel.swift b/Loop/View Models/FavoriteFoodsViewModel.swift index 48934d1c10..7842811806 100644 --- a/Loop/View Models/FavoriteFoodsViewModel.swift +++ b/Loop/View Models/FavoriteFoodsViewModel.swift @@ -48,14 +48,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) } diff --git a/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift b/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift index 44c7a83150..fcfe3fd5e2 100644 --- a/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift +++ b/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift @@ -11,63 +11,75 @@ import LoopKit import HealthKit public struct FavoriteFoodDetailView: View { - let food: StoredFavoriteFood? - let onFoodDelete: (StoredFavoriteFood) -> Void + @ObservedObject var viewModel: FavoriteFoodsViewModel @State private var isConfirmingDelete = false - - let carbFormatter: QuantityFormatter - let absorptionTimeFormatter: DateComponentsFormatter - let preferredCarbUnit: HKUnit - - public init(food: StoredFavoriteFood?, onFoodDelete: @escaping (StoredFavoriteFood) -> Void, isConfirmingDelete: Bool = false, carbFormatter: QuantityFormatter, absorptionTimeFormatter: DateComponentsFormatter, preferredCarbUnit: HKUnit = HKUnit.gram()) { - self.food = food - self.onFoodDelete = onFoodDelete - self.isConfirmingDelete = isConfirmingDelete - self.carbFormatter = carbFormatter - self.absorptionTimeFormatter = absorptionTimeFormatter - self.preferredCarbUnit = preferredCarbUnit - } - + public var body: some View { - if let food { - List { - Section("Information") { - VStack(spacing: 16) { - let rows: [(field: String, value: String)] = [ - ("Name", food.name), - ("Carb Quantity", food.carbsString(formatter: carbFormatter)), - ("Food Type", food.foodType), - ("Absorption Time", food.absorptionTimeString(formatter: absorptionTimeFormatter)) - ] - ForEach(rows, id: \.field) { row in + if let food = viewModel.selectedFood { + Group { + List { + 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)) + + Section { + Button(action: { viewModel.isEditViewActive.toggle() }) { HStack { - Text(row.field) - .font(.subheadline) + // 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() - Text(row.value) - .font(.subheadline) } } + + Button(role: .destructive, action: { isConfirmingDelete.toggle() }) { + Text("Delete Food") + .frame(maxWidth: .infinity, alignment: .center) // Align text in center + } } } - .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: viewModel.deleteSelectedFood) + ) + } + .insetGroupedListStyle() + .navigationTitle(food.title) + + NavigationLink(destination: FavoriteFoodAddEditView(originalFavoriteFood: viewModel.selectedFood, onSave: viewModel.onFoodSave(_:)), isActive: $viewModel.isEditViewActive) { + EmptyView() } } - .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/Favorite Foods/FavoriteFoodsView.swift b/Loop/Views/Favorite Foods/FavoriteFoodsView.swift index f76040fe01..e334ac3b00 100644 --- a/Loop/Views/Favorite Foods/FavoriteFoodsView.swift +++ b/Loop/Views/Favorite Foods/FavoriteFoodsView.swift @@ -47,12 +47,12 @@ struct FavoriteFoodsView: View { } .insetGroupedListStyle() - - NavigationLink(destination: FavoriteFoodAddEditView(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() } } From f6c10cf102689f3430cc234521f5e4a8eab6477b Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Mon, 5 Aug 2024 20:54:53 -0400 Subject: [PATCH 141/184] Add edit/insights screens to favorite food detail view --- .../StatusTableViewController.swift | 1 + Loop/View Models/FavoriteFoodsViewModel.swift | 42 ++++- Loop/View Models/SettingsViewModel.swift | 2 + Loop/Views/CarbEntryView.swift | 2 +- .../FavoriteFoodDetailView.swift | 150 +++++++++++++----- .../FavoriteFoodInsightsView.swift | 41 +++-- .../Favorite Foods/FavoriteFoodsView.swift | 6 +- Loop/Views/SettingsView.swift | 2 +- 8 files changed, 184 insertions(+), 62 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index ed5a727d8c..8542ff1649 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1623,6 +1623,7 @@ final class StatusTableViewController: LoopChartsTableViewController { therapySettingsViewModelDelegate: deviceManager, delegate: self ) + viewModel.favoriteFoodInsightsDelegate = loopManager let hostingController = DismissibleHostingController( rootView: SettingsView(viewModel: viewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucosePreference) diff --git a/Loop/View Models/FavoriteFoodsViewModel.swift b/Loop/View Models/FavoriteFoodsViewModel.swift index 7842811806..f9055c46f2 100644 --- a/Loop/View Models/FavoriteFoodsViewModel.swift +++ b/Loop/View Models/FavoriteFoodsViewModel.swift @@ -10,6 +10,7 @@ import SwiftUI import HealthKit import LoopKit import Combine +import os.log final class FavoriteFoodsViewModel: ObservableObject { @Published var favoriteFoods = UserDefaults.standard.favoriteFoods @@ -28,10 +29,24 @@ final class FavoriteFoodsViewModel: ObservableObject { return formatter }() + // Favorite Food Insights + @Published var selectedFoodLastEaten: Date? = nil + lazy var relativeDateFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter + }() + + private let log = OSLog(category: "CarbEntryViewModel") + + weak var insightsDelegate: FavoriteFoodInsightsViewModelDelegate? + private lazy var cancellables = Set() - init() { + init(insightsDelegate: FavoriteFoodInsightsViewModelDelegate?) { + self.insightsDelegate = insightsDelegate observeFavoriteFoodChange() + observeDetailViewPresentation() } func onFoodSave(_ newFood: NewFavoriteFood) { @@ -87,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/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 7a123ad400..3d73e7a1b2 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -100,6 +100,8 @@ public class SettingsViewModel: ObservableObject { delegate?.dosingEnabledChanged(closedLoopPreference) } } + + weak var favoriteFoodInsightsDelegate: FavoriteFoodInsightsViewModelDelegate? var showDeleteTestData: Bool { availableSupports.contains(where: { $0.showsDeleteTestDataUI }) diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index 56ac5045be..690c3965ef 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -342,7 +342,7 @@ extension CarbEntryView { .background(CardBackground()) .overlay { RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.accentColor, lineWidth: 2) + .strokeBorder(Color.accentColor, lineWidth: 2) } .padding(.horizontal) .contentShape(Rectangle()) diff --git a/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift b/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift index fcfe3fd5e2..508c431e49 100644 --- a/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift +++ b/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift @@ -8,61 +8,23 @@ import SwiftUI import LoopKit +import LoopKitUI import HealthKit public struct FavoriteFoodDetailView: View { @ObservedObject var viewModel: FavoriteFoodsViewModel @State private var isConfirmingDelete = false + @State private var showFavoriteFoodInsights = false public var body: some View { if let food = viewModel.selectedFood { Group { List { - 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)) - - 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) // Align text in center - } + informationSection(for: food) + actionsSection(for: food) + if let lastEatenDate = viewModel.selectedFoodLastEaten { + insightsSection(for: food, lastEaten: lastEatenDate) } } .alert(isPresented: $isConfirmingDelete) { @@ -79,7 +41,107 @@ public struct FavoriteFoodDetailView: View { 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) } } } + + private func insightsSection(for food: StoredFavoriteFood, lastEaten: Date) -> some View { + Section { + 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) + + let relativeTime = viewModel.relativeDateFormatter.localizedString(for: lastEaten, relativeTo: Date()) + let attributedFoodDescription = attributedFoodInsightsDescription(for: food.name, timeAgo: relativeTime) + Text(attributedFoodDescription) + .foregroundColor(.primary) + .multilineTextAlignment(.center) + } + .padding(.vertical, 12) + .padding(.horizontal) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.accentColor, lineWidth: 2) + } + .contentShape(Rectangle()) + } + .listRowInsets(EdgeInsets()) + .buttonStyle(PlainButtonStyle()) + } + } + + 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/FavoriteFoodInsightsView.swift b/Loop/Views/Favorite Foods/FavoriteFoodInsightsView.swift index ffee9f95f7..582a661f3a 100644 --- a/Loop/Views/Favorite Foods/FavoriteFoodInsightsView.swift +++ b/Loop/Views/Favorite Foods/FavoriteFoodInsightsView.swift @@ -21,26 +21,39 @@ struct FavoriteFoodInsightsView: View { @State private var showHowCarbEffectsWorks = false - init(viewModel: FavoriteFoodInsightsViewModel) { + let presentedAsSheet: Bool + + init(viewModel: FavoriteFoodInsightsViewModel, presentedAsSheet: Bool = true) { self._viewModel = StateObject(wrappedValue: viewModel) + self.presentedAsSheet = presentedAsSheet } var body: some View { - NavigationView { - List { - historicalCarbEntriesSection - historicalDataReviewSection - } - .padding(.top, -28) - .navigationTitle("Favorite Food Insights") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - dismissButton - } - .sheet(isPresented: $showHowCarbEffectsWorks) { - HowCarbEffectsWorksView() + 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 { diff --git a/Loop/Views/Favorite Foods/FavoriteFoodsView.swift b/Loop/Views/Favorite Foods/FavoriteFoodsView.swift index e334ac3b00..8ded4d57db 100644 --- a/Loop/Views/Favorite Foods/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 diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 31a4f1bbaa..5a609a938d 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -159,7 +159,7 @@ public struct SettingsView: View { .environment(\.guidanceColors, self.guidanceColors) .environment(\.insulinTintColor, self.insulinTintColor) case .favoriteFoods: - FavoriteFoodsView() + FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) } } } From e02190c8b378ae6ea5966ffa2f60d3f15c2adb19 Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Tue, 6 Aug 2024 15:42:46 -0400 Subject: [PATCH 142/184] Improve foodType/name UX for favorite foods --- Loop.xcodeproj/project.pbxproj | 4 ++++ Loop/Extensions/Character+IsEmoji.swift | 15 +++++++++++++++ .../FavoriteFoodAddEditViewModel.swift | 8 +++++++- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Loop/Extensions/Character+IsEmoji.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index f114f02aa0..4713698a8a 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 14B1737F28AEDC6C006CCD7C /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; 14B1738028AEDC6C006CCD7C /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; 14B1738128AEDC70006CCD7C /* StatusExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */; }; + 14BBB3B22C629DB100ECB800 /* Character+IsEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14BBB3B12C629DB100ECB800 /* Character+IsEmoji.swift */; }; 14C970682C5991CD00E8A01B /* LoopChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C970672C5991CD00E8A01B /* LoopChartView.swift */; }; 14C9706A2C5A833100E8A01B /* CarbEffectChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C970692C5A833100E8A01B /* CarbEffectChartView.swift */; }; 14C9706C2C5A836000E8A01B /* DoseChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C9706B2C5A836000E8A01B /* DoseChartView.swift */; }; @@ -751,6 +752,7 @@ 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 = ""; }; + 14BBB3B12C629DB100ECB800 /* Character+IsEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Character+IsEmoji.swift"; sourceTree = ""; }; 14C970672C5991CD00E8A01B /* LoopChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopChartView.swift; sourceTree = ""; }; 14C970692C5A833100E8A01B /* CarbEffectChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEffectChartView.swift; sourceTree = ""; }; 14C9706B2C5A836000E8A01B /* DoseChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseChartView.swift; sourceTree = ""; }; @@ -2191,6 +2193,7 @@ C1F2CAAB2B7A980600D7F581 /* BasalRelativeDose.swift */, A9F703722489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift */, C17824991E1999FA00D9D25C /* CaseCountable.swift */, + 14BBB3B12C629DB100ECB800 /* Character+IsEmoji.swift */, 4F6663931E905FD2009E74FC /* ChartColorPalette+Loop.swift */, 4389916A1E91B689000EEF90 /* ChartSettings+Loop.swift */, 4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */, @@ -3625,6 +3628,7 @@ C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */, 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */, 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */, + 14BBB3B22C629DB100ECB800 /* Character+IsEmoji.swift in Sources */, C1B80D632AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift in Sources */, DDC389FA2A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift in Sources */, B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */, diff --git a/Loop/Extensions/Character+IsEmoji.swift b/Loop/Extensions/Character+IsEmoji.swift new file mode 100644 index 0000000000..fe19295350 --- /dev/null +++ b/Loop/Extensions/Character+IsEmoji.swift @@ -0,0 +1,15 @@ +// +// Character+IsEmoji.swift +// Loop +// +// Created by Noah Brauner on 8/6/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation + +extension Character { + public var isEmoji: Bool { + unicodeScalars.contains(where: { $0.properties.isEmoji }) + } +} diff --git a/Loop/View Models/FavoriteFoodAddEditViewModel.swift b/Loop/View Models/FavoriteFoodAddEditViewModel.swift index ede583c4e1..225766db4c 100644 --- a/Loop/View Models/FavoriteFoodAddEditViewModel.swift +++ b/Loop/View Models/FavoriteFoodAddEditViewModel.swift @@ -57,8 +57,14 @@ final class FavoriteFoodAddEditViewModel: 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? From 871b48c77ffb16beaf884ed92a63da553ea8b8dd Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Wed, 7 Aug 2024 13:15:54 -0400 Subject: [PATCH 143/184] [LOOP-4982] Fix manual dose screen --- Loop/View Models/ManualEntryDoseViewModel.swift | 5 ++--- Loop/Views/ManualEntryDoseView.swift | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Loop/View Models/ManualEntryDoseViewModel.swift b/Loop/View Models/ManualEntryDoseViewModel.swift index de960b0e95..f6d1235df6 100644 --- a/Loop/View Models/ManualEntryDoseViewModel.swift +++ b/Loop/View Models/ManualEntryDoseViewModel.swift @@ -37,7 +37,6 @@ final class ManualEntryDoseViewModel: ObservableObject { // MARK: - State @Published var glucoseValues: [GlucoseValue] = [] // stored glucose values - private var storedGlucoseValues: [GlucoseValue] = [] @Published var predictedGlucoseValues: [GlucoseValue] = [] @Published var glucoseUnit: HKUnit = .milligramsPerDeciliter @Published var chartDateInterval: DateInterval @@ -245,7 +244,7 @@ final class ManualEntryDoseViewModel: ObservableObject { if let input = state.input { - self.storedGlucoseValues = input.glucoseHistory + self.glucoseValues = input.glucoseHistory do { predictedGlucoseValues = try input @@ -288,7 +287,7 @@ final class ManualEntryDoseViewModel: ObservableObject { let totalHours = floor(Double(availableWidth / LoopConstants.minimumChartWidthPerHour)) let insulinModel = delegate?.insulinModel(for: selectedInsulinType) - let futureHours = ceil((insulinModel?.effectDuration.hours ?? .hours(4)).hours) + 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/Views/ManualEntryDoseView.swift b/Loop/Views/ManualEntryDoseView.swift index d361b48ad3..ea70d235dc 100644 --- a/Loop/Views/ManualEntryDoseView.swift +++ b/Loop/Views/ManualEntryDoseView.swift @@ -197,7 +197,8 @@ struct ManualEntryDoseView: View { textAlignment: .right, keyboardType: .decimalPad, shouldBecomeFirstResponder: shouldBolusEntryBecomeFirstResponder, - maxLength: 5 + maxLength: 5, + doneButtonColor: .loopAccent ) bolusUnitsLabel } From bbfa148e300d39121f08dbbf7226c2971cf1c39c Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Wed, 7 Aug 2024 16:43:42 -0400 Subject: [PATCH 144/184] Edit carb entry screen: favorite food insights + allow removal of favorite food --- Loop/View Models/CarbEntryViewModel.swift | 44 +++++++++++++---------- Loop/Views/CarbEntryView.swift | 29 ++++++++++----- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index a1268dc962..b71771b6ad 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -141,8 +141,10 @@ final class CarbEntryViewModel: ObservableObject { if let favoriteFoodIndex = favoriteFoods.firstIndex(where: { $0.id == originalCarbEntry.favoriteFoodID }) { self.selectedFavoriteFoodIndex = favoriteFoodIndex + updateFavoriteFoodLastEatenDate(for: favoriteFoods[favoriteFoodIndex]) } + observeFavoriteFoodIndexChange() observeLoopUpdates() } @@ -150,12 +152,12 @@ final class CarbEntryViewModel: ObservableObject { 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 } - let favoriteFoodID = selectedFavoriteFoodIndex == -1 ? nil : favoriteFoods[selectedFavoriteFoodIndex].id - return NewCarbEntry( date: date, quantity: HKQuantity(unit: preferredCarbUnit, doubleValue: quantity), @@ -269,12 +271,15 @@ final class CarbEntryViewModel: ObservableObject { private func favoriteFoodSelected(at index: Int) { self.absorptionEditIsProgrammatic = true + // only updates carb entry fields if on new carb entry screen if index == -1 { - self.carbsQuantity = 0 + if originalCarbEntry == nil { + self.carbsQuantity = 0 + self.absorptionTime = defaultAbsorptionTimes.medium + self.absorptionTimeWasEdited = false + self.usesCustomFoodType = false + } self.foodType = "" - self.absorptionTime = defaultAbsorptionTimes.medium - self.absorptionTimeWasEdited = false - self.usesCustomFoodType = false } else { let food = favoriteFoods[index] @@ -283,19 +288,22 @@ final class CarbEntryViewModel: ObservableObject { self.absorptionTime = food.absorptionTime self.absorptionTimeWasEdited = true self.usesCustomFoodType = true - - // Update favorite food insights last eaten date - Task { @MainActor in - do { - if let lastEaten = try await delegate?.selectedFavoriteFoodLastEaten(food) { - withAnimation(.default) { - self.selectedFavoriteFoodLastEaten = lastEaten - } + 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)) - } + } + catch { + log.error("Failed to fetch last eaten date for favorite food: %{public}@, %{public}@", String(describing: selectedFavoriteFood), String(describing: error)) } } } diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index 8c5701c086..bb518d57f1 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -113,6 +113,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) @@ -122,8 +130,7 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { CardSectionDivider() - let selectedFavoriteFoodBinding = Binding(get: { viewModel.selectedFavoriteFood }, set: { _ in }) - FoodTypeRow(selectedFavoriteFood: selectedFavoriteFoodBinding, 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() @@ -228,14 +235,14 @@ extension CarbEntryView { // MARK: - Favorite Foods Card extension CarbEntryView { private var favoriteFoodsCard: some View { - return VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 6) { Text("FAVORITE FOODS") .font(.footnote) .foregroundColor(.secondary) .padding(.horizontal, 26) VStack(spacing: 10) { - if !viewModel.favoriteFoods.isEmpty { + if !viewModel.favoriteFoods.isEmpty, isNewEntry { VStack { HStack { Text("Choose Favorite:") @@ -267,14 +274,18 @@ extension CarbEntryView { } } - CardSectionDivider() + if viewModel.selectedFavoriteFood == nil { + CardSectionDivider() + } } - Button(action: saveAsFavoriteFood) { - Text("Save as favorite food") - .frame(maxWidth: .infinity) + if viewModel.selectedFavoriteFood == nil { + Button(action: saveAsFavoriteFood) { + Text("Save as favorite food") + .frame(maxWidth: .infinity) + } + .disabled(viewModel.saveFavoriteFoodButtonDisabled) } - .disabled(viewModel.saveFavoriteFoodButtonDisabled) } .padding(.vertical, 12) .padding(.horizontal) From 1b6711599963b411d99a14afc586db22c0c21f6b Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Wed, 7 Aug 2024 17:37:08 -0400 Subject: [PATCH 145/184] Add analytics for favorite foods --- Loop/Managers/AnalyticsServicesManager.swift | 4 ++-- Loop/View Models/BolusEntryViewModel.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 4e80ba7bd5..8528318d6c 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -166,8 +166,8 @@ final class AnalyticsServicesManager { logEvent("CGM Added", withProperties: ["identifier" : identifier]) } - func didAddCarbs(source: String, amount: Double, inSession: Bool = false) { - logEvent("Carb entry created", withProperties: ["source" : source, "amount": "\(amount)"], outOfSession: inSession) + func didAddCarbs(source: String, amount: Double, isFavoriteFood: Bool = false, inSession: Bool = false) { + logEvent("Carb entry created", withProperties: ["source" : source, "amount": "\(amount)", "isFavoriteFood": isFavoriteFood], outOfSession: inSession) } func didRetryBolus() { diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index f7a67fb995..6a65ee7590 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -382,7 +382,7 @@ final class BolusEntryViewModel: ObservableObject { } if let storedCarbEntry = await saveCarbEntry(carbEntry, replacingEntry: originalCarbEntry) { self.dosingDecision.carbEntry = storedCarbEntry - self.analyticsServicesManager?.didAddCarbs(source: "Phone", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) + self.analyticsServicesManager?.didAddCarbs(source: "Phone", amount: storedCarbEntry.quantity.doubleValue(for: .gram()), isFavoriteFood: storedCarbEntry.favoriteFoodID != nil) } else { self.presentAlert(.carbEntryPersistenceFailure) return false From 9ca50885631d23f711f54a25be88a3877cc3dd7b Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Wed, 7 Aug 2024 18:05:18 -0400 Subject: [PATCH 146/184] Code cleanup --- Loop.xcodeproj/project.pbxproj | 4 + Loop/Views/CarbEntryView.swift | 62 ++------------ .../FavoriteFoodDetailView.swift | 56 ++----------- .../FavoriteFoodInsightsCardView.swift | 82 +++++++++++++++++++ 4 files changed, 101 insertions(+), 103 deletions(-) create mode 100644 Loop/Views/Favorite Foods/FavoriteFoodInsightsCardView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 4713698a8a..acd8ba0799 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 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 */; }; @@ -763,6 +764,7 @@ 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 = ""; }; @@ -1829,6 +1831,7 @@ children = ( 1452F4AA2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift */, 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */, + 14ED83F52C6421F9008B4A5C /* FavoriteFoodInsightsCardView.swift */, 14C970852C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift */, 14C970812C5C2EC100E8A01B /* FavoriteFoodInsightsView.swift */, 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */, @@ -3725,6 +3728,7 @@ 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 */, 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */, diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index bb518d57f1..c5faffa1b4 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -80,8 +80,13 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { } if viewModel.selectedFavoriteFoodLastEaten != nil, FeatureFlags.allowExperimentalFeatures { - favoriteFoodInsightsCard - .padding(.top, 8) + 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 }) @@ -242,7 +247,7 @@ extension CarbEntryView { .padding(.horizontal, 26) VStack(spacing: 10) { - if !viewModel.favoriteFoods.isEmpty, isNewEntry { + if !viewModel.favoriteFoods.isEmpty { VStack { HStack { Text("Choose Favorite:") @@ -324,57 +329,6 @@ extension CarbEntryView { } } -// MARK: - Favorite Food Insights Card -extension CarbEntryView { - private var favoriteFoodInsightsCard: 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 = viewModel.selectedFavoriteFood?.name, - let lastEatenDate = viewModel.selectedFavoriteFoodLastEaten { - let relativeTime = viewModel.relativeDateFormatter.localizedString(for: lastEatenDate, relativeTo: Date()) - let attributedFoodDescription = attributedFoodInsightsDescription(for: foodName, timeAgo: relativeTime) - - Text(attributedFoodDescription) - .foregroundColor(.primary) - .multilineTextAlignment(.center) - } - } - .padding(.vertical, 12) - .padding(.horizontal) - .background(CardBackground()) - .overlay { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(Color.accentColor, lineWidth: 2) - } - .padding(.horizontal) - .contentShape(Rectangle()) - } - } - - 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 - } -} - // MARK: - Other UI Elements extension CarbEntryView { private var dismissButton: some View { diff --git a/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift b/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift index 508c431e49..10f7625d68 100644 --- a/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift +++ b/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift @@ -23,9 +23,13 @@ public struct FavoriteFoodDetailView: View { List { informationSection(for: food) actionsSection(for: food) - if let lastEatenDate = viewModel.selectedFoodLastEaten { - insightsSection(for: food, lastEaten: lastEatenDate) - } + FavoriteFoodInsightsCardView( + showFavoriteFoodInsights: $showFavoriteFoodInsights, + foodName: viewModel.selectedFood?.name, + lastEatenDate: viewModel.selectedFoodLastEaten, + relativeDateFormatter: viewModel.relativeDateFormatter, + presentInSection: true + ) } .alert(isPresented: $isConfirmingDelete) { Alert( @@ -98,50 +102,4 @@ public struct FavoriteFoodDetailView: View { } } } - - private func insightsSection(for food: StoredFavoriteFood, lastEaten: Date) -> some View { - Section { - 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) - - let relativeTime = viewModel.relativeDateFormatter.localizedString(for: lastEaten, relativeTo: Date()) - let attributedFoodDescription = attributedFoodInsightsDescription(for: food.name, timeAgo: relativeTime) - Text(attributedFoodDescription) - .foregroundColor(.primary) - .multilineTextAlignment(.center) - } - .padding(.vertical, 12) - .padding(.horizontal) - .overlay { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(Color.accentColor, lineWidth: 2) - } - .contentShape(Rectangle()) - } - .listRowInsets(EdgeInsets()) - .buttonStyle(PlainButtonStyle()) - } - } - - 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/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 + } +} From dd8b59bf4cf9f57bd22eec3c29e4f56a5c9e93d1 Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Fri, 9 Aug 2024 11:29:55 -0400 Subject: [PATCH 147/184] [LOOP-4994] Widget asset fix --- .../carbs.imageset/Contents.json | 0 .../carbs.imageset/Meal.pdf | Bin .../Widgets/SystemStatusWidget.swift | 2 +- Loop.xcodeproj/project.pbxproj | 4 +++- 4 files changed, 4 insertions(+), 2 deletions(-) rename Loop Widget Extension/{DefaultAssets.xcassets => DerivedAssetsBase.xcassets}/carbs.imageset/Contents.json (100%) rename Loop Widget Extension/{DefaultAssets.xcassets => DerivedAssetsBase.xcassets}/carbs.imageset/Meal.pdf (100%) 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/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift index b5c60a4d3e..49e7af3a52 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -29,7 +29,7 @@ struct SystemStatusWidgetEntryView : View { VStack(alignment: .center, spacing: 5) { HStack(alignment: .center, spacing: 15) { LoopCircleView(closedLoop: entry.closeLoop, freshness: freshness) - .environment(\.guidanceColors, .default) + .environment(\.loopStatusColorPalette, .loopStatus) .disabled(entry.contextIsStale) GlucoseView(entry: entry) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index acd8ba0799..779baea4cb 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 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 */; }; 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 */; }; @@ -2543,9 +2544,9 @@ 84AA81D92A4A2966000B658B /* Helpers */ = { isa = PBXGroup; children = ( + 8496F7302B5711C4003E672C /* ContentMargin.swift */, 84AA81DA2A4A2973000B658B /* Date.swift */, 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */, - 8496F7302B5711C4003E672C /* ContentMargin.swift */, ); path = Helpers; sourceTree = ""; @@ -3528,6 +3529,7 @@ 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 */, From 33337f226c2e991dbdd47dd7a66fb8e3793d362f Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Fri, 9 Aug 2024 11:46:07 -0400 Subject: [PATCH 148/184] Widget spacing ui fix --- .../Components/EventualGlucoseView.swift | 34 +++++++++++++++ .../Components/PumpView.swift | 43 +++++-------------- .../Widgets/SystemStatusWidget.swift | 24 +++++++---- Loop.xcodeproj/project.pbxproj | 4 ++ 4 files changed, 64 insertions(+), 41 deletions(-) create mode 100644 Loop Widget Extension/Components/EventualGlucoseView.swift diff --git a/Loop Widget Extension/Components/EventualGlucoseView.swift b/Loop Widget Extension/Components/EventualGlucoseView.swift new file mode 100644 index 0000000000..1011c93255 --- /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 ? 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)) + } + } + } + } +} diff --git a/Loop Widget Extension/Components/PumpView.swift b/Loop Widget Extension/Components/PumpView.swift index bee09c1217..fe074bf5f2 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, isOld: entry.contextIsStale) } } } diff --git a/Loop Widget Extension/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift index 49e7af3a52..9f148504d2 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -27,12 +27,14 @@ struct SystemStatusWidgetEntryView : View { var body: some View { HStack(alignment: .center, spacing: 5) { VStack(alignment: .center, spacing: 5) { - HStack(alignment: .center, spacing: 15) { + 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) @@ -41,13 +43,19 @@ struct SystemStatusWidgetEntryView : View { .fill(Color("WidgetSecondaryBackground")) ) - PumpView(entry: entry) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .padding(5) - .background( - ContainerRelativeShape() - .fill(Color("WidgetSecondaryBackground")) - ) + HStack(alignment: .center, spacing: 0) { + PumpView(entry: entry) + .frame(maxWidth: .infinity, alignment: .center) + + EventualGlucoseView(entry: entry) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxHeight: .infinity, alignment: .center) + .padding(.vertical, 5) + .background( + ContainerRelativeShape() + .fill(Color("WidgetSecondaryBackground")) + ) } if widgetFamily != .systemSmall { diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 779baea4cb..413ae636d0 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 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 */; }; 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 */; }; @@ -740,6 +741,7 @@ 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 = ""; }; 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 = ""; }; @@ -2523,6 +2525,7 @@ isa = PBXGroup; children = ( 14B1736E28AEDBF6006CCD7C /* BasalView.swift */, + 1455ACAA2C666F9C004F44F2 /* EventualGlucoseView.swift */, 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */, 84AA81E22A4A36FB000B658B /* SystemActionLink.swift */, 84AA81E62A4A4DEF000B658B /* PumpView.swift */, @@ -3539,6 +3542,7 @@ 14B1737828AEDC6C006CCD7C /* NSTimeInterval.swift in Sources */, 14B1737928AEDC6C006CCD7C /* NSUserDefaults+StatusExtension.swift in Sources */, 14B1737A28AEDC6C006CCD7C /* NumberFormatter.swift in Sources */, + 1455ACAB2C666F9C004F44F2 /* EventualGlucoseView.swift in Sources */, 14B1737B28AEDC6C006CCD7C /* OSLog.swift in Sources */, 14B1737C28AEDC6C006CCD7C /* PumpManager.swift in Sources */, 14B1737D28AEDC6C006CCD7C /* PumpManagerUI.swift in Sources */, From c22b37f4f54001e67fd3d1e1f8bfcb0d42a6139f Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Fri, 9 Aug 2024 12:41:31 -0400 Subject: [PATCH 149/184] Code cleanup --- .../Components/BasalView.swift | 11 ++- .../Components/DeeplinkView.swift | 55 +++++++++++++ .../Components/EventualGlucoseView.swift | 6 +- .../Components/GlucoseView.swift | 35 +++----- .../Components/PumpView.swift | 2 +- .../Components/SystemActionLink.swift | 82 ------------------- Loop Widget Extension/Helpers/Color.swift | 19 +++++ .../Helpers/WidgetBackground.swift | 12 ++- Loop Widget Extension/LoopWidgets.swift | 1 - .../Widgets/SystemStatusWidget.swift | 25 ++---- Loop.xcodeproj/project.pbxproj | 18 +++- Loop/Managers/DeeplinkManager.swift | 15 ---- Loop/Models/Deeplink.swift | 24 ++++++ 13 files changed, 151 insertions(+), 154 deletions(-) create mode 100644 Loop Widget Extension/Components/DeeplinkView.swift delete mode 100644 Loop Widget Extension/Components/SystemActionLink.swift create mode 100644 Loop Widget Extension/Helpers/Color.swift create mode 100644 Loop/Models/Deeplink.swift diff --git a/Loop Widget Extension/Components/BasalView.swift b/Loop Widget Extension/Components/BasalView.swift index b64bc9f338..224fbc7c27 100644 --- a/Loop Widget Extension/Components/BasalView.swift +++ b/Loop Widget Extension/Components/BasalView.swift @@ -10,8 +10,7 @@ import SwiftUI struct BasalView: View { let netBasal: NetBasalContext - let isOld: Bool - + let isStale: Bool var body: some View { let percent = netBasal.percentage @@ -21,20 +20,20 @@ struct BasalView: View { BasalRateView(percent: percent) .overlay( BasalRateView(percent: percent) - .stroke(isOld ? Color(UIColor.systemGray3) : Color("insulin"), lineWidth: 2) + .stroke(isStale ? Color.staleGray : Color.insulin, lineWidth: 2) ) - .foregroundColor((isOld ? Color(UIColor.systemGray3) : Color("insulin")).opacity(0.5)) + .foregroundColor((isStale ? Color.staleGray : Color.insulin).opacity(0.5)) .frame(width: 44, height: 22) if let rateString = decimalFormatter.string(from: NSNumber(value: rate)) { Text("\(rateString) U") .font(.footnote) - .foregroundColor(Color(isOld ? UIColor.systemGray3 : UIColor.secondaryLabel)) + .foregroundColor(isStale ? .staleGray : .secondary) } else { Text("-U") .font(.footnote) - .foregroundColor(Color(isOld ? UIColor.systemGray3 : UIColor.secondaryLabel)) + .foregroundColor(isStale ? .staleGray : .secondary) } } } diff --git a/Loop Widget Extension/Components/DeeplinkView.swift b/Loop Widget Extension/Components/DeeplinkView.swift new file mode 100644 index 0000000000..79fdf05862 --- /dev/null +++ b/Loop Widget Extension/Components/DeeplinkView.swift @@ -0,0 +1,55 @@ +// +// DeeplinkView.swift +// Loop Widget Extension +// +// Created by Noah Brauner on 8/9/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +fileprivate extension Deeplink { + var deeplinkURL: URL { + URL(string: "loop://\(rawValue)")! + } + + var accentColor: Color { + switch self { + case .carbEntry: + return .carbs + case .bolus: + return .insulin + case .preMeal: + return .carbs + case .customPresets: + return .glucose + } + } + + var icon: Image { + switch self { + case .carbEntry: + return Image(.carbs) + case .bolus: + return Image(.bolus) + case .preMeal: + return Image(.premeal) + case .customPresets: + return Image(.workout) + } + } +} + +struct DeeplinkView: View { + let destination: Deeplink + var isActive: Bool = false + + var body: some View { + Link(destination: destination.deeplinkURL) { + destination.icon + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .foregroundColor(isActive ? .white : destination.accentColor) + .containerRelativeBackground(color: isActive ? destination.accentColor : .widgetSecondaryBackground) + } + } +} diff --git a/Loop Widget Extension/Components/EventualGlucoseView.swift b/Loop Widget Extension/Components/EventualGlucoseView.swift index 1011c93255..fcb0d742b0 100644 --- a/Loop Widget Extension/Components/EventualGlucoseView.swift +++ b/Loop Widget Extension/Components/EventualGlucoseView.swift @@ -18,15 +18,15 @@ struct EventualGlucoseView: View { VStack { Text("Eventual") .font(.footnote) - .foregroundColor(entry.contextIsStale ? Color(UIColor.systemGray3) : Color(UIColor.secondaryLabel)) - + .foregroundColor(entry.contextIsStale ? .staleGray : .secondary) + Text("\(glucoseString)") .font(.subheadline) .fontWeight(.heavy) Text(eventualGlucose.unit.shortLocalizedUnitString()) .font(.footnote) - .foregroundColor(entry.contextIsStale ? Color(UIColor.systemGray3) : Color(UIColor.secondaryLabel)) + .foregroundColor(entry.contextIsStale ? .staleGray : .secondary) } } } diff --git a/Loop Widget Extension/Components/GlucoseView.swift b/Loop Widget Extension/Components/GlucoseView.swift index a0d5c5c26b..19abd8db30 100644 --- a/Loop Widget Extension/Components/GlucoseView.swift +++ b/Loop Widget Extension/Components/GlucoseView.swift @@ -12,26 +12,17 @@ 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("---") @@ -42,26 +33,22 @@ struct GlucoseView: View { Image(systemName: trendImageName) } } - // 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) } } } diff --git a/Loop Widget Extension/Components/PumpView.swift b/Loop Widget Extension/Components/PumpView.swift index fe074bf5f2..1dca02276d 100644 --- a/Loop Widget Extension/Components/PumpView.swift +++ b/Loop Widget Extension/Components/PumpView.swift @@ -21,7 +21,7 @@ struct PumpView: View { } } else if let netBasal = entry.netBasal { - BasalView(netBasal: netBasal, isOld: entry.contextIsStale) + BasalView(netBasal: netBasal, isStale: entry.contextIsStale) } } } diff --git a/Loop Widget Extension/Components/SystemActionLink.swift b/Loop Widget Extension/Components/SystemActionLink.swift deleted file mode 100644 index eb62bbfa40..0000000000 --- a/Loop Widget Extension/Components/SystemActionLink.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// SystemActionLink.swift -// Loop Widget Extension -// -// Created by Cameron Ingham on 6/26/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import Foundation -import SwiftUI - -struct SystemActionLink: View { - enum Destination: String, CaseIterable { - case carbEntry = "carb-entry" - case bolus = "manual-bolus" - case preMeal = "pre-meal-preset" - case customPreset = "custom-presets" - - var deeplink: URL { - URL(string: "loop://\(rawValue)")! - } - } - - let destination: Destination - let active: Bool - - init(to destination: Destination, active: Bool = false) { - self.destination = destination - self.active = active - } - - private func foregroundColor(active: Bool) -> Color { - switch destination { - case .carbEntry: - return Color("fresh") - case .bolus: - return Color("insulin") - case .preMeal: - return active ? .white : Color("fresh") - case .customPreset: - return active ? .white : Color("glucose") - } - } - - private func backgroundColor(active: Bool) -> Color { - switch destination { - case .carbEntry: - return active ? Color("fresh") : Color("WidgetSecondaryBackground") - case .bolus: - return active ? Color("insulin") : Color("WidgetSecondaryBackground") - case .preMeal: - return active ? Color("fresh") : Color("WidgetSecondaryBackground") - case .customPreset: - return active ? Color("glucose") : Color("WidgetSecondaryBackground") - } - } - - private var icon: Image { - switch destination { - case .carbEntry: - return Image("carbs") - case .bolus: - return Image("bolus") - case .preMeal: - return Image("premeal") - case .customPreset: - return Image("workout") - } - } - - var body: some View { - Link(destination: destination.deeplink) { - icon - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .foregroundColor(foregroundColor(active: active)) - .background( - ContainerRelativeShape() - .fill(backgroundColor(active: active)) - ) - } - } -} diff --git a/Loop Widget Extension/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 6bc0fec968..f8d338e6ba 100644 --- a/Loop Widget Extension/Helpers/WidgetBackground.swift +++ b/Loop Widget Extension/Helpers/WidgetBackground.swift @@ -13,10 +13,18 @@ extension View { func widgetBackground() -> some View { if #available(iOSApplicationExtension 17.0, *) { containerBackground(for: .widget) { - background { Color("WidgetBackground") } + background { Color.widgetBackground } } } else { - background { Color("WidgetBackground") } + background { Color.widgetBackground } } } + + @ViewBuilder + func containerRelativeBackground(color: Color = .widgetSecondaryBackground) -> some View { + background( + ContainerRelativeShape() + .fill(color) + ) + } } diff --git a/Loop Widget Extension/LoopWidgets.swift b/Loop Widget Extension/LoopWidgets.swift index 26f92edb45..a73de9b7a7 100644 --- a/Loop Widget Extension/LoopWidgets.swift +++ b/Loop Widget Extension/LoopWidgets.swift @@ -10,7 +10,6 @@ import SwiftUI @main struct LoopWidgets: WidgetBundle { - @WidgetBundleBuilder var body: some Widget { SystemStatusWidget() diff --git a/Loop Widget Extension/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift index 9f148504d2..6c3a73bcec 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -12,11 +12,10 @@ import LoopUI import SwiftUI import WidgetKit -struct SystemStatusWidgetEntryView : View { - +struct SystemStatusWidgetEntryView: View { @Environment(\.widgetFamily) private var widgetFamily - var entry: StatusWidgetTimelineProvider.Entry + var entry: StatusWidgetTimelimeEntry var freshness: LoopCompletionFreshness { let lastLoopCompleted = entry.lastLoopCompleted ?? Date().addingTimeInterval(.minutes(16)) @@ -38,10 +37,7 @@ struct SystemStatusWidgetEntryView : View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .padding(5) - .background( - ContainerRelativeShape() - .fill(Color("WidgetSecondaryBackground")) - ) + .containerRelativeBackground() HStack(alignment: .center, spacing: 0) { PumpView(entry: entry) @@ -52,33 +48,30 @@ struct SystemStatusWidgetEntryView : View { } .frame(maxHeight: .infinity, alignment: .center) .padding(.vertical, 5) - .background( - ContainerRelativeShape() - .fill(Color("WidgetSecondaryBackground")) - ) + .containerRelativeBackground() } if widgetFamily != .systemSmall { VStack(alignment: .center, spacing: 5) { HStack(alignment: .center, spacing: 5) { - SystemActionLink(to: .carbEntry) + DeeplinkView(destination: .carbEntry) - SystemActionLink(to: .bolus) + DeeplinkView(destination: .bolus) } HStack(alignment: .center, spacing: 5) { if entry.preMealPresetAllowed { - SystemActionLink(to: .preMeal, active: entry.preMealPresetActive) + DeeplinkView(destination: .preMeal, isActive: entry.preMealPresetActive) } - SystemActionLink(to: .customPreset, active: entry.customPresetActive) + DeeplinkView(destination: .customPresets, isActive: entry.customPresetActive) } } .buttonStyle(.plain) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } } - .foregroundColor(entry.contextIsStale ? Color(UIColor.systemGray3) : nil) + .foregroundColor(entry.contextIsStale ? .staleGray : nil) .padding(5) .widgetBackground() } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 413ae636d0..0f2fa3feb3 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -17,6 +17,10 @@ 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 */; }; @@ -260,7 +264,6 @@ 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 */; }; 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; @@ -742,6 +745,9 @@ 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 = ""; }; @@ -1184,7 +1190,6 @@ 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 = ""; }; 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.swift; sourceTree = ""; }; @@ -1959,6 +1964,7 @@ A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */, C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */, + 1455ACB12C667BEE004F44F2 /* Deeplink.swift */, DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */, B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */, 43C2FAE01EB656A500364AFF /* GlucoseEffectVelocity.swift */, @@ -2525,9 +2531,9 @@ isa = PBXGroup; children = ( 14B1736E28AEDBF6006CCD7C /* BasalView.swift */, + 1455ACAF2C667A1F004F44F2 /* DeeplinkView.swift */, 1455ACAA2C666F9C004F44F2 /* EventualGlucoseView.swift */, 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */, - 84AA81E22A4A36FB000B658B /* SystemActionLink.swift */, 84AA81E62A4A4DEF000B658B /* PumpView.swift */, ); path = Components; @@ -2547,6 +2553,7 @@ 84AA81D92A4A2966000B658B /* Helpers */ = { isa = PBXGroup; children = ( + 1455ACAC2C6675DF004F44F2 /* Color.swift */, 8496F7302B5711C4003E672C /* ContentMargin.swift */, 84AA81DA2A4A2973000B658B /* Date.swift */, 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */, @@ -3538,8 +3545,8 @@ 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 */, @@ -3548,11 +3555,13 @@ 14B1737D28AEDC6C006CCD7C /* PumpManagerUI.swift in Sources */, 14B1737E28AEDC6C006CCD7C /* FeatureFlags.swift in Sources */, 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */, + 1455ACAD2C6675E1004F44F2 /* Color.swift in Sources */, 14B1737F28AEDC6C006CCD7C /* PluginManager.swift in Sources */, 14B1738028AEDC6C006CCD7C /* Debug.swift in Sources */, 84AA81DB2A4A2973000B658B /* Date.swift in Sources */, 14B1737228AEDBF6006CCD7C /* BasalView.swift in Sources */, 14B1737428AEDBF6006CCD7C /* GlucoseView.swift in Sources */, + 1455ACB02C667A1F004F44F2 /* DeeplinkView.swift in Sources */, 14B1737328AEDBF6006CCD7C /* SystemStatusWidget.swift in Sources */, 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */, 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */, @@ -3744,6 +3753,7 @@ 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 */, diff --git a/Loop/Managers/DeeplinkManager.swift b/Loop/Managers/DeeplinkManager.swift index 86b17f625b..80e3df02b2 100644 --- a/Loop/Managers/DeeplinkManager.swift +++ b/Loop/Managers/DeeplinkManager.swift @@ -8,21 +8,6 @@ import UIKit -enum Deeplink: String, CaseIterable { - case carbEntry = "carb-entry" - case bolus = "manual-bolus" - case preMeal = "pre-meal-preset" - case customPresets = "custom-presets" - - init?(url: URL?) { - guard let url, let host = url.host, let deeplink = Deeplink.allCases.first(where: { $0.rawValue == host }) else { - return nil - } - - self = deeplink - } -} - class DeeplinkManager { private weak var rootViewController: UIViewController? diff --git a/Loop/Models/Deeplink.swift b/Loop/Models/Deeplink.swift new file mode 100644 index 0000000000..b3ccbb4855 --- /dev/null +++ b/Loop/Models/Deeplink.swift @@ -0,0 +1,24 @@ +// +// Deeplink.swift +// Loop +// +// Created by Noah Brauner on 8/9/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation + +enum Deeplink: String, CaseIterable { + case carbEntry = "carb-entry" + case bolus = "manual-bolus" + case preMeal = "pre-meal-preset" + case customPresets = "custom-presets" + + init?(url: URL?) { + guard let url, let host = url.host, let deeplink = Deeplink.allCases.first(where: { $0.rawValue == host }) else { + return nil + } + + self = deeplink + } +} From a30cd125f4fae76f83d6ab469a59a8af405da8a4 Mon Sep 17 00:00:00 2001 From: SwiftlyNoah Date: Fri, 9 Aug 2024 12:46:08 -0400 Subject: [PATCH 150/184] [LOOP-4994] Fix rest of assets --- .../bolus.imageset/Contents.json | 0 .../bolus.imageset/bolus.pdf | Bin .../premeal.imageset/Contents.json | 0 .../premeal.imageset/Pre-Meal.pdf | Bin .../workout.imageset/Contents.json | 0 .../workout.imageset/workout.pdf | Bin 6 files changed, 0 insertions(+), 0 deletions(-) rename Loop Widget Extension/{DefaultAssets.xcassets => DerivedAssetsBase.xcassets}/bolus.imageset/Contents.json (100%) rename Loop Widget Extension/{DefaultAssets.xcassets => DerivedAssetsBase.xcassets}/bolus.imageset/bolus.pdf (100%) rename Loop Widget Extension/{DefaultAssets.xcassets => DerivedAssetsBase.xcassets}/premeal.imageset/Contents.json (100%) rename Loop Widget Extension/{DefaultAssets.xcassets => DerivedAssetsBase.xcassets}/premeal.imageset/Pre-Meal.pdf (100%) rename Loop Widget Extension/{DefaultAssets.xcassets => DerivedAssetsBase.xcassets}/workout.imageset/Contents.json (100%) rename Loop Widget Extension/{DefaultAssets.xcassets => DerivedAssetsBase.xcassets}/workout.imageset/workout.pdf (100%) 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/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 From 2cf145ec09c1bb00ee3e88bc239c9e7399fb7a68 Mon Sep 17 00:00:00 2001 From: Noah Brauner <66573062+SwiftlyNoah@users.noreply.github.com> Date: Fri, 9 Aug 2024 17:24:43 -0400 Subject: [PATCH 151/184] Fix double arrow trend image not appearing on widget (#694) --- .../Components/GlucoseView.swift | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/Loop Widget Extension/Components/GlucoseView.swift b/Loop Widget Extension/Components/GlucoseView.swift index 19abd8db30..332d4afee4 100644 --- a/Loop Widget Extension/Components/GlucoseView.swift +++ b/Loop Widget Extension/Components/GlucoseView.swift @@ -29,8 +29,9 @@ struct GlucoseView: View { .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) } } .foregroundColor(entry.glucoseStatusIsStale ? .staleGray : .primary) @@ -52,25 +53,4 @@ struct GlucoseView: View { } } } - - 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 - } - } } From eea2354cc15d6fd840ff693020fd42242f8639f9 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 9 Aug 2024 15:25:27 -0700 Subject: [PATCH 152/184] [LOOP-4987] Fix color cycle of Loop Status --- LoopUI/Views/LoopStateView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index 508d9a53b8..0b4a64670b 100644 --- a/LoopUI/Views/LoopStateView.swift +++ b/LoopUI/Views/LoopStateView.swift @@ -32,7 +32,7 @@ class WrappedLoopStateViewModel: ObservableObject { struct WrappedLoopCircleView: View { - @ObservedObject var viewModel: WrappedLoopStateViewModel + @StateObject var viewModel: WrappedLoopStateViewModel var body: some View { LoopCircleView(closedLoop: viewModel.closedLoop, freshness: viewModel.freshness, animating: viewModel.animating) From 4775711efa0a41d6be865b825bbd78dcda83b37c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 23 Aug 2024 16:02:01 -0500 Subject: [PATCH 153/184] LOOP-4960 Upload settings on restart (#697) * Upload settings on restart * Comments from review --- Loop/Managers/DeviceDataManager.swift | 6 ++--- Loop/Managers/LoopAppManager.swift | 2 ++ Loop/Managers/LoopDataManager.swift | 3 ++- Loop/Managers/RemoteDataServicesManager.swift | 26 ++++++++----------- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index e113cbf968..6f574133e2 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1263,11 +1263,11 @@ struct CancelTempBasalFailedMaximumBasalRateChangedError: LocalizedError { //MARK: - RemoteDataServicesManagerDelegate protocol conformance extension DeviceDataManager : RemoteDataServicesManagerDelegate { - var shouldSyncToRemoteService: Bool { + var shouldSyncGlucoseToRemoteService: Bool { guard let cgmManager = cgmManager else { - return onboardingManager?.isComplete == true + return true } - return cgmManager.shouldSyncToRemoteService && (onboardingManager?.isComplete == true) + return cgmManager.shouldSyncToRemoteService } } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index f3bd38c373..1d929bb258 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -341,6 +341,8 @@ class LoopAppManager: NSObject { settingsManager.remoteDataServicesManager = remoteDataServicesManager + remoteDataServicesManager.triggerAllUploads() + servicesManager = ServicesManager( pluginManager: pluginManager, alertManager: alertManager, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 0e575eab23..c95c4e8808 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -455,7 +455,8 @@ final class LoopDataManager: ObservableObject { var dosingDecision = StoredDosingDecision( date: loopBaseTime, - reason: "loop" + reason: "loop", + settings: StoredDosingDecision.Settings(settingsProvider.settings) ) do { diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index 097cab00bd..c1d9a3306c 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -187,7 +187,15 @@ 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) @@ -241,8 +249,6 @@ final class RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadAlertData(to remoteDataService: RemoteDataService) { - guard delegate?.shouldSyncToRemoteService == true else { return } - uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .alert) @@ -278,8 +284,6 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadCarbData(to remoteDataService: RemoteDataService) { - guard delegate?.shouldSyncToRemoteService == true else { return } - uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .carb) @@ -322,8 +326,6 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadDoseData(to remoteDataService: RemoteDataService) { - guard delegate?.shouldSyncToRemoteService == true else { return } - uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .dose) @@ -366,8 +368,6 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadDosingDecisionData(to remoteDataService: RemoteDataService) { - guard delegate?.shouldSyncToRemoteService == true else { return } - uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .dosingDecision) @@ -411,7 +411,7 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadGlucoseData(to remoteDataService: RemoteDataService) { - guard delegate?.shouldSyncToRemoteService == true else { return } + guard delegate?.shouldSyncGlucoseToRemoteService != false else { return } uploadGroup.enter() @@ -455,8 +455,6 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadPumpEventData(to remoteDataService: RemoteDataService) { - guard delegate?.shouldSyncToRemoteService == true else { return } - uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .pumpEvent) @@ -499,8 +497,6 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager { private func uploadSettingsData(to remoteDataService: RemoteDataService) { - guard delegate?.shouldSyncToRemoteService == true else { return } - uploadGroup.enter() let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .settings) @@ -653,7 +649,7 @@ extension RemoteDataServicesManager { extension RemoteDataServicesManager: UploadEventListener { } protocol RemoteDataServicesManagerDelegate: AnyObject { - var shouldSyncToRemoteService: Bool { get } + var shouldSyncGlucoseToRemoteService: Bool { get } } From f55dac2bfc98469134d5a438ae9d925dfaa4aeaa Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 27 Aug 2024 11:51:10 -0500 Subject: [PATCH 154/184] LOOP-4769 Premeal Storage (#698) * Store premeal activations in overrides storage * Remove test of old behavior, and cleanup unused code --- Loop/Managers/TemporaryPresetsManager.swift | 18 +++++++---- .../TemporaryPresetsManagerTests.swift | 32 ------------------- 2 files changed, 11 insertions(+), 39 deletions(-) diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index c90463885d..2edbdecb12 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -36,8 +36,10 @@ class TemporaryPresetsManager { scheduleOverride = overrideHistory.activeOverride(at: Date()) - // TODO: Pre-meal is not stored in overrideHistory yet. https://tidepool.atlassian.net/browse/LOOP-4759 - //preMealOverride = overrideHistory.preMealOverride + if scheduleOverride?.context == .preMeal { + preMealOverride = scheduleOverride + scheduleOverride = nil + } overrideIntentObserver = UserDefaults.appGroup?.observe( \.intentExtensionOverrideToSet, @@ -79,6 +81,10 @@ class TemporaryPresetsManager { return } + if scheduleOverride != nil { + preMealOverride = nil + } + if let newValue = scheduleOverride, newValue.context == .preMeal { preconditionFailure("The `scheduleOverride` field should not be used for a pre-meal target range override; use `preMealOverride` instead") } @@ -98,10 +104,6 @@ class TemporaryPresetsManager { } } - if scheduleOverride?.context == .legacyWorkout { - preMealOverride = nil - } - notify(forChange: .preferences) } } @@ -116,10 +118,12 @@ class TemporaryPresetsManager { preconditionFailure("The `preMealOverride` field should be used only for a pre-meal target range override") } - if preMealOverride != nil, scheduleOverride?.context == .legacyWorkout { + if preMealOverride != nil { scheduleOverride = nil } + overrideHistory.recordOverride(preMealOverride) + notify(forChange: .preferences) } } diff --git a/LoopTests/Managers/TemporaryPresetsManagerTests.swift b/LoopTests/Managers/TemporaryPresetsManagerTests.swift index cb79a3878d..492762864f 100644 --- a/LoopTests/Managers/TemporaryPresetsManagerTests.swift +++ b/LoopTests/Managers/TemporaryPresetsManagerTests.swift @@ -34,7 +34,6 @@ class TemporaryPresetsManagerTests: XCTestCase { } func testPreMealOverride() { - var settings = self.settings let preMealStart = Date() manager.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) let actualPreMealRange = manager.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) @@ -42,7 +41,6 @@ class TemporaryPresetsManagerTests: XCTestCase { } func testPreMealOverrideWithPotentialCarbEntry() { - var settings = self.settings let preMealStart = Date() manager.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) let actualRange = manager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: true)?.value(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) @@ -50,7 +48,6 @@ class TemporaryPresetsManagerTests: XCTestCase { } func testScheduleOverride() { - var settings = self.settings let overrideStart = Date() let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) let override = TemporaryScheduleOverride( @@ -69,36 +66,7 @@ class TemporaryPresetsManagerTests: XCTestCase { XCTAssertEqual(actualOverrideRange, overrideTargetRange) } - func testBothPreMealAndScheduleOverride() { - var settings = self.settings - let preMealStart = Date() - manager.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() - ) - manager.scheduleOverride = override - - let actualPreMealRange = manager.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 = manager.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(2 /* hours */ * 60 * 60)) - XCTAssertEqual(preMealRangeDuringOverride, preMealRange) - } - func testScheduleOverrideWithExpiredPreMealOverride() { - var settings = self.settings manager.preMealOverride = TemporaryScheduleOverride( context: .preMeal, settings: TemporaryScheduleOverrideSettings(targetRange: preMealRange), From 4c7978b166f719b1ef4df039fec5de8184c60b7a Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 6 Sep 2024 16:30:11 -0300 Subject: [PATCH 155/184] [LOOP-4945] remove stale glucose timer from CGM status HUD view (#699) * remove stale glucose timer from CGM status HUD view * removed loop status extension * putting glucose value staleness timer in the aggregate class * updating unit tests * response to comments * start staleness timer when app starts --- .../Base.lproj/Localizable.strings | 5 - .../Base.lproj/MainInterface.storyboard | 112 ----- Loop Status Extension/Info.plist | 35 -- .../Loop Status Extension.entitlements | 10 - Loop Status Extension/StateColorPalette.swift | 17 - .../StatusChartsManager.swift | 21 - .../StatusViewController.swift | 319 -------------- .../ar.lproj/InfoPlist.strings | 3 - .../ar.lproj/Localizable.strings | 33 -- .../ar.lproj/MainInterface.strings | 6 - .../da.lproj/InfoPlist.strings | 6 - .../da.lproj/Localizable.strings | 45 -- .../da.lproj/MainInterface.strings | 12 - .../de.lproj/InfoPlist.strings | 6 - .../de.lproj/Localizable.strings | 45 -- .../de.lproj/MainInterface.strings | 12 - .../en.lproj/Localizable.strings | 5 - .../en.lproj/MainInterface.strings | 6 - .../es.lproj/InfoPlist.strings | 6 - .../es.lproj/Localizable.strings | 45 -- .../es.lproj/MainInterface.strings | 12 - .../fi.lproj/InfoPlist.strings | 6 - .../fi.lproj/Localizable.strings | 45 -- .../fi.lproj/MainInterface.strings | 12 - .../fr.lproj/InfoPlist.strings | 6 - .../fr.lproj/Localizable.strings | 45 -- .../fr.lproj/MainInterface.strings | 12 - .../he.lproj/InfoPlist.strings | 6 - .../he.lproj/Localizable.strings | 45 -- .../he.lproj/MainInterface.strings | 12 - .../it.lproj/InfoPlist.strings | 6 - .../it.lproj/Localizable.strings | 45 -- .../it.lproj/MainInterface.strings | 12 - .../ja.lproj/InfoPlist.strings | 3 - .../ja.lproj/Localizable.strings | 33 -- .../ja.lproj/MainInterface.strings | 6 - .../nb.lproj/InfoPlist.strings | 6 - .../nb.lproj/Localizable.strings | 45 -- .../nb.lproj/MainInterface.strings | 12 - .../nl.lproj/InfoPlist.strings | 6 - .../nl.lproj/Localizable.strings | 45 -- .../nl.lproj/MainInterface.strings | 12 - .../pl.lproj/InfoPlist.strings | 6 - .../pl.lproj/Localizable.strings | 45 -- .../pl.lproj/MainInterface.strings | 12 - .../pt-BR.lproj/InfoPlist.strings | 3 - .../pt-BR.lproj/Localizable.strings | 33 -- .../pt-BR.lproj/MainInterface.strings | 6 - .../ro.lproj/InfoPlist.strings | 6 - .../ro.lproj/Localizable.strings | 45 -- .../ro.lproj/MainInterface.strings | 12 - .../ru.lproj/InfoPlist.strings | 6 - .../ru.lproj/Localizable.strings | 45 -- .../ru.lproj/MainInterface.strings | 12 - .../sk.lproj/InfoPlist.strings | 3 - .../sk.lproj/Localizable.strings | 42 -- .../sk.lproj/MainInterface.strings | 12 - .../sv.lproj/InfoPlist.strings | 6 - .../sv.lproj/Localizable.strings | 45 -- .../sv.lproj/MainInterface.strings | 12 - .../tr.lproj/InfoPlist.strings | 6 - .../tr.lproj/Localizable.strings | 45 -- .../tr.lproj/MainInterface.strings | 12 - .../vi.lproj/InfoPlist.strings | 3 - .../vi.lproj/Localizable.strings | 33 -- .../vi.lproj/MainInterface.strings | 6 - .../zh-Hans.lproj/Localizable.strings | 6 - .../zh-Hans.lproj/MainInterface.strings | 6 - Loop.xcodeproj/project.pbxproj | 395 ------------------ Loop/Managers/LoopDataManager.swift | 39 +- .../GlucoseStoreProtocol.swift | 2 + .../StatusTableViewController.swift | 11 +- .../CGMStatusHUDViewModelTests.swift | 81 +--- LoopUI/ViewModel/CGMStatusHUDViewModel.swift | 62 +-- LoopUI/Views/CGMStatusHUDView.swift | 21 +- 75 files changed, 84 insertions(+), 2176 deletions(-) delete mode 100644 Loop Status Extension/Base.lproj/Localizable.strings delete mode 100644 Loop Status Extension/Base.lproj/MainInterface.storyboard delete mode 100644 Loop Status Extension/Info.plist delete mode 100644 Loop Status Extension/Loop Status Extension.entitlements delete mode 100644 Loop Status Extension/StateColorPalette.swift delete mode 100644 Loop Status Extension/StatusChartsManager.swift delete mode 100644 Loop Status Extension/StatusViewController.swift delete mode 100644 Loop Status Extension/ar.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/ar.lproj/Localizable.strings delete mode 100644 Loop Status Extension/ar.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/da.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/da.lproj/Localizable.strings delete mode 100644 Loop Status Extension/da.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/de.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/de.lproj/Localizable.strings delete mode 100644 Loop Status Extension/de.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/en.lproj/Localizable.strings delete mode 100644 Loop Status Extension/en.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/es.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/es.lproj/Localizable.strings delete mode 100644 Loop Status Extension/es.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/fi.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/fi.lproj/Localizable.strings delete mode 100644 Loop Status Extension/fi.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/fr.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/fr.lproj/Localizable.strings delete mode 100644 Loop Status Extension/fr.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/he.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/he.lproj/Localizable.strings delete mode 100644 Loop Status Extension/he.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/it.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/it.lproj/Localizable.strings delete mode 100644 Loop Status Extension/it.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/ja.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/ja.lproj/Localizable.strings delete mode 100644 Loop Status Extension/ja.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/nb.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/nb.lproj/Localizable.strings delete mode 100644 Loop Status Extension/nb.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/nl.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/nl.lproj/Localizable.strings delete mode 100644 Loop Status Extension/nl.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/pl.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/pl.lproj/Localizable.strings delete mode 100644 Loop Status Extension/pl.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/pt-BR.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/pt-BR.lproj/Localizable.strings delete mode 100644 Loop Status Extension/pt-BR.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/ro.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/ro.lproj/Localizable.strings delete mode 100644 Loop Status Extension/ro.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/ru.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/ru.lproj/Localizable.strings delete mode 100644 Loop Status Extension/ru.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/sk.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/sk.lproj/Localizable.strings delete mode 100644 Loop Status Extension/sk.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/sv.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/sv.lproj/Localizable.strings delete mode 100644 Loop Status Extension/sv.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/tr.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/tr.lproj/Localizable.strings delete mode 100644 Loop Status Extension/tr.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/vi.lproj/InfoPlist.strings delete mode 100644 Loop Status Extension/vi.lproj/Localizable.strings delete mode 100644 Loop Status Extension/vi.lproj/MainInterface.strings delete mode 100644 Loop Status Extension/zh-Hans.lproj/Localizable.strings delete mode 100644 Loop Status Extension/zh-Hans.lproj/MainInterface.strings diff --git a/Loop Status Extension/Base.lproj/Localizable.strings b/Loop Status Extension/Base.lproj/Localizable.strings deleted file mode 100644 index d21551845d..0000000000 --- a/Loop Status Extension/Base.lproj/Localizable.strings +++ /dev/null @@ -1,5 +0,0 @@ -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Eventually %1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; diff --git a/Loop Status Extension/Base.lproj/MainInterface.storyboard b/Loop Status Extension/Base.lproj/MainInterface.storyboard deleted file mode 100644 index 78d5e1c465..0000000000 --- a/Loop Status Extension/Base.lproj/MainInterface.storyboard +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Loop Status Extension/Info.plist b/Loop Status Extension/Info.plist deleted file mode 100644 index 98c5c3e989..0000000000 --- a/Loop Status Extension/Info.plist +++ /dev/null @@ -1,35 +0,0 @@ - - - - - AppGroupIdentifier - $(APP_GROUP_IDENTIFIER) - CFBundleDevelopmentRegion - en - CFBundleDisplayName - $(MAIN_APP_DISPLAY_NAME) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleShortVersionString - $(LOOP_MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - MainAppBundleIdentifier - $(MAIN_APP_BUNDLE_IDENTIFIER) - NSExtension - - NSExtensionMainStoryboard - MainInterface - NSExtensionPointIdentifier - com.apple.widget-extension - - - diff --git a/Loop Status Extension/Loop Status Extension.entitlements b/Loop Status Extension/Loop Status Extension.entitlements deleted file mode 100644 index d9849a816d..0000000000 --- a/Loop Status Extension/Loop Status Extension.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - $(APP_GROUP_IDENTIFIER) - - - diff --git a/Loop Status Extension/StateColorPalette.swift b/Loop Status Extension/StateColorPalette.swift deleted file mode 100644 index e6f18b436a..0000000000 --- a/Loop Status Extension/StateColorPalette.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// StateColorPalette.swift -// Loop -// -// Copyright © 2017 LoopKit Authors. All rights reserved. -// - -import LoopUI -import LoopKitUI - -extension StateColorPalette { - static let loopStatus = StateColorPalette(unknown: .unknownColor, normal: .freshColor, warning: .agingColor, error: .staleColor) - - static let cgmStatus = loopStatus - - static let pumpStatus = StateColorPalette(unknown: .unknownColor, normal: .pumpStatusNormal, warning: .agingColor, error: .staleColor) -} diff --git a/Loop Status Extension/StatusChartsManager.swift b/Loop Status Extension/StatusChartsManager.swift deleted file mode 100644 index c75041e52f..0000000000 --- a/Loop Status Extension/StatusChartsManager.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// StatusChartsManager.swift -// Loop Status Extension -// -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import Foundation -import LoopUI -import LoopKitUI -import SwiftCharts -import UIKit - -class StatusChartsManager: ChartsManager { - let predictedGlucose = PredictedGlucoseChart(predictedGlucoseBounds: FeatureFlags.predictedGlucoseChartClampEnabled ? .default : nil, - yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil) - - init(colors: ChartColorPalette, settings: ChartSettings, traitCollection: UITraitCollection) { - super.init(colors: colors, settings: settings, charts: [predictedGlucose], traitCollection: traitCollection) - } -} diff --git a/Loop Status Extension/StatusViewController.swift b/Loop Status Extension/StatusViewController.swift deleted file mode 100644 index 21a4ada94b..0000000000 --- a/Loop Status Extension/StatusViewController.swift +++ /dev/null @@ -1,319 +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 -import LoopAlgorithm - -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, - 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] = [] - - 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: LoopAlgorithm.inputDataRecencyInterval, - glucoseDisplay: context.glucoseDisplay, - wasUserEntered: lastGlucose.wasUserEntered, - isDisplayOnly: lastGlucose.isDisplayOnly - ) - } - - // Charts - self.charts.predictedGlucose.glucoseUnit = unit - self.charts.predictedGlucose.setGlucoseValues(glucose) - - if let predictedGlucose = context.predictedGlucose?.samples, context.isClosedLoop == true { - self.charts.predictedGlucose.setPredictedGlucoseValues(predictedGlucose) - } else { - self.charts.predictedGlucose.setPredictedGlucoseValues([]) - } - - self.charts.predictedGlucose.targetGlucoseSchedule = self.settingsStore.latestSettings?.glucoseTargetRangeSchedule - self.charts.invalidateChart(atIndex: 0) - self.charts.prerender() - self.glucoseChartContentView.reloadChart() - } - - switch extensionContext?.widgetActiveDisplayMode ?? .compact { - case .expanded: - glucoseChartContentView.isHidden = false - case .compact: - fallthrough - @unknown default: - glucoseChartContentView.isHidden = true - } - - // Right now we always act as if there's new data. - // TODO: keep track of data changes and return .noData if necessary - return NCUpdateResult.newData - } -} diff --git a/Loop Status Extension/ar.lproj/InfoPlist.strings b/Loop Status Extension/ar.lproj/InfoPlist.strings deleted file mode 100644 index 034a1e1f6a..0000000000 --- a/Loop Status Extension/ar.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - diff --git a/Loop Status Extension/ar.lproj/Localizable.strings b/Loop Status Extension/ar.lproj/Localizable.strings deleted file mode 100644 index 5935bf3282..0000000000 --- a/Loop Status Extension/ar.lproj/Localizable.strings +++ /dev/null @@ -1,33 +0,0 @@ -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "كارب النشط"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "أنسولين نشط"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "متوقع %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "أنسولين نشط %1$@ وحدة"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "وحدة"; - diff --git a/Loop Status Extension/ar.lproj/MainInterface.strings b/Loop Status Extension/ar.lproj/MainInterface.strings deleted file mode 100644 index 23ec628122..0000000000 --- a/Loop Status Extension/ar.lproj/MainInterface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "كارب النشط"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "أنسولين نشط"; - diff --git a/Loop Status Extension/da.lproj/InfoPlist.strings b/Loop Status Extension/da.lproj/InfoPlist.strings deleted file mode 100644 index ffe563a634..0000000000 --- a/Loop Status Extension/da.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop-statusudvidelse"; - diff --git a/Loop Status Extension/da.lproj/Localizable.strings b/Loop Status Extension/da.lproj/Localizable.strings deleted file mode 100644 index 4388492489..0000000000 --- a/Loop Status Extension/da.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? E"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ E"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktive kulhydrater"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktivt insulin"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Med tiden %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ E"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "E"; - diff --git a/Loop Status Extension/da.lproj/MainInterface.strings b/Loop Status Extension/da.lproj/MainInterface.strings deleted file mode 100644 index ca088fa3ce..0000000000 --- a/Loop Status Extension/da.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktive kulhydrater"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktivt insulin"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 E"; - diff --git a/Loop Status Extension/de.lproj/InfoPlist.strings b/Loop Status Extension/de.lproj/InfoPlist.strings deleted file mode 100644 index 8a7abf7ee4..0000000000 --- a/Loop Status Extension/de.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status-Erweiterung"; - diff --git a/Loop Status Extension/de.lproj/Localizable.strings b/Loop Status Extension/de.lproj/Localizable.strings deleted file mode 100644 index 196ef74140..0000000000 --- a/Loop Status Extension/de.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? IE"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ IE"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktive KH"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktives Insulin"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Voraussichtlich %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ IE"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "IE"; - diff --git a/Loop Status Extension/de.lproj/MainInterface.strings b/Loop Status Extension/de.lproj/MainInterface.strings deleted file mode 100644 index fb0ae387e6..0000000000 --- a/Loop Status Extension/de.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktive KH"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktives Insulin"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 IE"; - diff --git a/Loop Status Extension/en.lproj/Localizable.strings b/Loop Status Extension/en.lproj/Localizable.strings deleted file mode 100644 index d21551845d..0000000000 --- a/Loop Status Extension/en.lproj/Localizable.strings +++ /dev/null @@ -1,5 +0,0 @@ -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Eventually %1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; diff --git a/Loop Status Extension/en.lproj/MainInterface.strings b/Loop Status Extension/en.lproj/MainInterface.strings deleted file mode 100644 index 3a52b2e5e2..0000000000 --- a/Loop Status Extension/en.lproj/MainInterface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Eventually 92 mg/dL"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "IOB 1.0 U"; - diff --git a/Loop Status Extension/es.lproj/InfoPlist.strings b/Loop Status Extension/es.lproj/InfoPlist.strings deleted file mode 100644 index 029eaa2d2a..0000000000 --- a/Loop Status Extension/es.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Extensión de Estado de Loop"; - diff --git a/Loop Status Extension/es.lproj/Localizable.strings b/Loop Status Extension/es.lproj/Localizable.strings deleted file mode 100644 index a893db7399..0000000000 --- a/Loop Status Extension/es.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? gr"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? U"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ U"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Carbohidratos Activos"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Insulina activa"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Eventualmente %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/es.lproj/MainInterface.strings b/Loop Status Extension/es.lproj/MainInterface.strings deleted file mode 100644 index 5354b0e9c3..0000000000 --- a/Loop Status Extension/es.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Carbohidratos Activos"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 gr"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Insulina activa"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 U"; - diff --git a/Loop Status Extension/fi.lproj/InfoPlist.strings b/Loop Status Extension/fi.lproj/InfoPlist.strings deleted file mode 100644 index 1565e025fa..0000000000 --- a/Loop Status Extension/fi.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extension"; - diff --git a/Loop Status Extension/fi.lproj/Localizable.strings b/Loop Status Extension/fi.lproj/Localizable.strings deleted file mode 100644 index af5d51baf2..0000000000 --- a/Loop Status Extension/fi.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? U"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ U"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Akt. hiilari"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Akt. insuliini"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Ennuste %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/fi.lproj/MainInterface.strings b/Loop Status Extension/fi.lproj/MainInterface.strings deleted file mode 100644 index a1e847d468..0000000000 --- a/Loop Status Extension/fi.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Akt. hiilari"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Akt. insuliini"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 U"; - diff --git a/Loop Status Extension/fr.lproj/InfoPlist.strings b/Loop Status Extension/fr.lproj/InfoPlist.strings deleted file mode 100644 index 1565e025fa..0000000000 --- a/Loop Status Extension/fr.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extension"; - diff --git a/Loop Status Extension/fr.lproj/Localizable.strings b/Loop Status Extension/fr.lproj/Localizable.strings deleted file mode 100644 index 1c6e8dfb18..0000000000 --- a/Loop Status Extension/fr.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? U"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ U"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Glucides actifs"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Insuline active"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Finalement %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/fr.lproj/MainInterface.strings b/Loop Status Extension/fr.lproj/MainInterface.strings deleted file mode 100644 index 4d13ebda2d..0000000000 --- a/Loop Status Extension/fr.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Glucides actifs"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Insuline active"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 U"; - diff --git a/Loop Status Extension/he.lproj/InfoPlist.strings b/Loop Status Extension/he.lproj/InfoPlist.strings deleted file mode 100644 index 1565e025fa..0000000000 --- a/Loop Status Extension/he.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extension"; - diff --git a/Loop Status Extension/he.lproj/Localizable.strings b/Loop Status Extension/he.lproj/Localizable.strings deleted file mode 100644 index 27db2c87a8..0000000000 --- a/Loop Status Extension/he.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? U"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "U %1$@"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "פחמימות פעילות"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "אינסולין פעיל"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "בדרך ל-%1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/he.lproj/MainInterface.strings b/Loop Status Extension/he.lproj/MainInterface.strings deleted file mode 100644 index 7bb2ea5747..0000000000 --- a/Loop Status Extension/he.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "פחמימות פעילות"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "אינסולין פעיל"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "U 0"; - diff --git a/Loop Status Extension/it.lproj/InfoPlist.strings b/Loop Status Extension/it.lproj/InfoPlist.strings deleted file mode 100644 index da11eb5a77..0000000000 --- a/Loop Status Extension/it.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Estensione dello stato di funzionamento di Loop"; - diff --git a/Loop Status Extension/it.lproj/Localizable.strings b/Loop Status Extension/it.lproj/Localizable.strings deleted file mode 100644 index 871ef62d8c..0000000000 --- a/Loop Status Extension/it.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? U"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ U"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ contro %2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Carb Attivi"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Insulina attiva"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Probabile Glic. %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/it.lproj/MainInterface.strings b/Loop Status Extension/it.lproj/MainInterface.strings deleted file mode 100644 index ab9c005998..0000000000 --- a/Loop Status Extension/it.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Carb Attivi"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Insulina attiva"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 U"; - diff --git a/Loop Status Extension/ja.lproj/InfoPlist.strings b/Loop Status Extension/ja.lproj/InfoPlist.strings deleted file mode 100644 index bb232bb4cc..0000000000 --- a/Loop Status Extension/ja.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "ループ"; - diff --git a/Loop Status Extension/ja.lproj/Localizable.strings b/Loop Status Extension/ja.lproj/Localizable.strings deleted file mode 100644 index d328a81f35..0000000000 --- a/Loop Status Extension/ja.lproj/Localizable.strings +++ /dev/null @@ -1,33 +0,0 @@ -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "残存糖質"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "残存インスリン"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "予想 %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/ja.lproj/MainInterface.strings b/Loop Status Extension/ja.lproj/MainInterface.strings deleted file mode 100644 index 2407f97e64..0000000000 --- a/Loop Status Extension/ja.lproj/MainInterface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "残存糖質"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "残存インスリン"; - diff --git a/Loop Status Extension/nb.lproj/InfoPlist.strings b/Loop Status Extension/nb.lproj/InfoPlist.strings deleted file mode 100644 index 24d50f5390..0000000000 --- a/Loop Status Extension/nb.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Utvidelse av Loop status"; - diff --git a/Loop Status Extension/nb.lproj/Localizable.strings b/Loop Status Extension/nb.lproj/Localizable.strings deleted file mode 100644 index 2e4a88ce5f..0000000000 --- a/Loop Status Extension/nb.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? E"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ E"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v %2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktive karbohydrater"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktivt insulin"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Omsider %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ E"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "E"; - diff --git a/Loop Status Extension/nb.lproj/MainInterface.strings b/Loop Status Extension/nb.lproj/MainInterface.strings deleted file mode 100644 index 7942de07be..0000000000 --- a/Loop Status Extension/nb.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktive karbohydrater"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktivt insulin"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 E"; - diff --git a/Loop Status Extension/nl.lproj/InfoPlist.strings b/Loop Status Extension/nl.lproj/InfoPlist.strings deleted file mode 100644 index 62e5156f17..0000000000 --- a/Loop Status Extension/nl.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extensie"; - diff --git a/Loop Status Extension/nl.lproj/Localizable.strings b/Loop Status Extension/nl.lproj/Localizable.strings deleted file mode 100644 index b5f9439380..0000000000 --- a/Loop Status Extension/nl.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? E"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ E"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Actieve Koolhydraten"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Actieve Insuline"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Uiteindelijk %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ E"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "E"; - diff --git a/Loop Status Extension/nl.lproj/MainInterface.strings b/Loop Status Extension/nl.lproj/MainInterface.strings deleted file mode 100644 index 3300ee0aec..0000000000 --- a/Loop Status Extension/nl.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Actieve Koolhydraten"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Actieve Insuline"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 E"; - diff --git a/Loop Status Extension/pl.lproj/InfoPlist.strings b/Loop Status Extension/pl.lproj/InfoPlist.strings deleted file mode 100644 index 1565e025fa..0000000000 --- a/Loop Status Extension/pl.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extension"; - diff --git a/Loop Status Extension/pl.lproj/Localizable.strings b/Loop Status Extension/pl.lproj/Localizable.strings deleted file mode 100644 index 9f9cab187f..0000000000 --- a/Loop Status Extension/pl.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? J"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ J"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktywne węglowodany"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktywna insulina"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Docelowo %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ J"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dl"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "J"; - diff --git a/Loop Status Extension/pl.lproj/MainInterface.strings b/Loop Status Extension/pl.lproj/MainInterface.strings deleted file mode 100644 index 137aac2c3c..0000000000 --- a/Loop Status Extension/pl.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktywne węglowodany"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktywna insulina"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 J"; - diff --git a/Loop Status Extension/pt-BR.lproj/InfoPlist.strings b/Loop Status Extension/pt-BR.lproj/InfoPlist.strings deleted file mode 100644 index 034a1e1f6a..0000000000 --- a/Loop Status Extension/pt-BR.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - diff --git a/Loop Status Extension/pt-BR.lproj/Localizable.strings b/Loop Status Extension/pt-BR.lproj/Localizable.strings deleted file mode 100644 index ed1ddc8056..0000000000 --- a/Loop Status Extension/pt-BR.lproj/Localizable.strings +++ /dev/null @@ -1,33 +0,0 @@ -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Carboidratos Ativos"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Insulina Ativa"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Eventualmente %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/pt-BR.lproj/MainInterface.strings b/Loop Status Extension/pt-BR.lproj/MainInterface.strings deleted file mode 100644 index 09c2331507..0000000000 --- a/Loop Status Extension/pt-BR.lproj/MainInterface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Carboidratos Ativos"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Insulina Ativa"; - diff --git a/Loop Status Extension/ro.lproj/InfoPlist.strings b/Loop Status Extension/ro.lproj/InfoPlist.strings deleted file mode 100644 index 811f60ffd2..0000000000 --- a/Loop Status Extension/ro.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Extensie stare Loop"; - diff --git a/Loop Status Extension/ro.lproj/Localizable.strings b/Loop Status Extension/ro.lproj/Localizable.strings deleted file mode 100644 index e749a36e8e..0000000000 --- a/Loop Status Extension/ro.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? U"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ U"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Carbohidrați activi"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Insulină activă"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Eventually %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@%2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/ro.lproj/MainInterface.strings b/Loop Status Extension/ro.lproj/MainInterface.strings deleted file mode 100644 index 52df0e4c8c..0000000000 --- a/Loop Status Extension/ro.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Carbohidrați activi"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Insulină activă"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 U"; - diff --git a/Loop Status Extension/ru.lproj/InfoPlist.strings b/Loop Status Extension/ru.lproj/InfoPlist.strings deleted file mode 100644 index 1565e025fa..0000000000 --- a/Loop Status Extension/ru.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extension"; - diff --git a/Loop Status Extension/ru.lproj/Localizable.strings b/Loop Status Extension/ru.lproj/Localizable.strings deleted file mode 100644 index 590b1893da..0000000000 --- a/Loop Status Extension/ru.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? г"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? ед."; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ Ед"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ версии %2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Активные углеводы"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Активный инсулин"; - -/* The short unit display string for decibles */ -"dB" = "дБ"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "В конечном итоге %1$@"; - -/* The short unit display string for grams */ -"g" = "г"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ ед"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "мг/дл"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "ммоль/л"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "ед"; - diff --git a/Loop Status Extension/ru.lproj/MainInterface.strings b/Loop Status Extension/ru.lproj/MainInterface.strings deleted file mode 100644 index 7a44069cbe..0000000000 --- a/Loop Status Extension/ru.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Активные углеводы"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 г"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Активный инсулин"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 ед."; - diff --git a/Loop Status Extension/sk.lproj/InfoPlist.strings b/Loop Status Extension/sk.lproj/InfoPlist.strings deleted file mode 100644 index 034a1e1f6a..0000000000 --- a/Loop Status Extension/sk.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - diff --git a/Loop Status Extension/sk.lproj/Localizable.strings b/Loop Status Extension/sk.lproj/Localizable.strings deleted file mode 100644 index f7fe0850f1..0000000000 --- a/Loop Status Extension/sk.lproj/Localizable.strings +++ /dev/null @@ -1,42 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? j"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%@ j"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v %2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktívne sacharidy"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktívny inzulín"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ j"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "j"; - diff --git a/Loop Status Extension/sk.lproj/MainInterface.strings b/Loop Status Extension/sk.lproj/MainInterface.strings deleted file mode 100644 index e249f99412..0000000000 --- a/Loop Status Extension/sk.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktívne sacharidy"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktívny inzulín"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 j"; - diff --git a/Loop Status Extension/sv.lproj/InfoPlist.strings b/Loop Status Extension/sv.lproj/InfoPlist.strings deleted file mode 100644 index 1565e025fa..0000000000 --- a/Loop Status Extension/sv.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Status Extension"; - diff --git a/Loop Status Extension/sv.lproj/Localizable.strings b/Loop Status Extension/sv.lproj/Localizable.strings deleted file mode 100644 index fb3f8b00e7..0000000000 --- a/Loop Status Extension/sv.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? g"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? E"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ E"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktiva kolhydrater"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktivt insulin"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Förväntat %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ E"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dl"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "E"; - diff --git a/Loop Status Extension/sv.lproj/MainInterface.strings b/Loop Status Extension/sv.lproj/MainInterface.strings deleted file mode 100644 index afc966ed37..0000000000 --- a/Loop Status Extension/sv.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktiva kolhydrater"; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 g"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktivt insulin"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 E"; - diff --git a/Loop Status Extension/tr.lproj/InfoPlist.strings b/Loop Status Extension/tr.lproj/InfoPlist.strings deleted file mode 100644 index a67e46ff7e..0000000000 --- a/Loop Status Extension/tr.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - -/* Bundle name */ -"CFBundleName" = "Loop Durum Uzantısı"; - diff --git a/Loop Status Extension/tr.lproj/Localizable.strings b/Loop Status Extension/tr.lproj/Localizable.strings deleted file mode 100644 index 0f5ebe9125..0000000000 --- a/Loop Status Extension/tr.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* Displayed in the widget when the amount of active carbs cannot be determined. */ -"? g" = "? gr"; - -/* Displayed in the widget when the amount of active insulin cannot be determined. */ -"? U" = "? Ü"; - -/* The subtitle format describing the grams of active carbs. (1: localized carb value description) */ -"%1$@" = "%1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"%1$@ U" = "%1$@ Ü"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Aktif Karb."; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Aktif İnsülin"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Nihai KŞ %1$@"; - -/* The short unit display string for grams */ -"g" = "gr"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "AİNS %1$@ Ü"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "Ü"; - diff --git a/Loop Status Extension/tr.lproj/MainInterface.strings b/Loop Status Extension/tr.lproj/MainInterface.strings deleted file mode 100644 index de7b3fc545..0000000000 --- a/Loop Status Extension/tr.lproj/MainInterface.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Aktif Karb."; - -/* Class = "UILabel"; text = "0 g"; ObjectID = "dPp-lJ-5sh"; */ -"dPp-lJ-5sh.text" = "0 gr"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Aktif İnsülin"; - -/* Class = "UILabel"; text = "0 U"; ObjectID = "Vgf-p1-2QP"; */ -"Vgf-p1-2QP.text" = "0 Ü"; - diff --git a/Loop Status Extension/vi.lproj/InfoPlist.strings b/Loop Status Extension/vi.lproj/InfoPlist.strings deleted file mode 100644 index 034a1e1f6a..0000000000 --- a/Loop Status Extension/vi.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* Bundle display name */ -"CFBundleDisplayName" = "Loop"; - diff --git a/Loop Status Extension/vi.lproj/Localizable.strings b/Loop Status Extension/vi.lproj/Localizable.strings deleted file mode 100644 index a0b94d6a7f..0000000000 --- a/Loop Status Extension/vi.lproj/Localizable.strings +++ /dev/null @@ -1,33 +0,0 @@ -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Widget label title describing the active carbs */ -"Active Carbs" = "Lượng Carbs còn hoạt động"; - -/* Widget label title describing the active insulin */ -"Active Insulin" = "Lượng Insulin còn hoạt động"; - -/* The short unit display string for decibles */ -"dB" = "dB"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "Kết quả là %1$@"; - -/* The short unit display string for grams */ -"g" = "g"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ U"; - -/* The short unit display string for milligrams of glucose per decilter */ -"mg/dL" = "mg/dL"; - -/* The short unit display string for millimoles of glucose per liter */ -"mmol/L" = "mmol/L"; - -/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ -"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; - -/* The short unit display string for international units of insulin */ -"U" = "U"; - diff --git a/Loop Status Extension/vi.lproj/MainInterface.strings b/Loop Status Extension/vi.lproj/MainInterface.strings deleted file mode 100644 index c766b97e1b..0000000000 --- a/Loop Status Extension/vi.lproj/MainInterface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "Lượng Carbs còn hoạt động"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "Lượng Insulin còn hoạt động"; - diff --git a/Loop Status Extension/zh-Hans.lproj/Localizable.strings b/Loop Status Extension/zh-Hans.lproj/Localizable.strings deleted file mode 100644 index b1d62cfb8c..0000000000 --- a/Loop Status Extension/zh-Hans.lproj/Localizable.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %1$@" = "最终 %1$@"; - -/* The subtitle format describing units of active insulin. (1: localized insulin value description) */ -"IOB %1$@ U" = "IOB %1$@ 单位"; - diff --git a/Loop Status Extension/zh-Hans.lproj/MainInterface.strings b/Loop Status Extension/zh-Hans.lproj/MainInterface.strings deleted file mode 100644 index 2a063e6084..0000000000 --- a/Loop Status Extension/zh-Hans.lproj/MainInterface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "UILabel"; text = "Active Carbs"; ObjectID = "9iF-xY-Bh4"; */ -"9iF-xY-Bh4.text" = "最终血糖为92 毫克/分升"; - -/* Class = "UILabel"; text = "Active Insulin"; ObjectID = "UPi-dG-yYD"; */ -"UPi-dG-yYD.text" = "IOB 1.0 单位"; - diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 0f2fa3feb3..3195a8fd62 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -63,10 +63,8 @@ 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 */; }; @@ -108,7 +106,6 @@ 4344628220A7A37F00C4BE6F /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628120A7A37E00C4BE6F /* CoreBluetooth.framework */; }; 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4344629120A7C19800C4BE6F /* ButtonGroup.swift */; }; 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; - 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 */; }; @@ -162,12 +159,10 @@ 43BFF0B51E45C1E700FF19A9 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; 43BFF0B71E45C20C00FF19A9 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; 43BFF0C61E465A4400FF19A9 /* UIColor+HIG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */; }; - 43BFF0CD1E466C8400FF19A9 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0CC1E466C8400FF19A9 /* StateColorPalette.swift */; }; 43C05CA821EB2B26006FB252 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431E73471FF95A900069B5F7 /* PersistenceController.swift */; }; 43C05CA921EB2B26006FB252 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431E73471FF95A900069B5F7 /* PersistenceController.swift */; }; 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; - 43C05CAC21EB2B8B006FB252 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */; }; 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; 43C05CB221EBD88A006FB252 /* LoopCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9002A21EB209400AF44BF /* LoopCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -193,7 +188,6 @@ 43DBF0531C93EC8200B3C386 /* DeviceDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DBF0521C93EC8200B3C386 /* DeviceDataManager.swift */; }; 43DFB62320D4CAE7008A7BAE /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */; }; 43E3449F1B9D68E900C85C07 /* StatusTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E3449E1B9D68E900C85C07 /* StatusTableViewController.swift */; }; - 43E93FB51E4675E800EAB8DB /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; 43E93FB61E469A4000EAB8DB /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 43F41C371D3BF32400C11ED6 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F41C361D3BF32400C11ED6 /* UIAlertController.swift */; }; @@ -203,7 +197,6 @@ 43FCBBC21E51710B00343C1B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 43776F9A1B8022E90074EA36 /* LaunchScreen.storyboard */; }; 43FCEEA9221A615B0013DD30 /* StatusChartsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */; }; 43FCEEAD221A66780013DD30 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FCEEAC221A66780013DD30 /* DateFormatter.swift */; }; - 43FCEEB1221A863E0013DD30 /* StatusChartsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FCEEB0221A863E0013DD30 /* StatusChartsManager.swift */; }; 4B60626C287E286000BF8BBB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4B60626A287E286000BF8BBB /* Localizable.strings */; }; 4B60626D287E286000BF8BBB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4B60626A287E286000BF8BBB /* Localizable.strings */; }; 4B67E2C8289B4EDB002D92AF /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4B67E2C6289B4EDB002D92AF /* InfoPlist.strings */; }; @@ -215,7 +208,6 @@ 4F2C15741E0209F500E160D4 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 4F2C15811E0495B200E160D4 /* WatchContext+WatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2C15801E0495B200E160D4 /* WatchContext+WatchApp.swift */; }; 4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; - 4F2C15831E0757E600E160D4 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 4F2C15851E075B8700E160D4 /* LoopUI.h in Headers */ = {isa = PBXBuildFile; fileRef = 4F75288D1DFE1DC600C322D6 /* LoopUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; 4F2C15931E09BF2C00E160D4 /* HUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2C15921E09BF2C00E160D4 /* HUDView.swift */; }; 4F2C15951E09BF3C00E160D4 /* HUDView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15941E09BF3C00E160D4 /* HUDView.xib */; }; @@ -223,11 +215,7 @@ 4F2C159A1E0C9E5600E160D4 /* LoopUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 4F526D611DF8D9A900A04910 /* NetBasal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D601DF8D9A900A04910 /* NetBasal.swift */; }; 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6663931E905FD2009E74FC /* ChartColorPalette+Loop.swift */; }; - 4F70C1E11DE8DCA7006380B7 /* StatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C1E01DE8DCA7006380B7 /* StatusViewController.swift */; }; - 4F70C1E41DE8DCA7006380B7 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4F70C1E21DE8DCA7006380B7 /* MainInterface.storyboard */; }; - 4F70C1E81DE8DCA7006380B7 /* Loop Status Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 4F70C1DC1DE8DCA7006380B7 /* Loop Status Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4F70C2101DE8FAC5006380B7 /* ExtensionDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */; }; - 4F70C2121DE900EA006380B7 /* StatusExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */; }; 4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */; }; 4F7528941DFE1E9500C322D6 /* LoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; }; 4F75289A1DFE1F6000C322D6 /* BasalRateHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEBF1CD6FCD8003C8C80 /* BasalRateHUDView.swift */; }; @@ -242,16 +230,13 @@ 4F7E8AC720E2AC0300AEA65E /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; 4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; 4F82655020E69F9A0031A8F5 /* HUDInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */; }; - 4FAC02541E22F6B20087A773 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */; }; - 4FC8C8021DEB943800A1452E /* NSUserDefaults+StatusExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */; }; 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */; }; 4FF4D0F81E1725B000846527 /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434F54561D287FDB002A9274 /* NibLoadable.swift */; }; 4FF4D1001E18374700846527 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; 4FF4D1011E18375000846527 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; 63F5E17C297DDF3900A62D4B /* ckcomplication.strings in Resources */ = {isa = PBXBuildFile; fileRef = 63F5E17A297DDF3900A62D4B /* ckcomplication.strings */; }; 7D23667D21250C7E0028B67D /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D23667C21250C7E0028B67D /* LocalizedString.swift */; }; - 7D7076351FE06EDE004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076371FE06EDE004AC8EA /* Localizable.strings */; }; 7D7076451FE06EE0004AC8EA /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076471FE06EE0004AC8EA /* InfoPlist.strings */; }; 7D70764A1FE06EE1004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D70764C1FE06EE1004AC8EA /* Localizable.strings */; }; 7D70764F1FE06EE1004AC8EA /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076511FE06EE1004AC8EA /* InfoPlist.strings */; }; @@ -324,8 +309,6 @@ 89F9119424358E4500ECCAF3 /* CarbAbsorptionTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9119324358E4500ECCAF3 /* CarbAbsorptionTime.swift */; }; 89F9119624358E6900ECCAF3 /* BolusPickerValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9119524358E6900ECCAF3 /* BolusPickerValues.swift */; }; 89FE21AD24AC57E30033F501 /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FE21AC24AC57E30033F501 /* Collection.swift */; }; - A90EF53C25DEF06200F32D61 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; - A90EF54425DEF0A000F32D61 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91D2A3E26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift */; }; A91E4C2124F867A700BE9213 /* StoredAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91E4C2024F867A700BE9213 /* StoredAlertTests.swift */; }; A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */; }; @@ -379,7 +362,6 @@ B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4001CED28CBBC82002FB414 /* AlertManagementView.swift */; }; B405E35924D2A75B00DD058D /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */; }; B405E35A24D2B1A400DD058D /* HUDAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */; }; - B405E35B24D2E05600DD058D /* HUDAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */; }; B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */; }; B42C951424A3C76000857C73 /* CGMStatusHUDViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */; }; B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42D124228D371C400E43D22 /* AlertMuter.swift */; }; @@ -392,7 +374,6 @@ 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 */; }; @@ -414,7 +395,6 @@ B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */; }; C1004DF22981F5B700B8CF94 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1004DF02981F5B700B8CF94 /* InfoPlist.strings */; }; C1004DF52981F5B700B8CF94 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1004DF32981F5B700B8CF94 /* Localizable.strings */; }; - C1004DF82981F5B700B8CF94 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1004DF62981F5B700B8CF94 /* InfoPlist.strings */; }; C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C110888C2A3913C600BA4898 /* BuildDetails.swift */; }; C11613492983096D00777E7C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C11613472983096D00777E7C /* InfoPlist.strings */; }; C116134C2983096D00777E7C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C116134A2983096D00777E7C /* Localizable.strings */; }; @@ -434,10 +414,6 @@ 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 */; }; C15A8C492A7305B1009D736B /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; }; C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */; }; @@ -488,7 +464,6 @@ C1C660D1252E4DD5009B5C32 /* LoopConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C660D0252E4DD5009B5C32 /* LoopConstants.swift */; }; C1C73F0D1DE3D0270022FC89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1C73F0F1DE3D0270022FC89 /* InfoPlist.strings */; }; C1CCF1122858FA900035389C /* LoopCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */; }; - C1CCF1172858FBAD0035389C /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1CCF1162858FBAD0035389C /* SwiftCharts */; }; C1D0B6302986D4D90098D215 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0B62F2986D4D90098D215 /* LocalizedString.swift */; }; C1D0B6312986D4D90098D215 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0B62F2986D4D90098D215 /* LocalizedString.swift */; }; C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */; }; @@ -512,9 +487,7 @@ C1F7822627CC056900C0919A /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F7822527CC056900C0919A /* SettingsManager.swift */; }; C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */; }; C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428B217806A300FAB378 /* StateColorPalette.swift */; }; - C1FB428D21791D2500FAB378 /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */; }; C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; - C1FB4290217922A100FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */; }; DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */; }; DDC389F82A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */; }; @@ -614,13 +587,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 */; @@ -635,13 +601,6 @@ remoteGlobalIDString = 43D9001A21EB209400AF44BF; remoteInfo = "LoopCore-watchOS"; }; - C11B9D582867781E00500CF8 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 43776F841B8022E90074EA36 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 4F75288A1DFE1DC600C322D6; - remoteInfo = LoopUI; - }; C1CCF1142858FA900035389C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; @@ -720,7 +679,6 @@ files = ( 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed App Extensions */, E9B07F94253BBA6500BAD8F8 /* Loop Intent Extension.appex in Embed App Extensions */, - 4F70C1E81DE8DCA7006380B7 /* Loop Status Extension.appex in Embed App Extensions */, ); name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -890,7 +848,6 @@ 43BFF0B11E45C18400FF19A9 /* UIColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NumberFormatter.swift; sourceTree = ""; }; 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+HIG.swift"; sourceTree = ""; }; - 43BFF0CC1E466C8400FF19A9 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; 43C05CB021EBBDB9006FB252 /* TimeInRangeLesson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeInRangeLesson.swift; sourceTree = ""; }; 43C05CB421EBE274006FB252 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; 43C05CB721EBEA54006FB252 /* HKUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; @@ -945,7 +902,6 @@ 43F89CA222BDFBBC006BB54E /* UIActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIActivityIndicatorView.swift; sourceTree = ""; }; 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusChartsManager.swift; sourceTree = ""; }; 43FCEEAC221A66780013DD30 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = ""; }; - 43FCEEB0221A863E0013DD30 /* StatusChartsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusChartsManager.swift; sourceTree = ""; }; 4B60626B287E286000BF8BBB /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 4B67E2C7289B4EDB002D92AF /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; 4D3B40021D4A9DFE00BC6334 /* G4ShareSpy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = G4ShareSpy.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -959,12 +915,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; }; @@ -980,85 +931,65 @@ 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartHUDController.swift; sourceTree = ""; }; 63F5E17B297DDF3900A62D4B /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/ckcomplication.strings; sourceTree = ""; }; 7D199D93212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Main.strings; sourceTree = ""; }; - 7D199D94212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/MainInterface.strings; sourceTree = ""; }; 7D199D95212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Interface.strings; sourceTree = ""; }; 7D199D96212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 7D199D97212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D199D99212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 7D199D9A212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 7D199D9D212A067700241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 7D23667521250BE30028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; 7D23667621250BF70028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D23667821250C2D0028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; 7D23667921250C440028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; 7D23667A21250C480028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; 7D23667C21250C7E0028B67D /* LocalizedString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LocalizedString.swift; path = LoopUI/Common/LocalizedString.swift; sourceTree = SOURCE_ROOT; }; 7D23667E21250CAC0028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; 7D23667F21250CB80028B67D /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; 7D23668521250D180028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Main.strings; sourceTree = ""; }; - 7D23668621250D180028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/MainInterface.strings; sourceTree = ""; }; 7D23668721250D180028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Interface.strings; sourceTree = ""; }; 7D23668821250D180028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 7D23668921250D180028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D23668B21250D180028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 7D23668C21250D190028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 7D23668F21250D190028B67D /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 7D23669521250D220028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Main.strings; sourceTree = ""; }; - 7D23669621250D230028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/MainInterface.strings; sourceTree = ""; }; 7D23669721250D230028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Interface.strings; sourceTree = ""; }; 7D23669821250D230028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 7D23669921250D230028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D23669B21250D230028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 7D23669C21250D230028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 7D23669F21250D240028B67D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 7D2366A521250D2C0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Main.strings"; sourceTree = ""; }; - 7D2366A621250D2C0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/MainInterface.strings"; sourceTree = ""; }; 7D2366A721250D2C0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Interface.strings"; sourceTree = ""; }; 7D2366A821250D2C0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 7D2366A921250D2C0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; - 7D2366AB21250D2D0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 7D2366AC21250D2D0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 7D2366AF21250D2D0028B67D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 7D2366B421250D350028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Interface.strings; sourceTree = ""; }; 7D2366B721250D360028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Main.strings; sourceTree = ""; }; - 7D2366B821250D360028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/MainInterface.strings; sourceTree = ""; }; 7D2366B921250D360028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 7D2366BA21250D360028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D2366BC21250D360028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 7D2366BD21250D360028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 7D2366BF21250D370028B67D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 7D2366C521250D3F0028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Main.strings; sourceTree = ""; }; - 7D2366C621250D3F0028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/MainInterface.strings; sourceTree = ""; }; 7D2366C721250D3F0028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Interface.strings; sourceTree = ""; }; 7D2366C821250D400028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 7D2366C921250D400028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D2366CB21250D400028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 7D2366CC21250D400028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 7D2366CF21250D400028B67D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 7D2366D521250D4A0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Main.strings; sourceTree = ""; }; - 7D2366D621250D4A0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/MainInterface.strings; sourceTree = ""; }; 7D2366D721250D4A0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Interface.strings; sourceTree = ""; }; 7D2366D821250D4A0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; 7D2366D921250D4A0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D2366DB21250D4A0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; 7D2366DC21250D4B0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; 7D2366DF21250D4B0028B67D /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; 7D68AAAA1FE2DB0A00522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Main.strings; sourceTree = ""; }; - 7D68AAAB1FE2DB0A00522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/MainInterface.strings; sourceTree = ""; }; 7D68AAAC1FE2DB0A00522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Interface.strings; sourceTree = ""; }; - 7D68AAAD1FE2E8D400522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 7D68AAB31FE2E8D500522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 7D68AAB41FE2E8D600522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; 7D68AAB71FE2E8D600522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 7D68AAB81FE2E8D700522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; - 7D7076361FE06EDE004AC8EA /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 7D70764B1FE06EE1004AC8EA /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 7D70765F1FE06EE3004AC8EA /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 7D7076641FE06EE4004AC8EA /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEED52335A3CB005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; 7D9BEED72335A489005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Main.strings; sourceTree = ""; }; - 7D9BEED82335A4F7005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEEDA2335A522005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BEEDB2335A587005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEEDD2335A5CC005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Interface.strings; sourceTree = ""; }; 7D9BEEDE2335A5F7005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; @@ -1094,71 +1025,59 @@ 7D9BEF122335D694005DCFD6 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Main.strings; sourceTree = ""; }; 7D9BEF132335EC4B005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Intents.strings; sourceTree = ""; }; 7D9BEF152335EC4B005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF162335EC4B005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BEF172335EC4C005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Interface.strings; sourceTree = ""; }; 7D9BEF182335EC4C005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = ""; }; 7D9BEF1A2335EC4C005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF1B2335EC4C005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF1C2335EC4C005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D9BEF1E2335EC4D005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF1F2335EC4D005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF222335EC4D005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF282335EC4E005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF292335EC58005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Intents.strings"; sourceTree = ""; }; 7D9BEF2B2335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Main.strings"; sourceTree = ""; }; - 7D9BEF2C2335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/MainInterface.strings"; sourceTree = ""; }; 7D9BEF2D2335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Interface.strings"; sourceTree = ""; }; 7D9BEF2E2335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Main.strings"; sourceTree = ""; }; 7D9BEF302335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 7D9BEF312335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 7D9BEF322335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = ""; }; - 7D9BEF342335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 7D9BEF352335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 7D9BEF382335EC5A005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 7D9BEF3E2335EC5A005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 7D9BEF3F2335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Intents.strings; sourceTree = ""; }; 7D9BEF412335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF422335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BEF432335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Interface.strings; sourceTree = ""; }; 7D9BEF442335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Main.strings; sourceTree = ""; }; 7D9BEF462335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF472335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEF4A2335EC63005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF4B2335EC63005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF4E2335EC63005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF542335EC64005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF552335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Intents.strings; sourceTree = ""; }; 7D9BEF572335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF582335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BEF592335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Interface.strings; sourceTree = ""; }; 7D9BEF5A2335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Main.strings; sourceTree = ""; }; 7D9BEF5C2335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF5D2335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF5E2335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D9BEF602335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF612335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF642335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF6A2335EC70005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF6B2335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Intents.strings; sourceTree = ""; }; 7D9BEF6D2335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF6E2335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BEF6F2335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Interface.strings; sourceTree = ""; }; 7D9BEF702335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Main.strings; sourceTree = ""; }; 7D9BEF722335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF732335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; - 7D9BEF762335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF772335EC7E005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF7A2335EC7E005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF802335EC7E005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF812335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Intents.strings; sourceTree = ""; }; 7D9BEF832335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Main.strings; sourceTree = ""; }; - 7D9BEF842335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BEF852335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Interface.strings; sourceTree = ""; }; 7D9BEF862335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Main.strings; sourceTree = ""; }; 7D9BEF882335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF892335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF8A2335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D9BEF8C2335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF8D2335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF902335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEF962335EC8D005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; @@ -1168,18 +1087,15 @@ 7D9BEF9A233600D9005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/InfoPlist.strings; sourceTree = ""; }; 7D9BF13A23370E8B005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Intents.strings; sourceTree = ""; }; 7D9BF13B23370E8B005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Main.strings; sourceTree = ""; }; - 7D9BF13C23370E8B005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/MainInterface.strings; sourceTree = ""; }; 7D9BF13D23370E8B005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Interface.strings; sourceTree = ""; }; 7D9BF13E23370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Main.strings; sourceTree = ""; }; 7D9BF13F23370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 7D9BF14023370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 7D9BF14123370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; - 7D9BF14223370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 7D9BF14323370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 7D9BF14423370E8D005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 7D9BF14623370E8D005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 7DD382771F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Main.strings; sourceTree = ""; }; - 7DD382781F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/MainInterface.strings; sourceTree = ""; }; 7DD382791F8DBFC60071272B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Interface.strings; sourceTree = ""; }; 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetLoopManager.swift; sourceTree = ""; }; 80F864E52433BF5D0026EC26 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1334,7 +1250,6 @@ C1004DEF2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DF12981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DF42981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; - C1004DF72981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DF92981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; C1004DFA2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DFB2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1342,7 +1257,6 @@ C1004DFD2981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DFE2981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DFF2981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; - C1004E002981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E012981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; C1004E022981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E032981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1350,7 +1264,6 @@ C1004E052981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E062981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E072981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; - C1004E082981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E092981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; C1004E0A2981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E0B2981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1358,7 +1271,6 @@ C1004E0D2981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E0E2981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E0F2981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; - C1004E102981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E112981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; C1004E122981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E132981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1366,7 +1278,6 @@ C1004E152981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E162981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E172981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; - C1004E182981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E192981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; C1004E1A2981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E1B2981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1374,26 +1285,22 @@ C1004E1D2981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E1E2981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E1F2981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; - C1004E202981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E212981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; C1004E222981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E232981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E242981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E252981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E262981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; - C1004E272981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E282981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; C1004E292981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E2A2981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E2B2981F74300B8CF94 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E2C2981F75B00B8CF94 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; - C1004E2D2981F75B00B8CF94 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E2E2981F75B00B8CF94 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E2F2981F75B00B8CF94 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E302981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E312981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E322981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; - C1004E332981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E342981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E352981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C101947127DD473C004E7EB8 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1438,7 +1345,6 @@ C13DA2AF24F6C7690098BB29 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryUncertaintyAlertManager.swift; sourceTree = ""; }; C14952142995822A0095AA84 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; - C14952152995822A0095AA84 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; C155A8F32986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C155A8F42986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; C155A8F52986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/ckcomplication.strings; sourceTree = ""; }; @@ -1446,7 +1352,6 @@ C159C8212867859800A86EC0 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C159C82E286787EF00A86EC0 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C15A581F29C7866600D3A5A1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; - C15A582029C7866600D3A5A1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; C15A582129C7866600D3A5A1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; C15A582229C7866600D3A5A1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; C15A582329C7866600D3A5A1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1462,7 +1367,6 @@ C16FC0AF2A99392F0025E239 /* live_capture_input.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_input.json; sourceTree = ""; }; C1742331259BEADC00399C9D /* ManualEntryDoseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryDoseView.swift; sourceTree = ""; }; C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryDoseViewModel.swift; sourceTree = ""; }; - C174571329830930009EFCF2 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; C174571429830930009EFCF2 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; C174571529830930009EFCF2 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; C1750AEB255B013300B8011C /* Minizip.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Minizip.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1492,7 +1396,6 @@ C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusCalculatorTests.swift; sourceTree = ""; }; C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DosingStrategySelectionView.swift; sourceTree = ""; }; C192C5FE29C78711001EFEA6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; - C192C5FF29C78711001EFEA6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/InfoPlist.strings; sourceTree = ""; }; C192C60029C78711001EFEA6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; C192C60129C78711001EFEA6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/InfoPlist.strings; sourceTree = ""; }; C192C60229C78711001EFEA6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; @@ -1506,7 +1409,6 @@ C19E387B298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; C19E387C298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; C19E387D298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; - C19E387E298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; C19F496225630504003632D7 /* Minizip.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Minizip.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C1AD41FF256D61E500164DDD /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = ""; }; C1AD48CE298639890013B994 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1515,7 +1417,6 @@ C1AD630029BBFAA80002685D /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/ckcomplication.strings; sourceTree = ""; }; C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualGlucoseEntryRow.swift; sourceTree = ""; }; C1B0CFD429C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; - C1B0CFD529C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; C1B0CFD629C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; C1B0CFD729C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; C1B0CFD829C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; @@ -1530,7 +1431,6 @@ C1BCB5AF298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1BCB5B0298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1BCB5B1298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; - C1BCB5B2298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1BCB5B3298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; C1BCB5B4298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1BCB5B5298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1542,13 +1442,9 @@ C1C247892995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; C1C2478B2995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; C1C2478C2995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; - C1C2478D2995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; - C1C2478E2995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/MainInterface.strings; sourceTree = ""; }; - C1C2478F2995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; C1C247902995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; C1C247912995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; C1C31277297E4BFE00296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Main.strings; sourceTree = ""; }; - C1C31278297E4BFE00296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/MainInterface.strings; sourceTree = ""; }; C1C31279297E4BFE00296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Interface.strings; sourceTree = ""; }; C1C3127A297E4BFE00296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Main.strings; sourceTree = ""; }; C1C3127C297E4BFE00296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; @@ -1576,7 +1472,6 @@ C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredLoopNotRunningNotification.swift; sourceTree = ""; }; C1E5A6DE29C7870100703C90 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; C1E693CA29C786E200410918 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; - C1E693CB29C786E200410918 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = ""; }; C1E693CC29C786E200410918 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; C1E693CD29C786E200410918 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = ""; }; C1E693CE29C786E200410918 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; @@ -1597,7 +1492,6 @@ C1F48FF62995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F48FF72995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F48FF82995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; - C1F48FF92995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F48FFA2995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; C1F48FFB2995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F48FFC2995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1625,7 +1519,6 @@ C1FDCC0229C786F90056E652 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; C1FDCC0329C786F90056E652 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Interface.strings; sourceTree = ""; }; C1FF3D4929C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; - C1FF3D4A29C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; C1FF3D4B29C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; C1FF3D4C29C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; C1FF3D4D29C786A900BDC1EC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1681,13 +1574,11 @@ E9C58A7B24DB529A00487A17 /* insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = insulin_effect.json; sourceTree = ""; }; F5D9C01727DABBE0002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Intents.strings; sourceTree = ""; }; F5D9C01927DABBE0002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Main.strings; sourceTree = ""; }; - F5D9C01A27DABBE1002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/MainInterface.strings; sourceTree = ""; }; F5D9C01B27DABBE1002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Interface.strings; sourceTree = ""; }; F5D9C01C27DABBE1002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Main.strings; sourceTree = ""; }; F5D9C01E27DABBE2002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; F5D9C01F27DABBE2002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; F5D9C02027DABBE2002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; - F5D9C02127DABBE3002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; F5D9C02227DABBE3002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; F5D9C02327DABBE3002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; F5D9C02427DABBE3002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1695,13 +1586,11 @@ F5D9C02727DABBE4002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; F5E0BDD327E1D71C0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Intents.strings; sourceTree = ""; }; F5E0BDD527E1D71D0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Main.strings; sourceTree = ""; }; - F5E0BDD627E1D71D0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/MainInterface.strings; sourceTree = ""; }; F5E0BDD727E1D71E0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Interface.strings; sourceTree = ""; }; F5E0BDD827E1D71E0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Main.strings; sourceTree = ""; }; F5E0BDDA27E1D71F0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; F5E0BDDB27E1D7200033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; F5E0BDDC27E1D7200033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; - F5E0BDDD27E1D7210033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; F5E0BDDE27E1D7210033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; F5E0BDDF27E1D7210033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; F5E0BDE027E1D7220033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1786,18 +1675,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; @@ -1991,7 +1868,6 @@ C18A491122FCC20B00FDA733 /* Scripts */, 4FF4D0FA1E1834BD00846527 /* Common */, 43776F8E1B8022E90074EA36 /* Loop */, - 4F70C1DF1DE8DCA7006380B7 /* Loop Status Extension */, 43D9FFD021EAE05D00AF44BF /* LoopCore */, 4F75288C1DFE1DC600C322D6 /* LoopUI */, 43A943731B926B7B0051FA24 /* WatchApp */, @@ -2015,7 +1891,6 @@ 43A943721B926B7B0051FA24 /* WatchApp.app */, 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */, 43E2D90B1D20C581004DA55F /* LoopTests.xctest */, - 4F70C1DC1DE8DCA7006380B7 /* Loop Status Extension.appex */, 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */, 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */, 43D9002A21EB209400AF44BF /* LoopCore.framework */, @@ -2361,21 +2236,6 @@ path = LoopTests; sourceTree = ""; }; - 4F70C1DF1DE8DCA7006380B7 /* Loop Status Extension */ = { - isa = PBXGroup; - children = ( - 7D7076371FE06EDE004AC8EA /* Localizable.strings */, - 4F70C1FD1DE8E662006380B7 /* Loop Status Extension.entitlements */, - 4F70C1E51DE8DCA7006380B7 /* Info.plist */, - C1004DF62981F5B700B8CF94 /* InfoPlist.strings */, - 43BFF0CC1E466C8400FF19A9 /* StateColorPalette.swift */, - 43FCEEB0221A863E0013DD30 /* StatusChartsManager.swift */, - 4F70C1E01DE8DCA7006380B7 /* StatusViewController.swift */, - 4F70C1E21DE8DCA7006380B7 /* MainInterface.storyboard */, - ); - path = "Loop Status Extension"; - sourceTree = ""; - }; 4F75288C1DFE1DC600C322D6 /* LoopUI */ = { isa = PBXGroup; children = ( @@ -2962,7 +2822,6 @@ dependencies = ( 4F7528971DFE1ED400C322D6 /* PBXTargetDependency */, 43A943931B926B7B0051FA24 /* PBXTargetDependency */, - 4F70C1E71DE8DCA7006380B7 /* PBXTargetDependency */, 43D9FFD521EAE05D00AF44BF /* PBXTargetDependency */, E9B07F93253BBA6500BAD8F8 /* PBXTargetDependency */, 14B1736828AED9EE006CCD7C /* PBXTargetDependency */, @@ -3073,27 +2932,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" */; @@ -3218,16 +3056,6 @@ ProvisioningStyle = Automatic; TestTargetID = 43776F8B1B8022E90074EA36; }; - 4F70C1DB1DE8DCA7006380B7 = { - CreatedOnToolsVersion = 8.1; - LastSwiftMigration = 1020; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.ApplicationGroups.iOS = { - enabled = 1; - }; - }; - }; 4F75288A1DFE1DC600C322D6 = { CreatedOnToolsVersion = 8.1; LastSwiftMigration = 1020; @@ -3279,7 +3107,6 @@ projectRoot = ""; targets = ( 43776F8B1B8022E90074EA36 /* Loop */, - 4F70C1DB1DE8DCA7006380B7 /* Loop Status Extension */, 43A943711B926B7B0051FA24 /* WatchApp */, 43A9437D1B926B7B0051FA24 /* WatchApp Extension */, 14B1735B28AED9EC006CCD7C /* Loop Widget Extension */, @@ -3383,18 +3210,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 4F70C1DA1DE8DCA7006380B7 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B491B09E24D0B600004CBE8F /* DerivedAssets.xcassets in Resources */, - 4F70C1E41DE8DCA7006380B7 /* MainInterface.storyboard in Resources */, - B405E35B24D2E05600DD058D /* HUDAssets.xcassets in Resources */, - 7D7076351FE06EDE004AC8EA /* Localizable.strings in Resources */, - C1004DF82981F5B700B8CF94 /* InfoPlist.strings in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 4F7528891DFE1DC600C322D6 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -3969,29 +3784,6 @@ ); 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 */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 4F7528861DFE1DC600C322D6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -4083,11 +3875,6 @@ target = 43776F8B1B8022E90074EA36 /* Loop */; targetProxy = 43E2D9101D20C581004DA55F /* PBXContainerItemProxy */; }; - 4F70C1E71DE8DCA7006380B7 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 4F70C1DB1DE8DCA7006380B7 /* Loop Status Extension */; - targetProxy = 4F70C1E61DE8DCA7006380B7 /* PBXContainerItemProxy */; - }; 4F7528971DFE1ED400C322D6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 4F75288A1DFE1DC600C322D6 /* LoopUI */; @@ -4098,11 +3885,6 @@ target = 43D9001A21EB209400AF44BF /* LoopCore-watchOS */; targetProxy = C117ED70232EDB3200DA57CD /* PBXContainerItemProxy */; }; - C11B9D592867781E00500CF8 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 4F75288A1DFE1DC600C322D6 /* LoopUI */; - targetProxy = C11B9D582867781E00500CF8 /* PBXContainerItemProxy */; - }; C1CCF1152858FA900035389C /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 43D9FFCE21EAE05D00AF44BF /* LoopCore */; @@ -4298,35 +4080,6 @@ name = InfoPlist.strings; sourceTree = ""; }; - 4F70C1E21DE8DCA7006380B7 /* MainInterface.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 4F70C1E31DE8DCA7006380B7 /* Base */, - 7DD382781F8DBFC60071272B /* es */, - 7D68AAAB1FE2DB0A00522C49 /* ru */, - 7D23668621250D180028B67D /* fr */, - 7D23669621250D230028B67D /* de */, - 7D2366A621250D2C0028B67D /* zh-Hans */, - 7D2366B821250D360028B67D /* it */, - 7D2366C621250D3F0028B67D /* nl */, - 7D2366D621250D4A0028B67D /* nb */, - 7D199D94212A067600241026 /* pl */, - 7D9BEEDA2335A522005DCFD6 /* en */, - 7D9BEF162335EC4B005DCFD6 /* ja */, - 7D9BEF2C2335EC59005DCFD6 /* pt-BR */, - 7D9BEF422335EC62005DCFD6 /* vi */, - 7D9BEF582335EC6E005DCFD6 /* da */, - 7D9BEF6E2335EC7D005DCFD6 /* sv */, - 7D9BEF842335EC8B005DCFD6 /* fi */, - 7D9BF13C23370E8B005DCFD6 /* ro */, - F5D9C01A27DABBE1002E48F6 /* tr */, - F5E0BDD627E1D71D0033557E /* he */, - C1C31278297E4BFE00296DA4 /* ar */, - C1C2478E2995823200371B88 /* sk */, - ); - name = MainInterface.storyboard; - sourceTree = ""; - }; 63F5E17A297DDF3900A62D4B /* ckcomplication.strings */ = { isa = PBXVariantGroup; children = ( @@ -4347,35 +4100,6 @@ name = ckcomplication.strings; sourceTree = ""; }; - 7D7076371FE06EDE004AC8EA /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - 7D7076361FE06EDE004AC8EA /* es */, - 7D68AAAD1FE2E8D400522C49 /* ru */, - 7D23667821250C2D0028B67D /* Base */, - 7D23668B21250D180028B67D /* fr */, - 7D23669B21250D230028B67D /* de */, - 7D2366AB21250D2D0028B67D /* zh-Hans */, - 7D2366BC21250D360028B67D /* it */, - 7D2366CB21250D400028B67D /* nl */, - 7D2366DB21250D4A0028B67D /* nb */, - 7D199D99212A067600241026 /* pl */, - 7D9BEED82335A4F7005DCFD6 /* en */, - 7D9BEF1E2335EC4D005DCFD6 /* ja */, - 7D9BEF342335EC59005DCFD6 /* pt-BR */, - 7D9BEF4A2335EC63005DCFD6 /* vi */, - 7D9BEF602335EC6F005DCFD6 /* da */, - 7D9BEF762335EC7D005DCFD6 /* sv */, - 7D9BEF8C2335EC8C005DCFD6 /* fi */, - 7D9BF14223370E8C005DCFD6 /* ro */, - F5D9C02127DABBE3002E48F6 /* tr */, - F5E0BDDD27E1D7210033557E /* he */, - C174571329830930009EFCF2 /* ar */, - C1C2478D2995823200371B88 /* sk */, - ); - name = Localizable.strings; - sourceTree = ""; - }; 7D7076471FE06EE0004AC8EA /* InfoPlist.strings */ = { isa = PBXVariantGroup; children = ( @@ -4646,32 +4370,6 @@ name = Localizable.strings; sourceTree = ""; }; - C1004DF62981F5B700B8CF94 /* InfoPlist.strings */ = { - isa = PBXVariantGroup; - children = ( - C1004DF72981F5B700B8CF94 /* da */, - C1004E002981F67A00B8CF94 /* sv */, - C1004E082981F6A100B8CF94 /* ro */, - C1004E102981F6E200B8CF94 /* nl */, - C1004E182981F6F500B8CF94 /* nb */, - C1004E202981F72D00B8CF94 /* fr */, - C1004E272981F74300B8CF94 /* fi */, - C1004E2D2981F75B00B8CF94 /* es */, - C1004E332981F77B00B8CF94 /* de */, - C1BCB5B2298309C4001C50FF /* it */, - C19E387E298638CE00851444 /* tr */, - C1F48FF92995821600C8BD69 /* pl */, - C14952152995822A0095AA84 /* ru */, - C1C2478F2995823200371B88 /* sk */, - C15A582029C7866600D3A5A1 /* ar */, - C1FF3D4A29C786A900BDC1EC /* he */, - C1B0CFD529C786BF0045B04D /* ja */, - C1E693CB29C786E200410918 /* pt-BR */, - C192C5FF29C78711001EFEA6 /* vi */, - ); - name = InfoPlist.strings; - sourceTree = ""; - }; C11613472983096D00777E7C /* InfoPlist.strings */ = { isa = PBXVariantGroup; children = ( @@ -5355,58 +5053,6 @@ }; name = Release; }; - 4F70C1E91DE8DCA8006380B7 /* Debug */ = { - 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)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "Loop Status Extension/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_DEBUG)"; - SKIP_INSTALL = YES; - 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 = Debug; - }; - 4F70C1EA1DE8DCA8006380B7 /* Release */ = { - 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)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "Loop Status Extension/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_RELEASE)"; - SKIP_INSTALL = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; - TARGETED_DEVICE_FAMILY = 1; - }; - name = Release; - }; 4F7528901DFE1DC600C322D6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -5593,32 +5239,6 @@ }; name = Testflight; }; - B4E7CF932AD00A39009B4DF2 /* 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)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = "Loop Status Extension/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_RELEASE)"; - SKIP_INSTALL = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; - TARGETED_DEVICE_FAMILY = 1; - }; - name = Testflight; - }; B4E7CF942AD00A39009B4DF2 /* Testflight */ = { isa = XCBuildConfiguration; buildSettings = { @@ -5966,16 +5586,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 4F70C1EB1DE8DCA8006380B7 /* Build configuration list for PBXNativeTarget "Loop Status Extension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 4F70C1E91DE8DCA8006380B7 /* Debug */, - B4E7CF932AD00A39009B4DF2 /* Testflight */, - 4F70C1EA1DE8DCA8006380B7 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 4F7528921DFE1DC600C322D6 /* Build configuration list for PBXNativeTarget "LoopUI" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -6036,11 +5646,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/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index c95c4e8808..d504d18962 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -175,7 +175,7 @@ final class LoopDataManager: ObservableObject { self.analyticsServicesManager = analyticsServicesManager self.carbAbsorptionModel = carbAbsorptionModel self.usePositiveMomentumAndRCForManualBoluses = usePositiveMomentumAndRCForManualBoluses - + // Required for device settings in stored dosing decisions UIDevice.current.isBatteryMonitoringEnabled = true @@ -199,6 +199,7 @@ final class LoopDataManager: ObservableObject { ) { (note) in Task { @MainActor in self.logger.default("Received notification of glucose samples changing") + self.restartGlucoseValueStalenessTimer() await self.updateDisplayState() self.notify(forChange: .glucose) } @@ -235,8 +236,6 @@ final class LoopDataManager: ObservableObject { } } .store(in: &cancellables) - - } // MARK: - Calculation state @@ -643,9 +642,41 @@ final class LoopDataManager: ObservableObject { await self.dosingDecisionStore.storeDosingDecision(dosingDecision) } } + + // MARK: - Glucose Staleness + + private var glucoseValueStalenessTimer: Timer? + + private func restartGlucoseValueStalenessTimer() { + stopGlucoseValueStalenessTimer() + startGlucoseValueStalenessTimerIfNeeded() + } + + private func stopGlucoseValueStalenessTimer() { + glucoseValueStalenessTimer?.invalidate() + glucoseValueStalenessTimer = nil + } + + func startGlucoseValueStalenessTimerIfNeeded() { + guard let fireDate = glucoseValueStaleDate, + glucoseValueStalenessTimer == nil + else { return } + + glucoseValueStalenessTimer = Timer(fire: fireDate, interval: 0, repeats: false) { (_) in + Task { @MainActor in + self.notify(forChange: .glucose) + } + } + RunLoop.main.add(glucoseValueStalenessTimer!, forMode: .default) + } + + private var glucoseValueStaleDate: Date? { + guard let latestGlucoseDataDate = glucoseStore.latestGlucose?.startDate else { return nil } + return latestGlucoseDataDate.addingTimeInterval(LoopAlgorithm.inputDataRecencyInterval) + } } -// MARK: Background task management +// MARK: - Background task management extension LoopDataManager: PersistenceControllerDelegate { func persistenceControllerWillSave(_ controller: PersistenceController) { startBackgroundTask() diff --git a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift index 8e15e5145f..0f15a19f56 100644 --- a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift @@ -8,8 +8,10 @@ import LoopKit import HealthKit +import LoopAlgorithm protocol GlucoseStoreProtocol: AnyObject { + var latestGlucose: GlucoseSampleValue? { get } func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] func addGlucoseSamples(_ samples: [NewGlucoseSample]) async throws -> [StoredGlucoseSample] } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 8542ff1649..90a9dc95fa 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -268,6 +268,7 @@ final class StatusTableViewController: LoopChartsTableViewController { var onscreen: Bool = false { didSet { updateHUDActive() + loopManager.startGlucoseValueStalenessTimerIfNeeded() } } @@ -590,10 +591,10 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView.cgmStatusHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: unit), at: glucose.startDate, unit: unit, - staleGlucoseAge: LoopAlgorithm.inputDataRecencyInterval, glucoseDisplay: self.deviceManager.glucoseDisplay(for: glucose), wasUserEntered: glucose.wasUserEntered, - isDisplayOnly: glucose.isDisplayOnly) + isDisplayOnly: glucose.isDisplayOnly, + isGlucoseValueStale: self.deviceManager.isGlucoseValueStale) } hudView.cgmStatusHUD.presentStatusHighlight(self.deviceManager.cgmStatusHighlight) hudView.cgmStatusHUD.presentStatusBadge(self.deviceManager.cgmStatusBadge) @@ -751,8 +752,9 @@ final class StatusTableViewController: LoopChartsTableViewController { let hudIsVisible = self.shouldShowHUD let statusIsVisible = self.shouldShowStatus - + hudView?.cgmStatusHUD?.isVisible = hudIsVisible + hudView?.cgmStatusHUD.isGlucoseValueStale = deviceManager.isGlucoseValueStale tableView.beginUpdates() @@ -1833,8 +1835,7 @@ final class StatusTableViewController: LoopChartsTableViewController { present(alert, animated: true, completion: nil) } } - - + // MARK: - Debug Scenarios and Simulated Core Data var lastOrientation: UIDeviceOrientation? diff --git a/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift b/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift index ea743eb008..9728578c0c 100644 --- a/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift +++ b/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift @@ -14,12 +14,9 @@ import LoopKit class CGMStatusHUDViewModelTests: XCTestCase { private var viewModel: CGMStatusHUDViewModel! - private var staleGlucoseValueHandlerWasCalled = false - private var testExpect: XCTestExpectation! override func setUpWithError() throws { - staleGlucoseValueHandlerWasCalled = false - viewModel = CGMStatusHUDViewModel(staleGlucoseValueHandler: staleGlucoseValueHandler) + viewModel = CGMStatusHUDViewModel() } override func tearDownWithError() throws { @@ -45,14 +42,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() - let staleGlucoseAge: TimeInterval = .minutes(15) viewModel.setGlucoseQuantity(90, at: glucoseStartDate, unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: false, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: false) XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) XCTAssertNil(viewModel.statusHighlight) @@ -70,14 +66,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() - let staleGlucoseAge: TimeInterval = .minutes(-1) viewModel.setGlucoseQuantity(90, at: glucoseStartDate, unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: false, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: true) XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) XCTAssertNil(viewModel.statusHighlight) @@ -90,35 +85,6 @@ class CGMStatusHUDViewModelTests: XCTestCase { XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) } - func testSetGlucoseQuantityCGMStaleDelayed() { - testExpect = self.expectation(description: #function) - let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, - trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), - isLocal: true, - glucoseRangeCategory: .urgentLow) - let glucoseStartDate = Date() - let staleGlucoseAge: TimeInterval = .seconds(0.01) - viewModel.setGlucoseQuantity(90, - at: glucoseStartDate, - unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, - glucoseDisplay: glucoseDisplay, - wasUserEntered: false, - isDisplayOnly: false) - wait(for: [testExpect], timeout: 1.0) - XCTAssertTrue(staleGlucoseValueHandlerWasCalled) - XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) - XCTAssertNil(viewModel.statusHighlight) - XCTAssertEqual(viewModel.glucoseValueString, "– – –") - XCTAssertNil(viewModel.trend) - XCTAssertNotEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) - XCTAssertEqual(viewModel.glucoseTrendTintColor, .glucoseTintColor) - XCTAssertNotEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) - XCTAssertEqual(viewModel.glucoseValueTintColor, .label) - XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) - } - func testSetGlucoseQuantityManualGlucose() { let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, @@ -126,14 +92,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() - let staleGlucoseAge: TimeInterval = .minutes(15) viewModel.setGlucoseQuantity(90, at: glucoseStartDate, unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: true, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: false) XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) XCTAssertNil(viewModel.statusHighlight) @@ -152,14 +117,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() - let staleGlucoseAge: TimeInterval = .minutes(15) viewModel.setGlucoseQuantity(90, at: glucoseStartDate, unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: true, - isDisplayOnly: true) + isDisplayOnly: true, + isGlucoseValueStale: false) XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) XCTAssertEqual(viewModel.glucoseValueString, "90") @@ -191,14 +155,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() - let staleGlucoseAge: TimeInterval = .minutes(15) viewModel.setGlucoseQuantity(90, at: glucoseStartDate, unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: true, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: false) XCTAssertEqual(viewModel.glucoseValueString, "90") XCTAssertNil(viewModel.trend) @@ -222,14 +185,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), isLocal: true, glucoseRangeCategory: .urgentLow) - let staleGlucoseAge: TimeInterval = .minutes(15) viewModel.setGlucoseQuantity(90, at: Date(), unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: true, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: false) // check that manual glucose is displayed XCTAssertEqual(viewModel.glucoseValueString, "90") @@ -255,10 +217,10 @@ class CGMStatusHUDViewModelTests: XCTestCase { viewModel.setGlucoseQuantity(95, at: Date(), unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: false, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: false) // check that status highlight is displayed XCTAssertEqual(viewModel.glucoseValueString, "95") @@ -291,10 +253,10 @@ class CGMStatusHUDViewModelTests: XCTestCase { viewModel.setGlucoseQuantity(100, at: Date(), unit: .milligramsPerDeciliter, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: true, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: false) // check that manual glucose is still displayed (again with status highlight icon) XCTAssertEqual(viewModel.glucoseValueString, "100") @@ -307,10 +269,10 @@ class CGMStatusHUDViewModelTests: XCTestCase { viewModel.setGlucoseQuantity(100, at: Date(), unit: .milligramsPerDeciliter, - staleGlucoseAge: .minutes(-1), glucoseDisplay: glucoseDisplay, wasUserEntered: true, - isDisplayOnly: false) + isDisplayOnly: false, + isGlucoseValueStale: true) // check that the status highlight is displayed XCTAssertEqual(viewModel.statusHighlight as! TestStatusHighlight, statusHighlight2) @@ -319,11 +281,6 @@ class CGMStatusHUDViewModelTests: XCTestCase { } extension CGMStatusHUDViewModelTests { - func staleGlucoseValueHandler() { - self.staleGlucoseValueHandlerWasCalled = true - testExpect.fulfill() - } - struct TestStatusHighlight: DeviceStatusHighlight, Equatable { var localizedMessage: String diff --git a/LoopUI/ViewModel/CGMStatusHUDViewModel.swift b/LoopUI/ViewModel/CGMStatusHUDViewModel.swift index a26834e4b3..06dc1d456d 100644 --- a/LoopUI/ViewModel/CGMStatusHUDViewModel.swift +++ b/LoopUI/ViewModel/CGMStatusHUDViewModel.swift @@ -32,15 +32,12 @@ public class CGMStatusHUDViewModel { return manualGlucoseTrendIconOverride } - private var glucoseValueCurrent: Bool { - guard let isStaleAt = isStaleAt else { return true } - return Date() < isStaleAt - } + var isGlucoseValueStale: Bool = false private var isManualGlucose: Bool = false private var isManualGlucoseCurrent: Bool { - return isManualGlucose && glucoseValueCurrent + return isManualGlucose && !isGlucoseValueStale } var manualGlucoseTrendIconOverride: UIImage? @@ -70,58 +67,17 @@ public class CGMStatusHUDViewModel { } } - var isVisible: Bool = true { - didSet { - if oldValue != isVisible { - if !isVisible { - stalenessTimer?.invalidate() - stalenessTimer = nil - } else { - startStalenessTimerIfNeeded() - } - } - } - } - - private var stalenessTimer: Timer? - - private var isStaleAt: Date? { - didSet { - if oldValue != isStaleAt { - stalenessTimer?.invalidate() - stalenessTimer = nil - } - } - } + var isVisible: Bool = true - private func startStalenessTimerIfNeeded() { - if let fireDate = isStaleAt, - isVisible, - stalenessTimer == nil - { - stalenessTimer = Timer(fire: fireDate, interval: 0, repeats: false) { (_) in - self.displayStaleGlucoseValue() - self.staleGlucoseValueHandler() - } - RunLoop.main.add(stalenessTimer!, forMode: .default) - } - } - private lazy var timeFormatter = DateFormatter(timeStyle: .short) - - var staleGlucoseValueHandler: () -> Void - - init(staleGlucoseValueHandler: @escaping () -> Void) { - self.staleGlucoseValueHandler = staleGlucoseValueHandler - } func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: HKUnit, - staleGlucoseAge: TimeInterval, glucoseDisplay: GlucoseDisplayable?, wasUserEntered: Bool, - isDisplayOnly: Bool) + isDisplayOnly: Bool, + isGlucoseValueStale: Bool) { var accessibilityStrings = [String]() @@ -131,14 +87,12 @@ public class CGMStatusHUDViewModel { let time = timeFormatter.string(from: glucoseStartDate) - isStaleAt = glucoseStartDate.addingTimeInterval(staleGlucoseAge) - glucoseValueTintColor = glucoseDisplay?.glucoseRangeCategory?.glucoseColor ?? .label + self.isGlucoseValueStale = isGlucoseValueStale let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) if let valueString = numberFormatter.string(from: glucoseQuantity) { - if glucoseValueCurrent { - startStalenessTimerIfNeeded() + if !isGlucoseValueStale { switch glucoseDisplay?.glucoseRangeCategory { case .some(.belowRange): glucoseValueString = LocalizedString("LOW", comment: "String displayed instead of a glucose value below the CGM range") @@ -158,7 +112,7 @@ public class CGMStatusHUDViewModel { if isManualGlucoseCurrent { // a manual glucose value presents any status highlight icon instead of a trend icon setManualGlucoseTrendIconOverride() - } else if let trend = glucoseDisplay?.trendType, glucoseValueCurrent { + } else if let trend = glucoseDisplay?.trendType, !isGlucoseValueStale { self.trend = trend glucoseTrendTintColor = glucoseDisplay?.glucoseRangeCategory?.trendColor ?? .glucoseTintColor accessibilityStrings.append(trend.localizedDescription) diff --git a/LoopUI/Views/CGMStatusHUDView.swift b/LoopUI/Views/CGMStatusHUDView.swift index ad659445fd..5a40459c3c 100644 --- a/LoopUI/Views/CGMStatusHUDView.swift +++ b/LoopUI/Views/CGMStatusHUDView.swift @@ -23,6 +23,15 @@ public final class CGMStatusHUDView: DeviceStatusHUDView, NibLoadable { return 1 } + public var isGlucoseValueStale: Bool { + get { + viewModel.isGlucoseValueStale + } + set { + viewModel.isGlucoseValueStale = newValue + } + } + public var isVisible: Bool { get { viewModel.isVisible @@ -47,9 +56,7 @@ public final class CGMStatusHUDView: DeviceStatusHUDView, NibLoadable { override func setup() { super.setup() statusHighlightView.setIconPosition(.right) - viewModel = CGMStatusHUDViewModel(staleGlucoseValueHandler: { [weak self] in - self?.updateDisplay() - }) + viewModel = CGMStatusHUDViewModel() } override public func tintColorDidChange() { @@ -110,18 +117,18 @@ public final class CGMStatusHUDView: DeviceStatusHUDView, NibLoadable { public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: HKUnit, - staleGlucoseAge: TimeInterval, glucoseDisplay: GlucoseDisplayable?, wasUserEntered: Bool, - isDisplayOnly: Bool) + isDisplayOnly: Bool, + isGlucoseValueStale: Bool) { viewModel.setGlucoseQuantity(glucoseQuantity, at: glucoseStartDate, unit: unit, - staleGlucoseAge: staleGlucoseAge, glucoseDisplay: glucoseDisplay, wasUserEntered: wasUserEntered, - isDisplayOnly: isDisplayOnly) + isDisplayOnly: isDisplayOnly, + isGlucoseValueStale: isGlucoseValueStale) updateDisplay() } From 2192d6beaf92862a40a6e531bf9b4739a953d437 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 9 Sep 2024 12:13:34 -0700 Subject: [PATCH 156/184] Merge branch 'dev' into cameron/LOOP-4793 --- DIYLoopUITests/DIYLoopUITestPlan.xctestplan | 1 + 1 file changed, 1 insertion(+) diff --git a/DIYLoopUITests/DIYLoopUITestPlan.xctestplan b/DIYLoopUITests/DIYLoopUITestPlan.xctestplan index 1e2fd4403d..6ec7040b1c 100644 --- a/DIYLoopUITests/DIYLoopUITestPlan.xctestplan +++ b/DIYLoopUITests/DIYLoopUITestPlan.xctestplan @@ -33,6 +33,7 @@ }, "testTargets" : [ { + "enabled" : false, "target" : { "containerPath" : "container:..\/Loop\/Loop.xcodeproj", "identifier" : "847434F22B7C41D30084BE98", From 3761d96b084c256cf991bdda16c25b4ee5d3d77b Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 9 Sep 2024 12:24:20 -0700 Subject: [PATCH 157/184] Merge branch 'dev' into cameron/LOOP-4793 --- DIYLoopUITests/DIYLoopUITestPlan.xctestplan | 1 - 1 file changed, 1 deletion(-) diff --git a/DIYLoopUITests/DIYLoopUITestPlan.xctestplan b/DIYLoopUITests/DIYLoopUITestPlan.xctestplan index 6ec7040b1c..1e2fd4403d 100644 --- a/DIYLoopUITests/DIYLoopUITestPlan.xctestplan +++ b/DIYLoopUITests/DIYLoopUITestPlan.xctestplan @@ -33,7 +33,6 @@ }, "testTargets" : [ { - "enabled" : false, "target" : { "containerPath" : "container:..\/Loop\/Loop.xcodeproj", "identifier" : "847434F22B7C41D30084BE98", From ae4dffa258fb7a428fc5cc93d5e183ebddcdee8d Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 9 Sep 2024 14:57:37 -0700 Subject: [PATCH 158/184] Merge branch 'dev' into cameron/LOOP-4793 --- DIYLoopUITests/DIYLoopUITestPlan.xctestplan | 52 ---- DIYLoopUITests/DIYLoopUITests.swift | 44 ---- DIYLoopUITests/Screens/OnboardingScreen.swift | 82 ------- Loop.xcodeproj/project.pbxproj | 225 ------------------ 4 files changed, 403 deletions(-) delete mode 100644 DIYLoopUITests/DIYLoopUITestPlan.xctestplan delete mode 100644 DIYLoopUITests/DIYLoopUITests.swift delete mode 100644 DIYLoopUITests/Screens/OnboardingScreen.swift diff --git a/DIYLoopUITests/DIYLoopUITestPlan.xctestplan b/DIYLoopUITests/DIYLoopUITestPlan.xctestplan deleted file mode 100644 index 1e2fd4403d..0000000000 --- a/DIYLoopUITests/DIYLoopUITestPlan.xctestplan +++ /dev/null @@ -1,52 +0,0 @@ -{ - "configurations" : [ - { - "id" : "7D98F861-1A40-4E2D-B298-96208D0BC6BC", - "name" : "Configuration 1", - "options" : { - "environmentVariableEntries" : [ - { - "key" : "appName", - "value" : "DIY Loop" - } - ] - } - } - ], - "defaultOptions" : { - "environmentVariableEntries" : [ - { - "key" : "bundleIdentifier", - "value" : "org.tidepool.diy.Loop" - }, - { - "key" : "appName", - "value" : "DIY Loop" - } - ], - "targetForVariableExpansion" : { - "containerPath" : "container:..\/Loop\/Loop.xcodeproj", - "identifier" : "43776F8B1B8022E90074EA36", - "name" : "Loop" - }, - "testTimeoutsEnabled" : true - }, - "testTargets" : [ - { - "target" : { - "containerPath" : "container:..\/Loop\/Loop.xcodeproj", - "identifier" : "847434F22B7C41D30084BE98", - "name" : "DIYLoopUITests" - } - }, - { - "enabled" : false, - "target" : { - "containerPath" : "container:..\/Loop\/Loop.xcodeproj", - "identifier" : "840B7A7D2B7BFF58000ED932", - "name" : "LoopUITests" - } - } - ], - "version" : 1 -} diff --git a/DIYLoopUITests/DIYLoopUITests.swift b/DIYLoopUITests/DIYLoopUITests.swift deleted file mode 100644 index 8316ccd445..0000000000 --- a/DIYLoopUITests/DIYLoopUITests.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// DIYLoopUITests.swift -// DIYLoopUITests -// -// Created by Cameron Ingham on 2/13/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopUITestingKit -import XCTest - -@MainActor -final class DIYLoopUITests: XCTestCase { - private let app = XCUIApplication() - - var baseScreen: BaseScreen! - var homeScreen: HomeScreen! - var settingsScreen: SettingsScreen! - var systemSettingsScreen: SystemSettingsScreen! - var pumpSimulatorScreen: PumpSimulatorScreen! - var onboardingScreen: OnboardingScreen! - - override func setUpWithError() throws { - continueAfterFailure = false - app.launch() - baseScreen = BaseScreen(app: app) - homeScreen = HomeScreen(app: app) - settingsScreen = SettingsScreen(app: app) - systemSettingsScreen = SystemSettingsScreen() - pumpSimulatorScreen = PumpSimulatorScreen(app: app) - onboardingScreen = OnboardingScreen(app: app) - } - - func testSkippingOnboarding() async throws { - onboardingScreen.skipAllOfOnboarding() - homeScreen.openSettings() - settingsScreen.openPumpManager() - waitForExistence(settingsScreen.pumpSimulatorButton) - settingsScreen.pumpSimulatorButton.tap() - settingsScreen.openCGMManager() - waitForExistence(settingsScreen.cgmSimulatorButton) - settingsScreen.cgmSimulatorButton.tap() - } -} diff --git a/DIYLoopUITests/Screens/OnboardingScreen.swift b/DIYLoopUITests/Screens/OnboardingScreen.swift deleted file mode 100644 index 191e611a72..0000000000 --- a/DIYLoopUITests/Screens/OnboardingScreen.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// OnboardingScreen.swift -// DIYLoopUITests -// -// Created by Cameron Ingham on 2/13/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopUITestingKit -import XCTest - -class OnboardingScreen: BaseScreen { - - // MARK: Elements - - var loopLogo: XCUIElement { - app.images.matching(identifier: "loopLogo").firstMatch - } - - var simulatorAlert: XCUIElement { - app.alerts["Are you sure you want to skip the rest of onboarding (and use simulators)?"] - } - - var useSimulatorConfirmationButton: XCUIElement { - app.buttons["Yes"] - } - - var alertAllowButton:XCUIElement { - springboardApp.buttons["Allow"] - } - - var turnOnAllHealthCategoriesText: XCUIElement { - app.tables.staticTexts["Turn On All"] - } - - var healthDoneButton: XCUIElement { - app.navigationBars["Health Access"].buttons["Allow"] - } - - // MARK: Actions - - func skipAllOfOnboardingIfNeeded() { - if loopLogo.exists { - skipAllOfOnboarding() - } - } - - func skipAllOfOnboarding() { - allowSiri() - skipOnboarding() - allowNotificationsAuthorization() - allowHealthKitAuthorization() - } - - private func allowSiri() { - waitForExistence(alertAllowButton) - if alertAllowButton.exists { - alertAllowButton.tap() - } - } - - private func skipOnboarding() { - waitForExistence(loopLogo) - loopLogo.press(forDuration: 2) - } - - private func allowSimulatorAlert() { - waitForExistence(simulatorAlert) - useSimulatorConfirmationButton.tap() - } - - private func allowNotificationsAuthorization() { - waitForExistence(alertAllowButton) - alertAllowButton.tap() - } - - private func allowHealthKitAuthorization() { - waitForExistence(turnOnAllHealthCategoriesText) - turnOnAllHealthCategoriesText.tap() - healthDoneButton.tap() - } -} diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 87e795bf68..8810b2953a 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -244,10 +244,7 @@ 7D7076631FE06EE4004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076651FE06EE4004AC8EA /* Localizable.strings */; }; 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */; }; 845C74352B7D686000F71F90 /* LoopUITestingKit in Frameworks */ = {isa = PBXBuildFile; productRef = 845C74342B7D686000F71F90 /* LoopUITestingKit */; }; - 845C74372B7D686700F71F90 /* LoopUITestingKit in Frameworks */ = {isa = PBXBuildFile; productRef = 845C74362B7D686700F71F90 /* LoopUITestingKit */; }; 847434C82B7C17800084BE98 /* LoopUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847434C72B7C17800084BE98 /* LoopUITests.swift */; }; - 847434F62B7C41D30084BE98 /* DIYLoopUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847434F52B7C41D30084BE98 /* DIYLoopUITests.swift */; }; - 847435052B7C4F8D0084BE98 /* OnboardingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847435042B7C4F8D0084BE98 /* OnboardingScreen.swift */; }; 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */; }; @@ -606,13 +603,6 @@ remoteGlobalIDString = 43776F8B1B8022E90074EA36; remoteInfo = Loop; }; - 847434F92B7C41D30084BE98 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 43776F841B8022E90074EA36 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 43776F8B1B8022E90074EA36; - remoteInfo = Loop; - }; C117ED70232EDB3200DA57CD /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; @@ -1122,10 +1112,6 @@ 840B7A7E2B7BFF58000ED932 /* LoopUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoopUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 847434C72B7C17800084BE98 /* LoopUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopUITests.swift; sourceTree = ""; }; 847434DC2B7C34F70084BE98 /* LoopUITestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = LoopUITestPlan.xctestplan; sourceTree = ""; }; - 847434F32B7C41D30084BE98 /* DIYLoopUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DIYLoopUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 847434F52B7C41D30084BE98 /* DIYLoopUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DIYLoopUITests.swift; sourceTree = ""; }; - 847435022B7C42300084BE98 /* DIYLoopUITestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUITestPlan.xctestplan; sourceTree = ""; }; - 847435042B7C4F8D0084BE98 /* OnboardingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingScreen.swift; sourceTree = ""; }; 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; @@ -1719,14 +1705,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 847434F02B7C41D30084BE98 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 845C74372B7D686700F71F90 /* LoopUITestingKit in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; E9B07F79253BBA6500BAD8F8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1920,7 +1898,6 @@ 14B1736128AED9EC006CCD7C /* Loop Widget Extension */, A900531928D60852000BC15B /* Shortcuts */, 840B7A7F2B7BFF58000ED932 /* LoopUITests */, - 847434F42B7C41D30084BE98 /* DIYLoopUITests */, 968DCD53F724DE56FFE51920 /* Frameworks */, 43776F8D1B8022E90074EA36 /* Products */, 437D9BA11D7B5203007245E8 /* Loop.xcconfig */, @@ -1941,7 +1918,6 @@ E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */, 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */, 840B7A7E2B7BFF58000ED932 /* LoopUITests.xctest */, - 847434F32B7C41D30084BE98 /* DIYLoopUITests.xctest */, ); name = Products; sourceTree = ""; @@ -2442,24 +2418,6 @@ path = LoopUITests; sourceTree = ""; }; - 847434F42B7C41D30084BE98 /* DIYLoopUITests */ = { - isa = PBXGroup; - children = ( - 847435032B7C4F7D0084BE98 /* Screens */, - 847434F52B7C41D30084BE98 /* DIYLoopUITests.swift */, - 847435022B7C42300084BE98 /* DIYLoopUITestPlan.xctestplan */, - ); - path = DIYLoopUITests; - sourceTree = ""; - }; - 847435032B7C4F7D0084BE98 /* Screens */ = { - isa = PBXGroup; - children = ( - 847435042B7C4F8D0084BE98 /* OnboardingScreen.swift */, - ); - path = Screens; - sourceTree = ""; - }; 84AA81D12A4A2778000B658B /* Components */ = { isa = PBXGroup; children = ( @@ -3048,27 +3006,6 @@ productReference = 840B7A7E2B7BFF58000ED932 /* LoopUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; - 847434F22B7C41D30084BE98 /* DIYLoopUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 847434FE2B7C41D30084BE98 /* Build configuration list for PBXNativeTarget "DIYLoopUITests" */; - buildPhases = ( - 847434EF2B7C41D30084BE98 /* Sources */, - 847434F02B7C41D30084BE98 /* Frameworks */, - 847434F12B7C41D30084BE98 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 847434FA2B7C41D30084BE98 /* PBXTargetDependency */, - ); - name = DIYLoopUITests; - packageProductDependencies = ( - 845C74362B7D686700F71F90 /* LoopUITestingKit */, - ); - productName = DIYLoopUITests; - productReference = 847434F32B7C41D30084BE98 /* DIYLoopUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; E9B07F7B253BBA6500BAD8F8 /* Loop Intent Extension */ = { isa = PBXNativeTarget; buildConfigurationList = E9B07F9A253BBA6500BAD8F8 /* Build configuration list for PBXNativeTarget "Loop Intent Extension" */; @@ -3181,10 +3118,6 @@ LastSwiftMigration = 1520; TestTargetID = 43776F8B1B8022E90074EA36; }; - 847434F22B7C41D30084BE98 = { - CreatedOnToolsVersion = 15.2; - TestTargetID = 43776F8B1B8022E90074EA36; - }; E9B07F7B253BBA6500BAD8F8 = { ProvisioningStyle = Automatic; }; @@ -3241,7 +3174,6 @@ 4F75288A1DFE1DC600C322D6 /* LoopUI */, 43E2D90A1D20C581004DA55F /* LoopTests */, 840B7A7D2B7BFF58000ED932 /* LoopUITests */, - 847434F22B7C41D30084BE98 /* DIYLoopUITests */, ); }; /* End PBXProject section */ @@ -3357,13 +3289,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 847434F12B7C41D30084BE98 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; E9B07F7A253BBA6500BAD8F8 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -3976,15 +3901,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 847434EF2B7C41D30084BE98 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 847435052B7C4F8D0084BE98 /* OnboardingScreen.swift in Sources */, - 847434F62B7C41D30084BE98 /* DIYLoopUITests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; E9B07F78253BBA6500BAD8F8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -4043,11 +3959,6 @@ target = 43776F8B1B8022E90074EA36 /* Loop */; targetProxy = 840B7A842B7BFF58000ED932 /* PBXContainerItemProxy */; }; - 847434FA2B7C41D30084BE98 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 43776F8B1B8022E90074EA36 /* Loop */; - targetProxy = 847434F92B7C41D30084BE98 /* PBXContainerItemProxy */; - }; C117ED71232EDB3200DA57CD /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 43D9001A21EB209400AF44BF /* LoopCore-watchOS */; @@ -4936,7 +4847,6 @@ 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)"; @@ -4966,7 +4876,6 @@ 43776FB81B8022E90074EA36 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; @@ -5047,7 +4956,6 @@ 43A9439A1B926B7B0051FA24 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; @@ -5071,7 +4979,6 @@ 43A9439B1B926B7B0051FA24 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; @@ -5381,121 +5288,6 @@ }; name = Release; }; - 847434FB2B7C41D30084BE98 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - 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_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 75U4X84TEG; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.DIYLoopUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Loop; - }; - name = Debug; - }; - 847434FC2B7C41D30084BE98 /* Testflight */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - 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_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 75U4X84TEG; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.DIYLoopUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Loop; - }; - name = Testflight; - }; - 847434FD2B7C41D30084BE98 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - 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_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 75U4X84TEG; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.DIYLoopUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Loop; - }; - name = Release; - }; B4E7CF912AD00A39009B4DF2 /* Testflight */ = { isa = XCBuildConfiguration; baseConfigurationReference = 437D9BA11D7B5203007245E8 /* Loop.xcconfig */; @@ -5615,7 +5407,6 @@ B4E7CF922AD00A39009B4DF2 /* Testflight */ = { 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)"; @@ -5645,7 +5436,6 @@ B4E7CF942AD00A39009B4DF2 /* Testflight */ = { 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)"; @@ -6009,16 +5799,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 847434FE2B7C41D30084BE98 /* Build configuration list for PBXNativeTarget "DIYLoopUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 847434FB2B7C41D30084BE98 /* Debug */, - 847434FC2B7C41D30084BE98 /* Testflight */, - 847434FD2B7C41D30084BE98 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; E9B07F9A253BBA6500BAD8F8 /* Build configuration list for PBXNativeTarget "Loop Intent Extension" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -6072,11 +5852,6 @@ package = 845C74332B7D686000F71F90 /* XCRemoteSwiftPackageReference "LoopUITestingKit" */; productName = LoopUITestingKit; }; - 845C74362B7D686700F71F90 /* LoopUITestingKit */ = { - isa = XCSwiftPackageProductDependency; - package = 845C74332B7D686000F71F90 /* XCRemoteSwiftPackageReference "LoopUITestingKit" */; - productName = LoopUITestingKit; - }; C11B9D5A286778A800500CF8 /* SwiftCharts */ = { isa = XCSwiftPackageProductDependency; package = C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */; From 9b89125efa2fde36c4368d31867b650fc296338d Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 10 Sep 2024 16:43:45 -0700 Subject: [PATCH 159/184] Cleanup --- Loop.xcodeproj/project.pbxproj | 213 -------------------------- LoopUITests/LoopUITestPlan.xctestplan | 38 ----- LoopUITests/LoopUITests.swift | 169 -------------------- 3 files changed, 420 deletions(-) delete mode 100644 LoopUITests/LoopUITestPlan.xctestplan delete mode 100644 LoopUITests/LoopUITests.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 8810b2953a..6523cf6412 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -243,8 +243,6 @@ 7D70765E1FE06EE3004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076601FE06EE3004AC8EA /* Localizable.strings */; }; 7D7076631FE06EE4004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076651FE06EE4004AC8EA /* Localizable.strings */; }; 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */; }; - 845C74352B7D686000F71F90 /* LoopUITestingKit in Frameworks */ = {isa = PBXBuildFile; productRef = 845C74342B7D686000F71F90 /* LoopUITestingKit */; }; - 847434C82B7C17800084BE98 /* LoopUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847434C72B7C17800084BE98 /* LoopUITests.swift */; }; 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */; }; @@ -596,13 +594,6 @@ remoteGlobalIDString = 4F75288A1DFE1DC600C322D6; remoteInfo = LoopUI; }; - 840B7A842B7BFF58000ED932 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 43776F841B8022E90074EA36 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 43776F8B1B8022E90074EA36; - remoteInfo = Loop; - }; C117ED70232EDB3200DA57CD /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; @@ -1109,9 +1100,6 @@ 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; }; - 840B7A7E2B7BFF58000ED932 /* LoopUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoopUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 847434C72B7C17800084BE98 /* LoopUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopUITests.swift; sourceTree = ""; }; - 847434DC2B7C34F70084BE98 /* LoopUITestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = LoopUITestPlan.xctestplan; sourceTree = ""; }; 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; @@ -1697,14 +1685,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 840B7A7B2B7BFF58000ED932 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 845C74352B7D686000F71F90 /* LoopUITestingKit in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; E9B07F79253BBA6500BAD8F8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1897,7 +1877,6 @@ E9B07F7D253BBA6500BAD8F8 /* Loop Intent Extension */, 14B1736128AED9EC006CCD7C /* Loop Widget Extension */, A900531928D60852000BC15B /* Shortcuts */, - 840B7A7F2B7BFF58000ED932 /* LoopUITests */, 968DCD53F724DE56FFE51920 /* Frameworks */, 43776F8D1B8022E90074EA36 /* Products */, 437D9BA11D7B5203007245E8 /* Loop.xcconfig */, @@ -1917,7 +1896,6 @@ 43D9002A21EB209400AF44BF /* LoopCore.framework */, E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */, 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */, - 840B7A7E2B7BFF58000ED932 /* LoopUITests.xctest */, ); name = Products; sourceTree = ""; @@ -2409,15 +2387,6 @@ path = Common; sourceTree = ""; }; - 840B7A7F2B7BFF58000ED932 /* LoopUITests */ = { - isa = PBXGroup; - children = ( - 847434C72B7C17800084BE98 /* LoopUITests.swift */, - 847434DC2B7C34F70084BE98 /* LoopUITestPlan.xctestplan */, - ); - path = LoopUITests; - sourceTree = ""; - }; 84AA81D12A4A2778000B658B /* Components */ = { isa = PBXGroup; children = ( @@ -2985,27 +2954,6 @@ productReference = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; productType = "com.apple.product-type.framework"; }; - 840B7A7D2B7BFF58000ED932 /* LoopUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 840B7A862B7BFF59000ED932 /* Build configuration list for PBXNativeTarget "LoopUITests" */; - buildPhases = ( - 840B7A7A2B7BFF58000ED932 /* Sources */, - 840B7A7B2B7BFF58000ED932 /* Frameworks */, - 840B7A7C2B7BFF58000ED932 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 840B7A852B7BFF58000ED932 /* PBXTargetDependency */, - ); - name = LoopUITests; - packageProductDependencies = ( - 845C74342B7D686000F71F90 /* LoopUITestingKit */, - ); - productName = LoopUITests; - productReference = 840B7A7E2B7BFF58000ED932 /* LoopUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; E9B07F7B253BBA6500BAD8F8 /* Loop Intent Extension */ = { isa = PBXNativeTarget; buildConfigurationList = E9B07F9A253BBA6500BAD8F8 /* Build configuration list for PBXNativeTarget "Loop Intent Extension" */; @@ -3113,11 +3061,6 @@ LastSwiftMigration = 1020; ProvisioningStyle = Automatic; }; - 840B7A7D2B7BFF58000ED932 = { - CreatedOnToolsVersion = 15.2; - LastSwiftMigration = 1520; - TestTargetID = 43776F8B1B8022E90074EA36; - }; E9B07F7B253BBA6500BAD8F8 = { ProvisioningStyle = Automatic; }; @@ -3173,7 +3116,6 @@ 43D9001A21EB209400AF44BF /* LoopCore-watchOS */, 4F75288A1DFE1DC600C322D6 /* LoopUI */, 43E2D90A1D20C581004DA55F /* LoopTests */, - 840B7A7D2B7BFF58000ED932 /* LoopUITests */, ); }; /* End PBXProject section */ @@ -3282,13 +3224,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 840B7A7C2B7BFF58000ED932 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; E9B07F7A253BBA6500BAD8F8 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -3893,14 +3828,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 840B7A7A2B7BFF58000ED932 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 847434C82B7C17800084BE98 /* LoopUITests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; E9B07F78253BBA6500BAD8F8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -3954,11 +3881,6 @@ target = 4F75288A1DFE1DC600C322D6 /* LoopUI */; targetProxy = 4F7528961DFE1ED400C322D6 /* PBXContainerItemProxy */; }; - 840B7A852B7BFF58000ED932 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 43776F8B1B8022E90074EA36 /* Loop */; - targetProxy = 840B7A842B7BFF58000ED932 /* PBXContainerItemProxy */; - }; C117ED71232EDB3200DA57CD /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 43D9001A21EB209400AF44BF /* LoopCore-watchOS */; @@ -5168,126 +5090,6 @@ }; name = Release; }; - 840B7A872B7BFF59000ED932 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - 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_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 75U4X84TEG; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.LoopUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Loop; - }; - name = Debug; - }; - 840B7A882B7BFF59000ED932 /* Testflight */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - 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_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 75U4X84TEG; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.LoopUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Loop; - }; - name = Testflight; - }; - 840B7A892B7BFF59000ED932 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - 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_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 75U4X84TEG; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = org.tidepool.LoopUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Loop; - }; - name = Release; - }; B4E7CF912AD00A39009B4DF2 /* Testflight */ = { isa = XCBuildConfiguration; baseConfigurationReference = 437D9BA11D7B5203007245E8 /* Loop.xcconfig */; @@ -5789,16 +5591,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 840B7A862B7BFF59000ED932 /* Build configuration list for PBXNativeTarget "LoopUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 840B7A872B7BFF59000ED932 /* Debug */, - 840B7A882B7BFF59000ED932 /* Testflight */, - 840B7A892B7BFF59000ED932 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; E9B07F9A253BBA6500BAD8F8 /* Build configuration list for PBXNativeTarget "Loop Intent Extension" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -5847,11 +5639,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 845C74342B7D686000F71F90 /* LoopUITestingKit */ = { - isa = XCSwiftPackageProductDependency; - package = 845C74332B7D686000F71F90 /* XCRemoteSwiftPackageReference "LoopUITestingKit" */; - productName = LoopUITestingKit; - }; C11B9D5A286778A800500CF8 /* SwiftCharts */ = { isa = XCSwiftPackageProductDependency; package = C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */; diff --git a/LoopUITests/LoopUITestPlan.xctestplan b/LoopUITests/LoopUITestPlan.xctestplan deleted file mode 100644 index 44051bccdd..0000000000 --- a/LoopUITests/LoopUITestPlan.xctestplan +++ /dev/null @@ -1,38 +0,0 @@ -{ - "configurations" : [ - { - "id" : "E21F6FDF-4D9A-44ED-99CD-2F9CA0B20D37", - "name" : "Configuration 1", - "options" : { - "environmentVariableEntries" : [ - { - "key" : "appName", - "value" : "Loop" - }, - { - "key" : "bundleIdentifier", - "value" : "org.tidepool.Loop" - } - ] - } - } - ], - "defaultOptions" : { - "targetForVariableExpansion" : { - "containerPath" : "container:..\/Loop\/Loop.xcodeproj", - "identifier" : "43776F8B1B8022E90074EA36", - "name" : "Loop" - }, - "testTimeoutsEnabled" : true - }, - "testTargets" : [ - { - "target" : { - "containerPath" : "container:..\/Loop\/Loop.xcodeproj", - "identifier" : "840B7A7D2B7BFF58000ED932", - "name" : "LoopUITests" - } - } - ], - "version" : 1 -} diff --git a/LoopUITests/LoopUITests.swift b/LoopUITests/LoopUITests.swift deleted file mode 100644 index 71d0f14c2a..0000000000 --- a/LoopUITests/LoopUITests.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// LoopUITests.swift -// LoopUITests -// -// Created by Cameron Ingham on 2/13/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopUITestingKit -import XCTest - -@MainActor -final class LoopUITests: XCTestCase { - private let app = XCUIApplication() - var baseScreen: BaseScreen! - var homeScreen: HomeScreen! - var settingsScreen: SettingsScreen! - var systemSettingsScreen: SystemSettingsScreen! - var pumpSimulatorScreen: PumpSimulatorScreen! - - override func setUpWithError() throws { - continueAfterFailure = false - app.launch() - baseScreen = BaseScreen(app: app) - homeScreen = HomeScreen(app: app) - settingsScreen = SettingsScreen(app: app) - systemSettingsScreen = SystemSettingsScreen() - pumpSimulatorScreen = PumpSimulatorScreen(app: app) - } - - // https://tidepool.atlassian.net/browse/LOOP-1605 - func testAlertSettingsUI() { - systemSettingsScreen.launchApp() - systemSettingsScreen.openAppSystemSettings() - systemSettingsScreen.openSystemNotificationSettings() - systemSettingsScreen.toggleAllowNotifications() - systemSettingsScreen.toggleCriticalAlerts() - homeScreen.openSettings() - waitForExistence(settingsScreen.alertManagementAlertWarning) - settingsScreen.openAlertManagement() - waitForExistence(settingsScreen.alertPermissionsWarning) - settingsScreen.openAlertPermissions() - waitForExistence(settingsScreen.alertPermissionsNotificationsDisabled) - waitForExistence(settingsScreen.alertPermissionsCriticalAlertsDisabled) - settingsScreen.openPermissionsInSettings() - systemSettingsScreen.app.activate() - systemSettingsScreen.toggleAllowNotifications() - app.activate() - waitForExistence(settingsScreen.alertPermissionsNotificationsEnabled) - systemSettingsScreen.app.activate() - systemSettingsScreen.toggleCriticalAlerts() - app.activate() - waitForExistence(settingsScreen.alertPermissionsCriticalAlertsEnabled) - } - - // https://tidepool.atlassian.net/browse/LOOP-1713 - func testConfigureClosedLoopManagement() { - waitForExistence(homeScreen.hudStatusClosedLoop) - waitForExistence(homeScreen.preMealTabEnabled) - homeScreen.tapPreMealButton() - homeScreen.dismissPreMealConfirmationDialog() - homeScreen.openSettings() - settingsScreen.toggleClosedLoop() - settingsScreen.closeSettingsScreen() - waitForExistence(homeScreen.hudStatusOpenLoop) - waitForExistence(homeScreen.preMealTabDisabled) - homeScreen.tapLoopStatusOpen() - waitForExistence(homeScreen.closedLoopOffAlertTitle) - homeScreen.closeLoopStatusAlert() - homeScreen.tapBolusEntry() - waitForExistence(homeScreen.simpleBolusCalculatorTitle) - homeScreen.closeSimpleBolusEntry() - homeScreen.tapCarbEntry() - waitForExistence(homeScreen.simpleMealCalculatorTitle) - homeScreen.closeSimpleCarbEntry() - homeScreen.openSettings() - settingsScreen.toggleClosedLoop() - settingsScreen.closeSettingsScreen() - waitForExistence(homeScreen.hudStatusClosedLoop) - waitForExistence(homeScreen.preMealTabEnabled) - homeScreen.tapLoopStatusClosed() - waitForExistence(homeScreen.closedLoopOnAlertTitle) - homeScreen.closeLoopStatusAlert() - homeScreen.tapBolusEntry() - waitForExistence(homeScreen.bolusTitle) - homeScreen.closeBolusEntry() - homeScreen.tapCarbEntry() - waitForExistence(homeScreen.carbEntryTitle) - homeScreen.closeMealEntry() - } - - // https://tidepool.atlassian.net/browse/LOOP-1636 - func testPumpErrorAndStateHandlingStatusBarDisplay() { - waitForExistence(homeScreen.hudStatusClosedLoop) - homeScreen.tapPumpPill() - pumpSimulatorScreen.tapSuspendInsulinButton() - waitForExistence(pumpSimulatorScreen.resumeInsulinButton) - pumpSimulatorScreen.closePumpSimulator() - waitForExistence(homeScreen.hudPumpPill) - XCTAssertEqual(homeScreen.hudPumpPill.value as? String, NSLocalizedString("Insulin Suspended", comment: "")) - homeScreen.tapPumpPill() - pumpSimulatorScreen.tapResumeInsulinButton() - waitForExistence(pumpSimulatorScreen.suspendInsulinButton) - pumpSimulatorScreen.openPumpSettings() - pumpSimulatorScreen.tapReservoirRemainingRow() - pumpSimulatorScreen.tapReservoirRemainingTextField() - pumpSimulatorScreen.clearReservoirRemainingTextField() - app.typeText("0") - pumpSimulatorScreen.closeReservoirRemainingScreen() - pumpSimulatorScreen.closePumpSettings() - pumpSimulatorScreen.closePumpSimulator() - waitForExistence(homeScreen.hudPumpPill) - XCTAssertEqual(homeScreen.hudPumpPill.value as? String, NSLocalizedString("No Insulin", comment: "")) - homeScreen.tapPumpPill() - pumpSimulatorScreen.openPumpSettings() - pumpSimulatorScreen.tapReservoirRemainingRow() - pumpSimulatorScreen.tapReservoirRemainingTextField() - pumpSimulatorScreen.clearReservoirRemainingTextField() - app.typeText("15") - pumpSimulatorScreen.closeReservoirRemainingScreen() - pumpSimulatorScreen.closePumpSettings() - pumpSimulatorScreen.closePumpSimulator() - waitForExistence(homeScreen.hudPumpPill) - XCTAssert((homeScreen.hudPumpPill.value as? String)?.contains("15 units remaining") == true) - homeScreen.tapPumpPill() - pumpSimulatorScreen.openPumpSettings() - pumpSimulatorScreen.tapReservoirRemainingRow() - pumpSimulatorScreen.tapReservoirRemainingTextField() - pumpSimulatorScreen.clearReservoirRemainingTextField() - app.typeText("45") - pumpSimulatorScreen.closeReservoirRemainingScreen() - pumpSimulatorScreen.closePumpSettings() - pumpSimulatorScreen.closePumpSimulator() - waitForExistence(homeScreen.hudPumpPill) - XCTAssert((homeScreen.hudPumpPill.value as? String)?.contains("45 units remaining") == true) - homeScreen.tapPumpPill() - pumpSimulatorScreen.openPumpSettings() - pumpSimulatorScreen.tapDetectOcclusionButton() - pumpSimulatorScreen.closePumpSettings() - pumpSimulatorScreen.closePumpSimulator() - waitForExistence(homeScreen.hudPumpPill) - XCTAssertEqual(homeScreen.hudPumpPill.value as? String, NSLocalizedString("Pump Occlusion", comment: "")) - homeScreen.tapBolusEntry() - homeScreen.tapBolusEntryTextField() - app.typeText("2") - homeScreen.closeKeyboard() - homeScreen.tapDeliverBolusButton() - homeScreen.enterPasscode() - homeScreen.verifyOcclusionAlert() - homeScreen.tapPumpPill() - pumpSimulatorScreen.openPumpSettings() - pumpSimulatorScreen.tapResolveOcclusionButton() - pumpSimulatorScreen.tapCausePumpErrorButton() - pumpSimulatorScreen.closePumpSettings() - pumpSimulatorScreen.closePumpSimulator() - waitForExistence(homeScreen.hudPumpPill) - XCTAssertEqual(homeScreen.hudPumpPill.value as? String, NSLocalizedString("Pump Error", comment: "")) - homeScreen.tapPumpPill() - pumpSimulatorScreen.openPumpSettings() - pumpSimulatorScreen.tapResolvePumpErrorButton() - pumpSimulatorScreen.tapReservoirRemainingRow() - pumpSimulatorScreen.tapReservoirRemainingTextField() - pumpSimulatorScreen.clearReservoirRemainingTextField() - app.typeText("165") - pumpSimulatorScreen.closeReservoirRemainingScreen() - pumpSimulatorScreen.closePumpSettings() - pumpSimulatorScreen.closePumpSimulator() - } -} From d4cb32fda584b21875ff922db2ff7d55cde33fcb Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 10 Sep 2024 16:47:55 -0700 Subject: [PATCH 160/184] Cleanup --- LoopUITests/DIYLoopUnitTestPlan.xctestplan | 113 --------- LoopUITests/Screens/BaseScreen.swift | 43 ---- LoopUITests/Screens/HomeScreen.swift | 235 ------------------ LoopUITests/Screens/OnboardingScreen.swift | 83 ------- LoopUITests/Screens/PumpSimulatorScreen.swift | 133 ---------- LoopUITests/Screens/SettingsScreen.swift | 125 ---------- .../Screens/SystemSettingsScreen.swift | 62 ----- 7 files changed, 794 deletions(-) delete mode 100644 LoopUITests/DIYLoopUnitTestPlan.xctestplan delete mode 100644 LoopUITests/Screens/BaseScreen.swift delete mode 100644 LoopUITests/Screens/HomeScreen.swift delete mode 100644 LoopUITests/Screens/OnboardingScreen.swift delete mode 100644 LoopUITests/Screens/PumpSimulatorScreen.swift delete mode 100644 LoopUITests/Screens/SettingsScreen.swift delete mode 100644 LoopUITests/Screens/SystemSettingsScreen.swift diff --git a/LoopUITests/DIYLoopUnitTestPlan.xctestplan b/LoopUITests/DIYLoopUnitTestPlan.xctestplan deleted file mode 100644 index 844d8fb21e..0000000000 --- a/LoopUITests/DIYLoopUnitTestPlan.xctestplan +++ /dev/null @@ -1,113 +0,0 @@ -{ - "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:..\/Common\/TidepoolService\/TidepoolService.xcodeproj", - "identifier" : "A9DAAD0622E7987800E76C9F", - "name" : "TidepoolServiceKitTests" - } - }, - { - "target" : { - "containerPath" : "container:..\/Common\/TidepoolService\/TidepoolService.xcodeproj", - "identifier" : "A9DAAD2222E7988900E76C9F", - "name" : "TidepoolServiceKitUITests" - } - }, - { - "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/LoopUITests/Screens/BaseScreen.swift b/LoopUITests/Screens/BaseScreen.swift deleted file mode 100644 index 3acf372cd1..0000000000 --- a/LoopUITests/Screens/BaseScreen.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// BaseScreen.swift -// LoopUITests -// -// Created by Ginny Yadav on 10/27/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import XCTest - -class BaseScreen { - var app: XCUIApplication - var springboardApp: XCUIApplication - var bundleIdentifier: String? - - init(app: XCUIApplication) { - self.app = app - self.springboardApp = XCUIApplication(bundleIdentifier:"com.apple.springboard") - self.bundleIdentifier = Bundle.main.bundleIdentifier - } - - func deleteApp() { - XCUIApplication().terminate() - - let icon = springboardApp.icons["Tidepool Loop"] - if icon.exists { - let iconFrame = icon.frame - let springboardFrame = springboardApp.frame - icon.press(forDuration: 5) - - // Tap the little "X" button at approximately where it is. The X is not exposed directly - springboardApp.coordinate(withNormalizedOffset: CGVector(dx: (iconFrame.minX + 3) / springboardFrame.maxX, dy: (iconFrame.minY + 3) / springboardFrame.maxY)).tap() - - springboardApp.alerts.buttons["Delete App"].tap() - - waitForExistence(springboardApp.alerts.buttons["Delete"]) - springboardApp.alerts.buttons["Delete"].tap() - - waitForExistence(springboardApp.alerts.buttons["OK"]) - springboardApp.alerts.buttons["OK"].tap() - } - } -} diff --git a/LoopUITests/Screens/HomeScreen.swift b/LoopUITests/Screens/HomeScreen.swift deleted file mode 100644 index 6b2da6a8a3..0000000000 --- a/LoopUITests/Screens/HomeScreen.swift +++ /dev/null @@ -1,235 +0,0 @@ -// -// OnboardingScreen.swift -// LoopUITests -// -// Created by Ginny Yadav on 10/27/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. - -// This is a page file. -// It's intention is to map out all the locators for a particular section of the app. -// If the locator uses a label please use the localization key -// If the locator uses an accesibility ID you don't need the localization key - -import XCTest - -class HomeScreen: BaseScreen { - - // MARK: Elements - - var hudStatusClosedLoop: XCUIElement { - app.descendants(matching: .any).matching(identifier: "loopCompletionHUDLoopStatusClosed").firstMatch - } - - var hudPumpPill: XCUIElement { - app.descendants(matching: .any).matching(identifier: "pumpHUDView").firstMatch - } - - var closedLoopOnAlertTitle: XCUIElement { - app.staticTexts["Closed Loop ON"] - } - - var hudStatusOpenLoop: XCUIElement { - app.descendants(matching: .any).matching(identifier: "loopCompletionHUDLoopStatusOpen").firstMatch - } - - var closedLoopOffAlertTitle: XCUIElement { - app.staticTexts["Closed Loop OFF"] - } - - var preMealTabEnabled: XCUIElement { - app.descendants(matching: .any).matching(identifier: "statusTableViewPreMealButtonEnabled").firstMatch - } - - var preMealTabDisabled: XCUIElement { - app.descendants(matching: .any).matching(identifier: "statusTableViewPreMealButtonDisabled").firstMatch - } - - var settingsTab: XCUIElement { - app.descendants(matching: .any).matching(identifier: "statusTableViewControllerSettingsButton").firstMatch - } - - var carbsTab: XCUIElement { - app.descendants(matching: .any).matching(identifier: "statusTableViewControllerCarbsButton").firstMatch - } - - var carbEntryTitle: XCUIElement { - app.navigationBars.staticTexts["Add Carb Entry"] - } - - var carbEntryCancelButton: XCUIElement { - app.navigationBars["Add Carb Entry"].buttons["Cancel"] - } - - var simpleMealCalculatorTitle: XCUIElement { - app.navigationBars.staticTexts["Simple Meal Calculator"] - } - - var simpleMealCalculatorCancelButton: XCUIElement { - app.navigationBars["Simple Meal Calculator"].buttons["Cancel"] - } - - var bolusTab: XCUIElement { - app.descendants(matching: .any).matching(identifier: "statusTableViewControllerBolusButton").firstMatch - } - - var bolusTitle: XCUIElement { - app.navigationBars.staticTexts["Bolus"] - } - - var bolusEntryViewBolusEntryRow: XCUIElement { - app.descendants(matching: .any).matching(identifier: "dismissibleKeyboardTextField").firstMatch - } - - var bolusCancelButton: XCUIElement { - app.navigationBars["Bolus"].buttons["Cancel"] - } - - var simpleBolusCalculatorTitle: XCUIElement { - app.navigationBars.staticTexts["Simple Bolus Calculator"] - } - - var simpleBolusCalculatorCancelButton: XCUIElement { - app.navigationBars["Simple Bolus Calculator"].buttons["Cancel"] - } - - var safetyNotificationsAlertTitle: XCUIElement { - app.alerts["\n\nWarning! Safety notifications are turned OFF"] - } - - var safetyNotificationsAlertCloseButton: XCUIElement { - app.alerts.firstMatch.buttons["Close"] - } - - var alertDismissButton: XCUIElement { - app.buttons["Dismiss"] - } - - var confirmationDialogCancelButton: XCUIElement { - app.buttons["Cancel"] - } - - var keyboardDoneButton: XCUIElement { - app.toolbars.firstMatch.buttons["Done"].firstMatch - } - - var deliverBolusButton: XCUIElement { - app.buttons["Deliver"] - } - - var notification: XCUIElement { - springboardApp.descendants(matching: .any).matching(identifier: "NotificationShortLookView").firstMatch - } - - var bolusIssueNotificationTitle: XCUIElement { - app.alerts["Bolus Issue"] - } - - var passcodeEntry: XCUIElement { - springboardApp.secureTextFields["Passcode field"] - } - - var springboardKeyboardDoneButton: XCUIElement { - springboardApp.keyboards.buttons["done"] - } - - // MARK: Actions - - func openSettings() { - waitForExistence(settingsTab) - settingsTab.tap() - } - - func tapSafetyNotificationAlertCloseButton() { - waitForExistence(safetyNotificationsAlertCloseButton) - safetyNotificationsAlertCloseButton.tap() - } - - func tapLoopStatusOpen() { - waitForExistence(hudStatusOpenLoop) - hudStatusOpenLoop.tap() - } - - func tapLoopStatusClosed() { - waitForExistence(hudStatusClosedLoop) - hudStatusClosedLoop.tap() - } - - func closeLoopStatusAlert() { - waitForExistence(alertDismissButton) - alertDismissButton.tap() - } - - func tapPreMealButton() { - waitForExistence(preMealTabEnabled) - preMealTabEnabled.tap() - } - - func dismissPreMealConfirmationDialog() { - waitForExistence(confirmationDialogCancelButton) - confirmationDialogCancelButton.tap() - } - - func tapCarbEntry() { - waitForExistence(carbsTab) - carbsTab.tap() - } - - func closeMealEntry() { - waitForExistence(carbEntryCancelButton) - carbEntryCancelButton.tap() - } - - func closeSimpleCarbEntry() { - waitForExistence(simpleMealCalculatorCancelButton) - simpleMealCalculatorCancelButton.tap() - } - - func tapBolusEntry() { - waitForExistence(bolusTab) - bolusTab.tap() - } - - func closeBolusEntry() { - waitForExistence(bolusCancelButton) - bolusCancelButton.tap() - } - - func closeSimpleBolusEntry() { - waitForExistence(simpleBolusCalculatorCancelButton) - simpleBolusCalculatorCancelButton.tap() - } - - func tapPumpPill() { - waitForExistence(hudPumpPill) - hudPumpPill.tap() - } - - func tapBolusEntryTextField() { - waitForExistence(bolusEntryViewBolusEntryRow) - bolusEntryViewBolusEntryRow.tap() - } - - func closeKeyboard() { - waitForExistence(keyboardDoneButton) - keyboardDoneButton.tap() - } - - func tapDeliverBolusButton() { - waitForExistence(deliverBolusButton) - deliverBolusButton.forceTap() - } - - func verifyOcclusionAlert() { -// waitForExistence(notification) -// notification.tap() -// waitForExistence(bolusIssueNotificationTitle) -// app.activate() - #warning("FIXME") - } - - func enterPasscode() { - waitForExistence(passcodeEntry) - passcodeEntry.tap() - springboardApp.typeText("1\n") - } -} diff --git a/LoopUITests/Screens/OnboardingScreen.swift b/LoopUITests/Screens/OnboardingScreen.swift deleted file mode 100644 index c9072e53f3..0000000000 --- a/LoopUITests/Screens/OnboardingScreen.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// OnboardingScreen.swift -// LoopUITests -// -// Created by Ginny Yadav on 10/27/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. - -// This is a page file. -// It's intention is to map out all the locators for a particular section of the app. -// If the locator uses a label please use the localization key -// If the locator uses an accesibility ID you don't need the localization key - -import XCTest - -class OnboardingScreen: BaseScreen { - - // MARK: Elements - - var welcomeTitleText: XCUIElement { - app.staticTexts.element(matching: .staticText, identifier: "welcome data 0") - } - - var simulatorAlert: XCUIElement { - app.alerts["Are you sure you want to skip the rest of onboarding (and use simulators)?"] - } - - var useSimulatorConfirmationButton: XCUIElement { - app.buttons["Yes"] - } - - var alertAllowButton:XCUIElement { - springboardApp.buttons["Allow"] - } - - var turnOnAllHealthCategoriesText: XCUIElement { - app.tables.staticTexts["Turn On All"] - } - - var healthDoneButton: XCUIElement { - app.navigationBars["Health Access"].buttons["Allow"] - } - - // MARK: Actions - - func skipAllOfOnboardingIfNeeded() { - if welcomeTitleText.exists { - skipAllOfOnboarding() - } - } - - func skipAllOfOnboarding() { - skipOnboarding() - allowSimulatorAlert() - allowNotificationsAuthorization() - allowCriticalAlertsAuthorization() - allowHealthKitAuthorization() - } - - private func skipOnboarding() { - welcomeTitleText.press(forDuration: 2.5) - } - - private func allowSimulatorAlert() { - waitForExistence(simulatorAlert) - useSimulatorConfirmationButton.tap() - } - - private func allowNotificationsAuthorization() { - waitForExistence(alertAllowButton) - alertAllowButton.tap() - } - - private func allowCriticalAlertsAuthorization() { - waitForExistence(alertAllowButton) - alertAllowButton.tap() - } - - private func allowHealthKitAuthorization() { - waitForExistence(turnOnAllHealthCategoriesText) - turnOnAllHealthCategoriesText.tap() - healthDoneButton.tap() - } -} diff --git a/LoopUITests/Screens/PumpSimulatorScreen.swift b/LoopUITests/Screens/PumpSimulatorScreen.swift deleted file mode 100644 index de4d237524..0000000000 --- a/LoopUITests/Screens/PumpSimulatorScreen.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// PumpSimulatorScreen.swift -// LoopUITests -// -// Created by Cameron Ingham on 2/6/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import XCTest - -final class PumpSimulatorScreen: BaseScreen { - - // MARK: Elements - - var suspendInsulinButton: XCUIElement { - app.descendants(matching: .any).buttons["Suspend Insulin Delivery"] - } - - var resumeInsulinButton: XCUIElement { - app.descendants(matching: .any).buttons["Tap to Resume Insulin Delivery"] - } - - var doneButton: XCUIElement { - app.navigationBars["Pump Simulator"].buttons["Done"] - } - - var pumpProgressView: XCUIElement { - app.descendants(matching: .any).matching(identifier: "mockPumpManagerProgressView").firstMatch - } - - var reservoirRemainingButton: XCUIElement { - app.descendants(matching: .any).matching(identifier: "mockPumpSettingsReservoirRemaining").firstMatch - } - - var reservoirRemainingTextField: XCUIElement { - app.descendants(matching: .any).textFields.firstMatch - } - - var pumpSettingsBackButton: XCUIElement { - app.navigationBars.firstMatch.buttons["Back"] - } - - var reservoirRemainingBackButton: XCUIElement { - app.navigationBars.firstMatch.buttons["Back"] - } - - var detectOcclusionButton: XCUIElement { - app.staticTexts["Detect Occlusion"] - } - - var resolveOcclusionButton: XCUIElement { - app.staticTexts["Resolve Occlusion"] - } - - var causePumpErrorButton: XCUIElement { - app.staticTexts["Cause Pump Error"] - } - - var resolvePumpErrorButton: XCUIElement { - app.staticTexts["Resolve Pump Error"] - } - - // MARK: Actions - - func tapSuspendInsulinButton() { - waitForExistence(suspendInsulinButton) - suspendInsulinButton.tap() - } - - func tapResumeInsulinButton() { - waitForExistence(resumeInsulinButton) - resumeInsulinButton.tap() - } - - func closePumpSimulator() { - waitForExistence(doneButton) - doneButton.tap() - } - - func openPumpSettings() { - waitForExistence(pumpProgressView) - pumpProgressView.press(forDuration: 10) - } - - func closePumpSettings() { - waitForExistence(pumpSettingsBackButton) - pumpSettingsBackButton.tap() - } - - func tapReservoirRemainingRow() { - waitForExistence(reservoirRemainingButton) - reservoirRemainingButton.tap() - } - - func tapReservoirRemainingTextField() { - waitForExistence(reservoirRemainingTextField) - reservoirRemainingTextField.tap() - } - - func clearReservoirRemainingTextField() { - guard let value = reservoirRemainingTextField.value as? String else { - XCTFail() - return - } - - app.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: value.count)) - } - - func closeReservoirRemainingScreen() { - waitForExistence(reservoirRemainingBackButton) - reservoirRemainingBackButton.tap() - } - - func tapDetectOcclusionButton() { - waitForExistence(detectOcclusionButton) - detectOcclusionButton.tap() - } - - func tapResolveOcclusionButton() { - waitForExistence(resolveOcclusionButton) - resolveOcclusionButton.tap() - } - - func tapCausePumpErrorButton() { - waitForExistence(causePumpErrorButton) - causePumpErrorButton.tap() - } - - func tapResolvePumpErrorButton() { - waitForExistence(resolvePumpErrorButton) - resolvePumpErrorButton.tap() - } -} diff --git a/LoopUITests/Screens/SettingsScreen.swift b/LoopUITests/Screens/SettingsScreen.swift deleted file mode 100644 index 6c17ad273b..0000000000 --- a/LoopUITests/Screens/SettingsScreen.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// SettingsScreen.swift -// LoopUITests -// -// Created by Cameron Ingham on 2/2/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import XCTest - -final class SettingsScreen: BaseScreen { - - // MARK: Elements - - var insulinPump: XCUIElement { - app.descendants(matching: .any).matching(identifier: "settingsViewInsulinPump").firstMatch - } - - var pumpSimulatorTitle: XCUIElement { - app.navigationBars.staticTexts["Pump Simulator"] - } - - var pumpSimulatorDoneButton: XCUIElement { - app.navigationBars["Pump Simulator"].buttons["Done"] - } - - var cgm: XCUIElement { - app.descendants(matching: .any).matching(identifier: "settingsViewCGM").firstMatch - } - - var cgmSimulatorTitle: XCUIElement { - app.navigationBars.staticTexts["CGM Simulator"] - } - - var cgmSimulatorDoneButton: XCUIElement { - app.navigationBars["CGM Simulator"].buttons["Done"] - } - - var settingsDoneButton: XCUIElement { - app.navigationBars["Settings"].buttons["Done"] - } - - var alertManagementAlertWarning: XCUIElement { - app.descendants(matching: .any).matching(identifier: "settingsViewAlertManagementAlertWarning").firstMatch - } - - var alertManagement: XCUIElement { - app.descendants(matching: .any).matching(identifier: "settingsViewAlertManagement").firstMatch - } - - var alertPermissionsWarning: XCUIElement { - app.descendants(matching: .any).matching(identifier: "settingsViewAlertManagementAlertPermissionsAlertWarning").firstMatch - } - - var managePermissionsInSettings: XCUIElement { - app.descendants(matching: .any).buttons["Manage Permissions in Settings"] - } - - var alertPermissionsNotificationsEnabled: XCUIElement { - app.staticTexts["settingsViewAlertManagementAlertPermissionsNotificationsEnabled"] - } - - var alertPermissionsNotificationsDisabled: XCUIElement { - app.staticTexts["settingsViewAlertManagementAlertPermissionsNotificationsDisabled"] - } - - var alertPermissionsCriticalAlertsEnabled: XCUIElement { - app.staticTexts["settingsViewAlertManagementAlertPermissionsCriticalAlertsEnabled"] - } - - var alertPermissionsCriticalAlertsDisabled: XCUIElement { - app.staticTexts["settingsViewAlertManagementAlertPermissionsCriticalAlertsDisabled"] - } - - var closedLoopToggle: XCUIElement { - app.descendants(matching: .any).matching(identifier: "settingsViewClosedLoopToggle").switches.firstMatch - } - - // MARK: Actions - - func openPumpManager() { - waitForExistence(insulinPump) - insulinPump.tap() - } - - func closePumpSimulator() { - waitForExistence(pumpSimulatorDoneButton) - pumpSimulatorDoneButton.tap() - } - - func openCGMManager() { - waitForExistence(cgm) - cgm.tap() - } - - func closeCGMSimulator() { - waitForExistence(cgmSimulatorDoneButton) - cgmSimulatorDoneButton.tap() - } - - func closeSettingsScreen() { - waitForExistence(settingsDoneButton) - settingsDoneButton.tap() - } - - func openAlertManagement() { - waitForExistence(alertManagement) - alertManagement.tap() - } - - func openAlertPermissions() { - waitForExistence(alertPermissionsWarning) - alertPermissionsWarning.tap() - } - - func openPermissionsInSettings() { - waitForExistence(managePermissionsInSettings) - managePermissionsInSettings.tap() - } - - func toggleClosedLoop() { - waitForExistence(closedLoopToggle) - closedLoopToggle.tap() - } -} diff --git a/LoopUITests/Screens/SystemSettingsScreen.swift b/LoopUITests/Screens/SystemSettingsScreen.swift deleted file mode 100644 index 1b998710d8..0000000000 --- a/LoopUITests/Screens/SystemSettingsScreen.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// SystemSettingsScreen.swift -// LoopUITests -// -// Created by Cameron Ingham on 2/2/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import XCTest - -final class SystemSettingsScreen: BaseScreen { - - // MARK: Elements - - var loopCell: XCUIElement { - app.cells["Tidepool Loop"] - } - - var notificationsButton: XCUIElement { - app.descendants(matching: .any).element(matching: .button, identifier: "NOTIFICATIONS") - } - - var allowNotificationsToggle: XCUIElement { - app.switches["Allow Notifications"] - } - - var criticalAlertsToggle: XCUIElement { - app.switches["Critical Alerts"] - } - - // MARK: Initializers - - init() { - super.init(app: XCUIApplication(bundleIdentifier: "com.apple.Preferences")) - } - - // MARK: Actions - - func launchApp() { - app.launch() - } - - func openAppSystemSettings() { - waitForExistence(loopCell) - loopCell.tap() - } - - func openSystemNotificationSettings() { - waitForExistence(notificationsButton) - notificationsButton.tap() - } - - func toggleAllowNotifications() { - waitForExistence(allowNotificationsToggle) - allowNotificationsToggle.tap() - } - - func toggleCriticalAlerts() { - waitForExistence(criticalAlertsToggle) - criticalAlertsToggle.tap() - } -} From 48c44ac245d868ab8f38be7abe195a459d11c7c4 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 10 Sep 2024 17:04:12 -0700 Subject: [PATCH 161/184] Cleanup --- Loop.xcodeproj/project.pbxproj | 4 + LoopTests/DIYLoopUnitTestPlan.xctestplan | 113 +++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 LoopTests/DIYLoopUnitTestPlan.xctestplan diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 6523cf6412..4c9d1b39cb 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -252,6 +252,7 @@ 84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */; }; 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E62A4A4DEF000B658B /* PumpView.swift */; }; 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; + 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */; }; 892A5D59222F0A27008961AB /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */; }; @@ -1109,6 +1110,7 @@ 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkManager.swift; sourceTree = ""; }; 84AA81E62A4A4DEF000B658B /* PumpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpView.swift; sourceTree = ""; }; 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.swift; sourceTree = ""; }; + 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlowViewModel.swift; sourceTree = ""; }; 892A5D29222EF60A008961AB /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKit.framework; path = Carthage/Build/iOS/MockKit.framework; sourceTree = SOURCE_ROOT; }; 892A5D2B222EF60A008961AB /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKitUI.framework; path = Carthage/Build/iOS/MockKitUI.framework; sourceTree = SOURCE_ROOT; }; @@ -2232,6 +2234,7 @@ C188599C2AF15F9A0010F21F /* Mocks */, A9E6DFED246A0460005B1A1C /* Models */, B4BC56362518DE8800373647 /* ViewModels */, + 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */, ); path = LoopTests; sourceTree = ""; @@ -3200,6 +3203,7 @@ E9B3553A293706CB0076AB04 /* missed_meal_counteraction_effect.json in Resources */, E9B35538293706CB0076AB04 /* needs_clamping_counteraction_effect.json in Resources */, E9C58A8024DB529A00487A17 /* insulin_effect.json in Resources */, + 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */, C16FC0B02A99392F0025E239 /* live_capture_input.json in Resources */, E9B3553B293706CB0076AB04 /* noisy_cgm_counteraction_effect.json in Resources */, E9B3553C293706CB0076AB04 /* realistic_report_counteraction_effect.json in Resources */, diff --git a/LoopTests/DIYLoopUnitTestPlan.xctestplan b/LoopTests/DIYLoopUnitTestPlan.xctestplan new file mode 100644 index 0000000000..844d8fb21e --- /dev/null +++ b/LoopTests/DIYLoopUnitTestPlan.xctestplan @@ -0,0 +1,113 @@ +{ + "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:..\/Common\/TidepoolService\/TidepoolService.xcodeproj", + "identifier" : "A9DAAD0622E7987800E76C9F", + "name" : "TidepoolServiceKitTests" + } + }, + { + "target" : { + "containerPath" : "container:..\/Common\/TidepoolService\/TidepoolService.xcodeproj", + "identifier" : "A9DAAD2222E7988900E76C9F", + "name" : "TidepoolServiceKitUITests" + } + }, + { + "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 +} From e0d901e92900d639978f9d03137f9a786b5fc957 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 11 Sep 2024 09:49:11 -0700 Subject: [PATCH 162/184] remove tidepool references --- Loop.xcodeproj/project.pbxproj | 9 --------- LoopTests/DIYLoopUnitTestPlan.xctestplan | 14 -------------- 2 files changed, 23 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 4c9d1b39cb..4644cecdb3 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -3104,7 +3104,6 @@ C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */, C1D6EE9E2A06C7270047DE5C /* XCRemoteSwiftPackageReference "MKRingProgressView" */, C1735B1C2A0809830082BB8A /* XCRemoteSwiftPackageReference "ZIPFoundation" */, - 845C74332B7D686000F71F90 /* XCRemoteSwiftPackageReference "LoopUITestingKit" */, ); productRefGroup = 43776F8D1B8022E90074EA36 /* Products */; projectDirPath = ""; @@ -5608,14 +5607,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 845C74332B7D686000F71F90 /* XCRemoteSwiftPackageReference "LoopUITestingKit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/tidepool-org/LoopUITestingKit.git"; - requirement = { - branch = main; - kind = branch; - }; - }; C1735B1C2A0809830082BB8A /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/LoopKit/ZIPFoundation.git"; diff --git a/LoopTests/DIYLoopUnitTestPlan.xctestplan b/LoopTests/DIYLoopUnitTestPlan.xctestplan index 844d8fb21e..88f5cc6436 100644 --- a/LoopTests/DIYLoopUnitTestPlan.xctestplan +++ b/LoopTests/DIYLoopUnitTestPlan.xctestplan @@ -38,20 +38,6 @@ "name" : "NightscoutServiceKitTests" } }, - { - "target" : { - "containerPath" : "container:..\/Common\/TidepoolService\/TidepoolService.xcodeproj", - "identifier" : "A9DAAD0622E7987800E76C9F", - "name" : "TidepoolServiceKitTests" - } - }, - { - "target" : { - "containerPath" : "container:..\/Common\/TidepoolService\/TidepoolService.xcodeproj", - "identifier" : "A9DAAD2222E7988900E76C9F", - "name" : "TidepoolServiceKitUITests" - } - }, { "target" : { "containerPath" : "container:..\/Loop\/Loop.xcodeproj", From b6f259400aae30dba9a9ff51ac331dc19c0814f2 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 11 Sep 2024 17:45:14 -0500 Subject: [PATCH 163/184] Use LoopAlgorithm basal overlay for computing total delivery (#700) --- Loop/Managers/LoopDataManager.swift | 37 +++++++++++++++++-- .../Store Protocols/DoseStoreProtocol.swift | 2 - .../InsulinDeliveryTableViewController.swift | 4 +- .../StatusTableViewController.swift | 2 +- Loop/View Models/BolusEntryViewModel.swift | 4 +- 5 files changed, 37 insertions(+), 12 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index d504d18962..425e5e0fd6 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -276,18 +276,27 @@ final class LoopDataManager: ObservableObject { func fetchData( for baseTime: Date = Date(), - disablingPreMeal: Bool = false + disablingPreMeal: Bool = false, + ensureDosingCoverageStart: Date? = nil ) async throws -> StoredDataAlgorithmInput { // Need to fetch doses back as far as t - (DIA + DCA) for Dynamic carbs let dosesInputHistory = CarbMath.maximumAbsorptionTimeInterval + InsulinMath.defaultInsulinActivityDuration var dosesStart = baseTime.addingTimeInterval(-dosesInputHistory) + + // Ensure dosing data goes back before ensureDosingCoverageStart, if specified + if let ensureDosingCoverageStart { + dosesStart = min(ensureDosingCoverageStart, dosesStart) + } + let doses = try await doseStore.getNormalizedDoseEntries( start: dosesStart, end: baseTime ) - dosesStart = doses.map { $0.startDate }.min() ?? dosesStart + // 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) let basal = try await settingsProvider.getBasalHistory(startDate: dosesStart, endDate: baseTime) @@ -411,7 +420,9 @@ final class LoopDataManager: ObservableObject { func updateDisplayState() async { var newState = AlgorithmDisplayState() do { - var input = try await fetchData(for: now()) + let midnight = Calendar.current.startOfDay(for: Date()) + + var input = try await fetchData(for: now(), ensureDosingCoverageStart: midnight) input.recommendationType = .manualBolus newState.input = input newState.output = LoopAlgorithm.run(input: input) @@ -598,6 +609,24 @@ final class LoopDataManager: ObservableObject { } } + public func totalDeliveredToday() async -> InsulinValue? + { + guard let data = displayState.input else { + return nil + } + + let now = data.predictionStart + let midnight = Calendar.current.startOfDay(for: now) + + let annotatedDoses = data.doses.annotated(with: data.basal, fillBasalGaps: true) + let trimmed = annotatedDoses.map { $0.trimmed(from: midnight, to: now)} + + return InsulinValue( + startDate: midnight, + value: trimmed.reduce(0.0) { $0 + $1.volume } + ) + } + var iobValues: [InsulinValue] { dosesRelativeToBasal.insulinOnBoardTimeline() } @@ -1123,7 +1152,7 @@ extension LoopDataManager: SimpleBolusViewModelDelegate { } -extension LoopDataManager: BolusEntryViewModelDelegate { +extension LoopDataManager: BolusEntryViewModelDelegate { func saveGlucose(sample: LoopKit.NewGlucoseSample) async throws -> LoopKit.StoredGlucoseSample { let storedSamples = try await addGlucose([sample]) return storedSamples.first! diff --git a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift index bfbbfcad30..0d0d11d6a9 100644 --- a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift @@ -17,8 +17,6 @@ protocol DoseStoreProtocol: AnyObject { var lastReservoirValue: ReservoirValue? { get } - func getTotalUnitsDelivered(since startDate: Date) async throws -> InsulinValue - var lastAddedPumpData: Date { get } } diff --git a/Loop/View Controllers/InsulinDeliveryTableViewController.swift b/Loop/View Controllers/InsulinDeliveryTableViewController.swift index 0eb7e52916..7c3847d36f 100644 --- a/Loop/View Controllers/InsulinDeliveryTableViewController.swift +++ b/Loop/View Controllers/InsulinDeliveryTableViewController.swift @@ -320,9 +320,7 @@ public final class InsulinDeliveryTableViewController: UITableViewController { private func updateTotal() { Task { @MainActor in if case .display = state { - let midnight = Calendar.current.startOfDay(for: Date()) - - if let result = try? await doseStore?.getTotalUnitsDelivered(since: midnight) { + 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 { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 05322f35db..f40e1e1828 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -508,7 +508,7 @@ final class StatusTableViewController: LoopChartsTableViewController { doseEntries = loopManager.dosesRelativeToBasal.trimmed(from: startDate) iobValues = loopManager.iobValues.filterDateRange(startDate, nil) - totalDelivery = try? await loopManager.doseStore.getTotalUnitsDelivered(since: Calendar.current.startOfDay(for: Date())).value + totalDelivery = await loopManager.totalDeliveredToday()?.value } updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 6a65ee7590..4b88387fd5 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -27,7 +27,7 @@ protocol BolusEntryViewModelDelegate: AnyObject { var mostRecentGlucoseDataDate: Date? { get } var mostRecentPumpDataDate: Date? { get } - func fetchData(for baseTime: Date, disablingPreMeal: Bool) async throws -> StoredDataAlgorithmInput + func fetchData(for baseTime: Date, disablingPreMeal: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry @@ -515,7 +515,7 @@ final class BolusEntryViewModel: ObservableObject { do { let startDate = now() - var input = try await delegate.fetchData(for: startDate, disablingPreMeal: potentialCarbEntry != nil) + var input = try await delegate.fetchData(for: startDate, disablingPreMeal: potentialCarbEntry != nil, ensureDosingCoverageStart: nil) let insulinModel = delegate.insulinModel(for: deliveryDelegate?.pumpInsulinType) From 5dab2307a10233a88858ffb43265c967c2c0c5fa Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 13 Sep 2024 16:10:43 -0500 Subject: [PATCH 164/184] LOOP-4098 Overlay basal from history timeline instead of schedule (#701) * Overlay basal from history timeline instead of schedule * Remove file --- .../DoseStore+SimulatedCoreData.swift | 13 ++--- Loop/Managers/DeviceDataManager.swift | 33 ++++++++----- Loop/Managers/LoopAppManager.swift | 47 +++++++------------ .../InsulinDeliveryTableViewController.swift | 22 ++++----- .../ViewModels/BolusEntryViewModelTests.swift | 6 +-- 5 files changed, 54 insertions(+), 67 deletions(-) diff --git a/Loop/Extensions/DoseStore+SimulatedCoreData.swift b/Loop/Extensions/DoseStore+SimulatedCoreData.swift index 151f0dbb3f..066e1306a0 100644 --- a/Loop/Extensions/DoseStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DoseStore+SimulatedCoreData.swift @@ -21,7 +21,7 @@ extension DoseStore { 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 @@ -79,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 = [] } @@ -90,11 +87,11 @@ extension DoseStore { startDate = startDate.addingTimeInterval(simulatedBasalStartDateInterval) } - completion(addPumpEvents(events: simulated)) + try await addPumpEvents(events: simulated) } - func purgeHistoricalPumpEvents(completion: @escaping (Error?) -> Void) { - purgePumpEventObjects(before: historicalEndDate, completion: completion) + func purgeHistoricalPumpEvents() async throws { + try await purgePumpEventObjects(before: historicalEndDate) } } diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 22d96f993d..2799b905d0 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -308,7 +308,9 @@ final class DeviceDataManager { 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) @@ -1047,16 +1049,15 @@ extension DeviceDataManager: PumpManagerDelegate { dispatchPrecondition(condition: .onQueue(.main)) log.default("PumpManager:%{public}@ hasNewPumpEvents (lastReconciliation = %{public}@)", String(describing: type(of: pumpManager)), String(describing: lastReconciliation)) - doseStore.addPumpEvents(events, lastReconciliation: lastReconciliation, replacePendingEvents: replacePendingEvents) { (error) in - if let error = error { + Task { + do { + try await doseStore.addPumpEvents(events, lastReconciliation: lastReconciliation, replacePendingEvents: replacePendingEvents) + } catch { self.log.error("Failed to addPumpEvents to DoseStore: %{public}@", String(describing: error)) + completion(error) } - - 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) } } @@ -1131,6 +1132,10 @@ 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) { uploadEventListener.triggerUpload(for: .pumpEvent) } @@ -1175,10 +1180,12 @@ extension DeviceDataManager { let devicePredicate = HKQuery.predicateForObjects(from: [testingPumpManager.testingDevice]) let insulinDeliveryStore = doseStore.insulinDeliveryStore - - doseStore.resetPumpData { doseStoreError in - guard doseStoreError == nil else { - completion?(doseStoreError!) + + Task { + do { + try await doseStore.resetPumpData() + } catch { + completion?(error) return } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 1d929bb258..c1179fadf2 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -253,7 +253,6 @@ class LoopAppManager: NSObject { cacheStore: cacheStore, cacheLength: localCacheDuration, longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, - basalProfile: settingsManager.settings.basalRateSchedule, lastPumpEventsReconciliation: nil // PumpManager is nil at this point. Will update this via addPumpEvents below ) @@ -499,21 +498,6 @@ class LoopAppManager: NSObject { } } .store(in: &cancellables) - - // DoseStore still needs to keep updated basal schedule for now - NotificationCenter.default.publisher(for: .LoopDataUpdated) - .receive(on: DispatchQueue.main) - .sink { [weak self] note in - if let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopUpdateContext.RawValue, - let context = LoopUpdateContext(rawValue: rawContext), - let self, - context == .preferences - { - self.doseStore.basalProfile = self.settingsManager.settings.basalRateSchedule - } - } - .store(in: &cancellables) - } private func loopCycleDidComplete() async { @@ -1016,15 +1000,16 @@ extension LoopAppManager: SimulatedData { return } self.dosingDecisionStore.generateSimulatedHistoricalDosingDecisionObjects() { error in - guard error == nil else { - completion(error) - return - } - self.doseStore.generateSimulatedHistoricalPumpEvents() { error in + Task { guard error == nil else { completion(error) return } + do { + try await self.doseStore.generateSimulatedHistoricalPumpEvents() + } catch { + completion(error) + } self.deviceDataManager.deviceLog.generateSimulatedHistoricalDeviceLogEntries() { error in guard error == nil else { completion(error) @@ -1056,28 +1041,28 @@ extension LoopAppManager: SimulatedData { return } Task { @MainActor in - self.doseStore.purgeHistoricalPumpEvents() { error in + do { + try await self.doseStore.purgeHistoricalPumpEvents() + } catch { + completion(error) + return + } + self.dosingDecisionStore.purgeHistoricalDosingDecisionObjects() { error in guard error == nil else { completion(error) return } - self.dosingDecisionStore.purgeHistoricalDosingDecisionObjects() { error in + self.carbStore.purgeHistoricalCarbObjects() { error in guard error == nil else { completion(error) return } - self.carbStore.purgeHistoricalCarbObjects() { error in + self.glucoseStore.purgeHistoricalGlucoseObjects() { error in guard error == nil else { completion(error) return } - self.glucoseStore.purgeHistoricalGlucoseObjects() { error in - guard error == nil else { - completion(error) - return - } - self.settingsManager.purgeHistoricalSettingsObjects(completion: completion) - } + self.settingsManager.purgeHistoricalSettingsObjects(completion: completion) } } } diff --git a/Loop/View Controllers/InsulinDeliveryTableViewController.swift b/Loop/View Controllers/InsulinDeliveryTableViewController.swift index 7c3847d36f..46bdec8e3c 100644 --- a/Loop/View Controllers/InsulinDeliveryTableViewController.swift +++ b/Loop/View Controllers/InsulinDeliveryTableViewController.swift @@ -362,37 +362,35 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } let sheet = UIAlertController(deleteAllConfirmationMessage: confirmMessage) { - self.deleteAllObjects() + Task { + await self.deleteAllObjects() + } } present(sheet, animated: true) } private var deletionPending = false - private func deleteAllObjects() { + private func deleteAllObjects() async { guard !deletionPending else { return } deletionPending = true - let completion = { (_: DoseStore.DoseStoreError?) -> Void in - DispatchQueue.main.async { - self.deletionPending = false - self.setEditing(false, animated: true) - } - } - let sinceDate = Date().addingTimeInterval(-InsulinDeliveryTableViewController.historicDataDisplayTimeInterval) switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! { case .reservoir: - doseStore?.deleteAllReservoirValues(completion) + try? await doseStore?.deleteAllReservoirValues() case .history: - doseStore?.deleteAllPumpEvents(completion) + try? await doseStore?.deleteAllPumpEvents() case .manualEntryDose: - doseStore?.deleteAllManuallyEnteredDoses(since: sinceDate, completion) + try? await doseStore?.deleteAllManuallyEnteredDoses(since: sinceDate) } + self.deletionPending = false + self.setEditing(false, animated: true) + } // MARK: - Table view data source diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 97faf3384b..275b4c3743 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -189,7 +189,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdatePredictedGlucoseValues() async throws { do { - let input = try await delegate.fetchData(for: Self.exampleStartDate, disablingPreMeal: false) + let input = try await delegate.fetchData(for: Self.exampleStartDate, disablingPreMeal: false, ensureDosingCoverageStart: nil) let prediction = try input.predictGlucose() await bolusEntryViewModel.update() XCTAssertEqual(prediction, bolusEntryViewModel.predictedGlucoseValues.map { PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) }) @@ -200,7 +200,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdatePredictedGlucoseValuesWithManual() async throws { do { - let input = try await delegate.fetchData(for: Self.exampleStartDate, disablingPreMeal: false) + let input = try await delegate.fetchData(for: Self.exampleStartDate, disablingPreMeal: false, ensureDosingCoverageStart: nil) let prediction = try input.predictGlucose() await bolusEntryViewModel.update() bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity @@ -870,7 +870,7 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { automaticBolusApplicationFactor: 0.4 ) - func fetchData(for baseTime: Date, disablingPreMeal: Bool) async throws -> StoredDataAlgorithmInput { + func fetchData(for baseTime: Date, disablingPreMeal: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput { loopStateInput.predictionStart = baseTime return loopStateInput } From 5c35de6469c0b1453b548c2f3e2c192f5b227d13 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 13 Sep 2024 15:40:00 -0700 Subject: [PATCH 165/184] [LOOP-4975] Update Open Loop Freshness Logic and Labeling --- Common/Models/StatusExtensionContext.swift | 6 + .../Timeline/StatusWidgetTimelimeEntry.swift | 2 + .../StatusWidgetTimelineProvider.swift | 4 +- .../Widgets/SystemStatusWidget.swift | 13 +- Loop/Managers/ExtensionDataManager.swift | 2 + .../StatusTableViewController.swift | 12 ++ Loop/View Models/SettingsViewModel.swift | 21 ++- LoopUI/Views/LoopCompletionHUDView.swift | 123 ++++++++++++++++-- 8 files changed, 168 insertions(+), 15 deletions(-) diff --git a/Common/Models/StatusExtensionContext.swift b/Common/Models/StatusExtensionContext.swift index 8f5f7634fb..0d01f6079f 100644 --- a/Common/Models/StatusExtensionContext.swift +++ b/Common/Models/StatusExtensionContext.swift @@ -296,6 +296,8 @@ struct StatusExtensionContext: RawRepresentable { var predictedGlucose: PredictedGlucoseContext? var lastLoopCompleted: Date? + var lastCGMComm: Date? + var lastPumpComm: Date? var createdAt: Date? var isClosedLoop: Bool? var preMealPresetAllowed: Bool? @@ -328,6 +330,8 @@ struct StatusExtensionContext: RawRepresentable { } lastLoopCompleted = rawValue["lastLoopCompleted"] as? Date + lastCGMComm = rawValue["lastCGMComm"] as? Date + lastPumpComm = rawValue["lastPumpComm"] as? Date createdAt = rawValue["createdAt"] as? Date isClosedLoop = rawValue["isClosedLoop"] as? Bool preMealPresetAllowed = rawValue["preMealPresetAllowed"] as? Bool @@ -369,6 +373,8 @@ struct StatusExtensionContext: RawRepresentable { raw["predictedGlucose"] = predictedGlucose?.rawValue raw["lastLoopCompleted"] = lastLoopCompleted + raw["lastCGMComm"] = lastCGMComm + raw["lastPumpComm"] = lastPumpComm raw["createdAt"] = createdAt raw["isClosedLoop"] = isClosedLoop raw["preMealPresetAllowed"] = preMealPresetAllowed diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift index 85c22c5649..bf24d8f2e6 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift @@ -19,6 +19,8 @@ struct StatusWidgetTimelimeEntry: TimelineEntry { let contextUpdatedAt: Date let lastLoopCompleted: Date? + let lastCGMComm: Date? + let lastPumpComm: Date? let closeLoop: Bool let currentGlucose: GlucoseValue? diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift index f96f0dde62..6765a30c63 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift @@ -38,7 +38,7 @@ class StatusWidgetTimelineProvider: TimelineProvider { 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, lastCGMComm: nil, lastPumpComm: 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) -> ()) { @@ -159,6 +159,8 @@ class StatusWidgetTimelineProvider: TimelineProvider { date: updateDate, contextUpdatedAt: contextUpdatedAt, lastLoopCompleted: lastCompleted, + lastCGMComm: context.lastCGMComm, + lastPumpComm: context.lastPumpComm, closeLoop: closeLoop, currentGlucose: currentGlucose, glucoseFetchedAt: updateDate, diff --git a/Loop Widget Extension/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift index 6c3a73bcec..1e9b7a443f 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -18,8 +18,17 @@ struct SystemStatusWidgetEntryView: View { var entry: StatusWidgetTimelimeEntry var freshness: LoopCompletionFreshness { - let lastLoopCompleted = entry.lastLoopCompleted ?? Date().addingTimeInterval(.minutes(16)) - let age = abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) + var age: TimeInterval + + if entry.closeLoop { + let lastLoopCompleted = entry.lastLoopCompleted ?? Date().addingTimeInterval(.minutes(16)) + age = abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) + } else { + let lastCGMComm = entry.lastCGMComm ?? Date().addingTimeInterval(.minutes(16)) + let lastPumpComm = entry.lastPumpComm ?? Date().addingTimeInterval(.minutes(16)) + age = abs(max(min(0, lastCGMComm.timeIntervalSinceNow), min(0, lastPumpComm.timeIntervalSinceNow))) + } + return LoopCompletionFreshness(age: age) } diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift index c5c6f49b50..a39b33745c 100644 --- a/Loop/Managers/ExtensionDataManager.swift +++ b/Loop/Managers/ExtensionDataManager.swift @@ -118,6 +118,8 @@ final class ExtensionDataManager { #endif context.lastLoopCompleted = loopDataManager.lastLoopCompleted + context.lastCGMComm = loopDataManager.mostRecentGlucoseDataDate + context.lastPumpComm = loopDataManager.mostRecentPumpDataDate context.isClosedLoop = self.automaticDosingStatus.automaticDosingEnabled diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 05322f35db..a35f094964 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -422,6 +422,8 @@ final class StatusTableViewController: LoopChartsTableViewController { dispatchPrecondition(condition: .onQueue(.main)) // This should be kept up to date immediately hudView?.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted + hudView?.loopCompletionHUD.lastCGMComm = loopManager.mostRecentGlucoseDataDate + hudView?.loopCompletionHUD.lastPumpComm = loopManager.mostRecentPumpDataDate guard !reloading && !deviceManager.authorizationRequired else { return @@ -1625,6 +1627,8 @@ final class StatusTableViewController: LoopChartsTableViewController { initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, lastLoopCompletion: loopManager.$lastLoopCompleted, + lastCGMComm: { [weak self] in self?.loopManager.mostRecentGlucoseDataDate }, + lastPumpComm: { [weak self] in self?.loopManager.mostRecentPumpDataDate }, availableSupports: supportManager.availableSupports, isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceManager, @@ -1668,6 +1672,12 @@ final class StatusTableViewController: LoopChartsTableViewController { updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) hudView?.loopCompletionHUD.loopIconClosed = automaticDosingEnabled hudView?.loopCompletionHUD.closedLoopDisallowedLocalizedDescription = deviceManager.closedLoopDisallowedLocalizedDescription + + if automaticDosingEnabled { + Task { + await loopManager.loop() + } + } } // MARK: - HUDs @@ -1695,6 +1705,8 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView.loopCompletionHUD.stateColors = .loopStatus hudView.loopCompletionHUD.loopIconClosed = automaticDosingStatus.automaticDosingEnabled hudView.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted + hudView.loopCompletionHUD.lastCGMComm = loopManager.mostRecentGlucoseDataDate + hudView.loopCompletionHUD.lastPumpComm = loopManager.mostRecentPumpDataDate hudView.cgmStatusHUD.stateColors = .cgmStatus hudView.cgmStatusHUD.tintColor = .label diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 3d73e7a1b2..a3b8ea7274 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -83,6 +83,8 @@ public class SettingsViewModel: ObservableObject { @Published private(set) var automaticDosingStatus: AutomaticDosingStatus @Published private(set) var lastLoopCompletion: Date? + let lastCGMComm: () -> Date? + let lastPumpComm: () -> Date? var closedLoopDescriptiveText: String? { return delegate?.closedLoopDescriptiveText @@ -108,8 +110,17 @@ public class SettingsViewModel: ObservableObject { } var loopStatusCircleFreshness: LoopCompletionFreshness { - let lastLoopCompletion = lastLoopCompletion ?? Date().addingTimeInterval(.minutes(16)) - let age = abs(min(0, lastLoopCompletion.timeIntervalSinceNow)) + var age: TimeInterval + + if automaticDosingStatus.automaticDosingEnabled { + let lastLoopCompletion = lastLoopCompletion ?? Date().addingTimeInterval(.minutes(16)) + age = abs(min(0, lastLoopCompletion.timeIntervalSinceNow)) + } else { + let lastCGMComm = lastCGMComm() ?? Date().addingTimeInterval(.minutes(16)) + let lastPumpComm = lastPumpComm() ?? Date().addingTimeInterval(.minutes(16)) + age = abs(max(min(0, lastCGMComm.timeIntervalSinceNow), min(0, lastPumpComm.timeIntervalSinceNow))) + } + return LoopCompletionFreshness(age: age) } @@ -128,6 +139,8 @@ public class SettingsViewModel: ObservableObject { automaticDosingStatus: AutomaticDosingStatus, automaticDosingStrategy: AutomaticDosingStrategy, lastLoopCompletion: Published.Publisher, + lastCGMComm: @escaping () -> Date?, + lastPumpComm: @escaping () -> Date?, availableSupports: [SupportUI], isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, @@ -146,6 +159,8 @@ public class SettingsViewModel: ObservableObject { self.automaticDosingStatus = automaticDosingStatus self.automaticDosingStrategy = automaticDosingStrategy self.lastLoopCompletion = nil + self.lastCGMComm = lastCGMComm + self.lastPumpComm = lastPumpComm self.availableSupports = availableSupports self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate @@ -200,6 +215,8 @@ extension SettingsViewModel { automaticDosingStatus: AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true), automaticDosingStrategy: .automaticBolus, lastLoopCompletion: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, + lastCGMComm: { nil }, + lastPumpComm: { nil }, availableSupports: [], isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index cf6ff35579..c7be1374af 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -49,6 +49,9 @@ public final class LoopCompletionHUDView: BaseHUDView { } } } + + public var lastCGMComm: Date? + public var lastPumpComm: Date? public var loopInProgress = false { didSet { @@ -136,7 +139,7 @@ public final class LoopCompletionHUDView: BaseHUDView { lastLoopMessage = "" let timeAgoToIncludeTimeStamp: TimeInterval = .minutes(20) let timeAgoToIncludeDate: TimeInterval = .hours(4) - if let date = lastLoopCompleted { + if loopIconClosed, let date = lastLoopCompleted { let ago = abs(min(0, date.timeIntervalSinceNow)) freshness = LoopCompletionFreshness(age: ago) @@ -170,6 +173,28 @@ public final class LoopCompletionHUDView: BaseHUDView { caption?.text = "–" accessibilityLabel = nil } + } else if let lastPumpComm, let lastCGMComm { + let ago = abs(max(min(0, lastPumpComm.timeIntervalSinceNow), min(0, lastCGMComm.timeIntervalSinceNow))) + + freshness = LoopCompletionFreshness(age: ago) + + if let timeString = timeAgoFormatter.string(from: ago) { + switch traitCollection.preferredContentSizeCategory { + case UIContentSizeCategory.extraSmall, + UIContentSizeCategory.small, + UIContentSizeCategory.medium, + UIContentSizeCategory.large: + // Use a longer form only for smaller text sizes + caption?.text = String(format: LocalizedString("%@ ago", comment: "Format string describing the time interval since the last cgm or pump communication date. (1: The localized date components"), timeString) + default: + caption?.text = timeString + } + + accessibilityLabel = String(format: LocalizedString("Last device communication ran %@ ago", comment: "Accessbility format label describing the time interval since the last device communication date. (1: The localized date components)"), timeString) + } else { + caption?.text = "–" + accessibilityLabel = nil + } } else { caption?.text = "–" accessibilityLabel = LocalizedString("Waiting for first run", comment: "Accessibility label describing completion HUD waiting for first run") @@ -196,19 +221,97 @@ extension LoopCompletionHUDView { 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)) + let reason = closedLoopDisallowedLocalizedDescription ?? LocalizedString( + "Tap Settings to toggle Closed Loop ON if you wish for the app to automate your insulin.", + comment: "Instructions for user to close loop if it is allowed." + ) + + return ( + title: LocalizedString( + "Closed Loop OFF", + comment: "Title of fresh loop OFF message" + ), + message: String( + format: LocalizedString( + "\n%1$@ is operating with Closed Loop in the OFF position. Your pump and CGM will continue operating, but the app will not adjust dosing automatically.\n\n%2$@", + comment: "Fresh closed loop OFF message (1: app name)(2: reason for open loop)" + ), + Bundle.main.bundleDisplayName, + reason + ) + ) } else { - return (title: LocalizedString("Closed Loop ON", comment: "Title of green closed loop ON message"), - message: String(format: LocalizedString("\n%1$@\n\n%2$@ is operating with Closed Loop in the ON position.", comment: "Green closed loop ON message (1: last loop string) (2: app name)"), lastLoopMessage, Bundle.main.bundleDisplayName)) + return ( + title: LocalizedString( + "Closed Loop ON", + comment: "Title of fresh closed loop ON message" + ), + message: String( + format: LocalizedString( + "\n%1$@\n\n%2$@ is operating with Closed Loop in the ON position.", + comment: "Fresh closed loop ON message (1: last loop string) (2: app name)" + ), + lastLoopMessage, + Bundle.main.bundleDisplayName + ) + ) } case .aging: - return (title: LocalizedString("Loop Warning", comment: "Title of yellow loop message"), - message: String(format: LocalizedString("\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but watch for potential communication issues with your pump and CGM.", comment: "Yellow loop message (1: last loop string) (2: app name)"), lastLoopMessage, Bundle.main.bundleDisplayName)) + if loopStateView.open { + return ( + title: LocalizedString( + "Caution", + comment: "Title of aging open loop message" + ), + message: LocalizedString( + "Tap your CGM and insulin pump status icons for more information. Check for potential communication issues with your pump and CGM.", + comment: "Aging open loop message" + ) + ) + } else { + return ( + title: LocalizedString( + "Loop Warning", + comment: "Title of aging closed loop message" + ), + message: String( + format: LocalizedString( + "\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but watch for potential communication issues with your pump and CGM.", + comment: "Aging loop message (1: last loop string) (2: app name)" + ), + lastLoopMessage, + Bundle.main.bundleDisplayName + ) + ) + } case .stale: - return (title: LocalizedString("Loop Failure", comment: "Title of red loop message"), - message: String(format: LocalizedString("\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but check for potential communication issues with your pump and CGM.", comment: "Red loop message (1: last loop string) (2: app name)"), lastLoopMessage, Bundle.main.bundleDisplayName)) + if loopStateView.open { + return ( + title: LocalizedString( + "Device Error", + comment: "Title of stale loop message" + ), + message: LocalizedString( + "Tap your CGM and insulin pump status icons for more information. Check for potential communication issues with your pump and CGM.", + comment: "Stale open loop message" + ) + ) + } else { + return ( + title: LocalizedString( + "Loop Failure", + comment: "Title of red loop message" + ), + message: String( + format: LocalizedString( + "\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but check for potential communication issues with your pump and CGM.", + comment: "Red loop message (1: last loop string) (2: app name)" + ), + lastLoopMessage, + Bundle.main.bundleDisplayName + ) + ) + } } } } From 22094e773466e538d1c678552080ac80cda08930 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 14 Sep 2024 08:15:07 -0500 Subject: [PATCH 166/184] Add missing returns (#702) --- Loop/Managers/DeviceDataManager.swift | 1 + Loop/Managers/LoopAppManager.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 2799b905d0..4719b5a677 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1055,6 +1055,7 @@ extension DeviceDataManager: PumpManagerDelegate { } catch { self.log.error("Failed to addPumpEvents to DoseStore: %{public}@", String(describing: error)) completion(error) + return } completion(nil) NotificationCenter.default.post(name: .PumpEventsAdded, object: self, userInfo: nil) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index c1179fadf2..116e838a03 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -1009,6 +1009,7 @@ extension LoopAppManager: SimulatedData { try await self.doseStore.generateSimulatedHistoricalPumpEvents() } catch { completion(error) + return } self.deviceDataManager.deviceLog.generateSimulatedHistoricalDeviceLogEntries() { error in guard error == nil else { From 198e12092d76894f2cb2cfbd0d27e48ba4da466a Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 16 Sep 2024 09:12:14 -0700 Subject: [PATCH 167/184] [LOOP-4975] Naming Update --- Common/Models/StatusExtensionContext.swift | 12 +++++----- .../Timeline/StatusWidgetTimelimeEntry.swift | 4 ++-- .../StatusWidgetTimelineProvider.swift | 6 ++--- .../Widgets/SystemStatusWidget.swift | 6 ++--- Loop/Managers/ExtensionDataManager.swift | 4 ++-- .../StatusTableViewController.swift | 12 +++++----- Loop/View Models/SettingsViewModel.swift | 22 +++++++++---------- LoopUI/Views/LoopCompletionHUDView.swift | 8 +++---- 8 files changed, 37 insertions(+), 37 deletions(-) diff --git a/Common/Models/StatusExtensionContext.swift b/Common/Models/StatusExtensionContext.swift index 0d01f6079f..cf486fd1a8 100644 --- a/Common/Models/StatusExtensionContext.swift +++ b/Common/Models/StatusExtensionContext.swift @@ -296,8 +296,8 @@ struct StatusExtensionContext: RawRepresentable { var predictedGlucose: PredictedGlucoseContext? var lastLoopCompleted: Date? - var lastCGMComm: Date? - var lastPumpComm: Date? + var mostRecentGlucoseDataDate: Date? + var mostRecentPumpDataDate: Date? var createdAt: Date? var isClosedLoop: Bool? var preMealPresetAllowed: Bool? @@ -330,8 +330,8 @@ struct StatusExtensionContext: RawRepresentable { } lastLoopCompleted = rawValue["lastLoopCompleted"] as? Date - lastCGMComm = rawValue["lastCGMComm"] as? Date - lastPumpComm = rawValue["lastPumpComm"] 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 @@ -373,8 +373,8 @@ struct StatusExtensionContext: RawRepresentable { raw["predictedGlucose"] = predictedGlucose?.rawValue raw["lastLoopCompleted"] = lastLoopCompleted - raw["lastCGMComm"] = lastCGMComm - raw["lastPumpComm"] = lastPumpComm + raw["mostRecentGlucoseDataDate"] = mostRecentGlucoseDataDate + raw["mostRecentPumpDataDate"] = mostRecentPumpDataDate raw["createdAt"] = createdAt raw["isClosedLoop"] = isClosedLoop raw["preMealPresetAllowed"] = preMealPresetAllowed diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift index bf24d8f2e6..78cec95b08 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift @@ -19,8 +19,8 @@ struct StatusWidgetTimelimeEntry: TimelineEntry { let contextUpdatedAt: Date let lastLoopCompleted: Date? - let lastCGMComm: Date? - let lastPumpComm: Date? + let mostRecentGlucoseDataDate: Date? + let mostRecentPumpDataDate: Date? let closeLoop: Bool let currentGlucose: GlucoseValue? diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift index 6765a30c63..314ea4542b 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift @@ -38,7 +38,7 @@ class StatusWidgetTimelineProvider: TimelineProvider { func placeholder(in context: Context) -> StatusWidgetTimelimeEntry { log.default("%{public}@: context=%{public}@", #function, String(describing: context)) - return StatusWidgetTimelimeEntry(date: Date(), contextUpdatedAt: Date(), lastLoopCompleted: nil, lastCGMComm: nil, lastPumpComm: 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) -> ()) { @@ -159,8 +159,8 @@ class StatusWidgetTimelineProvider: TimelineProvider { date: updateDate, contextUpdatedAt: contextUpdatedAt, lastLoopCompleted: lastCompleted, - lastCGMComm: context.lastCGMComm, - lastPumpComm: context.lastPumpComm, + 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 1e9b7a443f..8a2f9b0e73 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -24,9 +24,9 @@ struct SystemStatusWidgetEntryView: View { let lastLoopCompleted = entry.lastLoopCompleted ?? Date().addingTimeInterval(.minutes(16)) age = abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) } else { - let lastCGMComm = entry.lastCGMComm ?? Date().addingTimeInterval(.minutes(16)) - let lastPumpComm = entry.lastPumpComm ?? Date().addingTimeInterval(.minutes(16)) - age = abs(max(min(0, lastCGMComm.timeIntervalSinceNow), min(0, lastPumpComm.timeIntervalSinceNow))) + let mostRecentGlucoseDataDate = entry.mostRecentGlucoseDataDate ?? Date().addingTimeInterval(.minutes(16)) + let mostRecentPumpDataDate = entry.mostRecentPumpDataDate ?? Date().addingTimeInterval(.minutes(16)) + age = abs(max(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow), min(0, mostRecentPumpDataDate.timeIntervalSinceNow))) } return LoopCompletionFreshness(age: age) diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift index a39b33745c..37e1a21ed6 100644 --- a/Loop/Managers/ExtensionDataManager.swift +++ b/Loop/Managers/ExtensionDataManager.swift @@ -118,8 +118,8 @@ final class ExtensionDataManager { #endif context.lastLoopCompleted = loopDataManager.lastLoopCompleted - context.lastCGMComm = loopDataManager.mostRecentGlucoseDataDate - context.lastPumpComm = loopDataManager.mostRecentPumpDataDate + context.mostRecentGlucoseDataDate = loopDataManager.mostRecentGlucoseDataDate + context.mostRecentPumpDataDate = loopDataManager.mostRecentPumpDataDate context.isClosedLoop = self.automaticDosingStatus.automaticDosingEnabled diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index a35f094964..e5f8072c80 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -422,8 +422,8 @@ final class StatusTableViewController: LoopChartsTableViewController { dispatchPrecondition(condition: .onQueue(.main)) // This should be kept up to date immediately hudView?.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted - hudView?.loopCompletionHUD.lastCGMComm = loopManager.mostRecentGlucoseDataDate - hudView?.loopCompletionHUD.lastPumpComm = loopManager.mostRecentPumpDataDate + hudView?.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate + hudView?.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate guard !reloading && !deviceManager.authorizationRequired else { return @@ -1627,8 +1627,8 @@ final class StatusTableViewController: LoopChartsTableViewController { initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, lastLoopCompletion: loopManager.$lastLoopCompleted, - lastCGMComm: { [weak self] in self?.loopManager.mostRecentGlucoseDataDate }, - lastPumpComm: { [weak self] in self?.loopManager.mostRecentPumpDataDate }, + mostRecentGlucoseDataDate: { [weak self] in self?.loopManager.mostRecentGlucoseDataDate }, + mostRecentPumpDataDate: { [weak self] in self?.loopManager.mostRecentPumpDataDate }, availableSupports: supportManager.availableSupports, isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceManager, @@ -1705,8 +1705,8 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView.loopCompletionHUD.stateColors = .loopStatus hudView.loopCompletionHUD.loopIconClosed = automaticDosingStatus.automaticDosingEnabled hudView.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted - hudView.loopCompletionHUD.lastCGMComm = loopManager.mostRecentGlucoseDataDate - hudView.loopCompletionHUD.lastPumpComm = loopManager.mostRecentPumpDataDate + hudView.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate + hudView.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate hudView.cgmStatusHUD.stateColors = .cgmStatus hudView.cgmStatusHUD.tintColor = .label diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index a3b8ea7274..20be3bd0b5 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -83,8 +83,8 @@ public class SettingsViewModel: ObservableObject { @Published private(set) var automaticDosingStatus: AutomaticDosingStatus @Published private(set) var lastLoopCompletion: Date? - let lastCGMComm: () -> Date? - let lastPumpComm: () -> Date? + let mostRecentGlucoseDataDate: () -> Date? + let mostRecentPumpDataDate: () -> Date? var closedLoopDescriptiveText: String? { return delegate?.closedLoopDescriptiveText @@ -116,9 +116,9 @@ public class SettingsViewModel: ObservableObject { let lastLoopCompletion = lastLoopCompletion ?? Date().addingTimeInterval(.minutes(16)) age = abs(min(0, lastLoopCompletion.timeIntervalSinceNow)) } else { - let lastCGMComm = lastCGMComm() ?? Date().addingTimeInterval(.minutes(16)) - let lastPumpComm = lastPumpComm() ?? Date().addingTimeInterval(.minutes(16)) - age = abs(max(min(0, lastCGMComm.timeIntervalSinceNow), min(0, lastPumpComm.timeIntervalSinceNow))) + let mostRecentGlucoseDataDate = mostRecentGlucoseDataDate() ?? Date().addingTimeInterval(.minutes(16)) + let mostRecentPumpDataDate = mostRecentPumpDataDate() ?? Date().addingTimeInterval(.minutes(16)) + age = abs(max(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow), min(0, mostRecentPumpDataDate.timeIntervalSinceNow))) } return LoopCompletionFreshness(age: age) @@ -139,8 +139,8 @@ public class SettingsViewModel: ObservableObject { automaticDosingStatus: AutomaticDosingStatus, automaticDosingStrategy: AutomaticDosingStrategy, lastLoopCompletion: Published.Publisher, - lastCGMComm: @escaping () -> Date?, - lastPumpComm: @escaping () -> Date?, + mostRecentGlucoseDataDate: @escaping () -> Date?, + mostRecentPumpDataDate: @escaping () -> Date?, availableSupports: [SupportUI], isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, @@ -159,8 +159,8 @@ public class SettingsViewModel: ObservableObject { self.automaticDosingStatus = automaticDosingStatus self.automaticDosingStrategy = automaticDosingStrategy self.lastLoopCompletion = nil - self.lastCGMComm = lastCGMComm - self.lastPumpComm = lastPumpComm + self.mostRecentGlucoseDataDate = mostRecentGlucoseDataDate + self.mostRecentPumpDataDate = mostRecentPumpDataDate self.availableSupports = availableSupports self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate @@ -215,8 +215,8 @@ extension SettingsViewModel { automaticDosingStatus: AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true), automaticDosingStrategy: .automaticBolus, lastLoopCompletion: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, - lastCGMComm: { nil }, - lastPumpComm: { nil }, + mostRecentGlucoseDataDate: { nil }, + mostRecentPumpDataDate: { nil }, availableSupports: [], isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index c7be1374af..8b60983c65 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -50,8 +50,8 @@ public final class LoopCompletionHUDView: BaseHUDView { } } - public var lastCGMComm: Date? - public var lastPumpComm: Date? + public var mostRecentGlucoseDataDate: Date? + public var mostRecentPumpDataDate: Date? public var loopInProgress = false { didSet { @@ -173,8 +173,8 @@ public final class LoopCompletionHUDView: BaseHUDView { caption?.text = "–" accessibilityLabel = nil } - } else if let lastPumpComm, let lastCGMComm { - let ago = abs(max(min(0, lastPumpComm.timeIntervalSinceNow), min(0, lastCGMComm.timeIntervalSinceNow))) + } else if let mostRecentPumpDataDate, let mostRecentGlucoseDataDate { + let ago = abs(max(min(0, mostRecentPumpDataDate.timeIntervalSinceNow), min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow))) freshness = LoopCompletionFreshness(age: ago) From 9e1a8c9468c7ca40d2d14d8f7e1b8a7bc648ca17 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 18 Sep 2024 09:13:51 -0300 Subject: [PATCH 168/184] [PAL-704] removing debug from testflight (#704) --- Loop.xcodeproj/project.pbxproj | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 4644cecdb3..3652945ff3 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -5161,10 +5161,6 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); 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; @@ -5193,13 +5189,13 @@ LocalizedString, ); MAIN_APP_BUNDLE_IDENTIFIER = "$(inherited).Loop"; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + 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) DEBUG"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -5225,8 +5221,6 @@ "@executable_path/Frameworks", ); OTHER_LDFLAGS = ""; - "OTHER_SWIFT_FLAGS[arch=*]" = "-DDEBUG"; - "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_RELEASE)"; From f9c01eba5543b413ad232f970c8620a936acdae7 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 19 Sep 2024 09:56:43 -0700 Subject: [PATCH 169/184] [LOOP-4975] Update Open Loop Freshness Syncing Between StatusHUD and SettingsView (#705) --- .../Widgets/SystemStatusWidget.swift | 2 +- Loop/Managers/LoopAppManager.swift | 2 ++ Loop/Managers/LoopDataManager.swift | 8 +++++ .../StatusTableViewController.swift | 25 ++++++++++++++-- Loop/View Models/SettingsViewModel.swift | 29 +++++++++++-------- LoopUI/Views/LoopCompletionHUDView.swift | 10 +++---- 6 files changed, 56 insertions(+), 20 deletions(-) diff --git a/Loop Widget Extension/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift index 8a2f9b0e73..2cb9f7fc91 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -26,7 +26,7 @@ struct SystemStatusWidgetEntryView: View { } else { let mostRecentGlucoseDataDate = entry.mostRecentGlucoseDataDate ?? Date().addingTimeInterval(.minutes(16)) let mostRecentPumpDataDate = entry.mostRecentPumpDataDate ?? Date().addingTimeInterval(.minutes(16)) - age = abs(max(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow), min(0, mostRecentPumpDataDate.timeIntervalSinceNow))) + age = max(abs(min(0, mostRecentPumpDataDate.timeIntervalSinceNow)), abs(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow))) } return LoopCompletionFreshness(age: age) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 116e838a03..9f3adb516d 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -290,6 +290,8 @@ class LoopAppManager: NSObject { loopDataManager = LoopDataManager( lastLoopCompleted: ExtensionDataManager.context?.lastLoopCompleted, + publishedMostRecentGlucoseDataDate: ExtensionDataManager.context?.mostRecentGlucoseDataDate, + publishedMostRecentPumpDataDate: ExtensionDataManager.context?.mostRecentPumpDataDate, temporaryPresetsManager: temporaryPresetsManager, settingsProvider: settingsManager, doseStore: doseStore, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 425e5e0fd6..801557619e 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -99,6 +99,8 @@ final class LoopDataManager: ObservableObject { } @Published private(set) var lastLoopCompleted: Date? + @Published private(set) var publishedMostRecentGlucoseDataDate: Date? + @Published private(set) var publishedMostRecentPumpDataDate: Date? var deliveryDelegate: DeliveryDelegate? @@ -148,6 +150,8 @@ final class LoopDataManager: ObservableObject { init( lastLoopCompleted: Date?, + publishedMostRecentGlucoseDataDate: Date?, + publishedMostRecentPumpDataDate: Date?, temporaryPresetsManager: TemporaryPresetsManager, settingsProvider: SettingsProvider, doseStore: DoseStoreProtocol, @@ -163,6 +167,8 @@ final class LoopDataManager: ObservableObject { ) { self.lastLoopCompleted = lastLoopCompleted + self.publishedMostRecentGlucoseDataDate = publishedMostRecentGlucoseDataDate + self.publishedMostRecentPumpDataDate = publishedMostRecentPumpDataDate self.temporaryPresetsManager = temporaryPresetsManager self.settingsProvider = settingsProvider self.doseStore = doseStore @@ -431,6 +437,8 @@ final class LoopDataManager: ObservableObject { logger.error("Error updating Loop state: %{public}@", String(describing: loopError)) } displayState = newState + publishedMostRecentGlucoseDataDate = mostRecentGlucoseDataDate + publishedMostRecentPumpDataDate = mostRecentPumpDataDate await updateRemoteRecommendation() } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 1500bf876f..a6532903e6 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -166,6 +166,27 @@ final class StatusTableViewController: LoopChartsTableViewController { } } .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) if let gestureRecognizer = charts.gestureRecognizer { tableView.addGestureRecognizer(gestureRecognizer) @@ -1627,8 +1648,8 @@ final class StatusTableViewController: LoopChartsTableViewController { initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, lastLoopCompletion: loopManager.$lastLoopCompleted, - mostRecentGlucoseDataDate: { [weak self] in self?.loopManager.mostRecentGlucoseDataDate }, - mostRecentPumpDataDate: { [weak self] in self?.loopManager.mostRecentPumpDataDate }, + mostRecentGlucoseDataDate: loopManager.$publishedMostRecentGlucoseDataDate, + mostRecentPumpDataDate: loopManager.$publishedMostRecentPumpDataDate, availableSupports: supportManager.availableSupports, isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceManager, diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 20be3bd0b5..ea93e53d7d 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -83,8 +83,8 @@ public class SettingsViewModel: ObservableObject { @Published private(set) var automaticDosingStatus: AutomaticDosingStatus @Published private(set) var lastLoopCompletion: Date? - let mostRecentGlucoseDataDate: () -> Date? - let mostRecentPumpDataDate: () -> Date? + @Published private(set) var mostRecentGlucoseDataDate: Date? + @Published private(set) var mostRecentPumpDataDate: Date? var closedLoopDescriptiveText: String? { return delegate?.closedLoopDescriptiveText @@ -116,9 +116,9 @@ public class SettingsViewModel: ObservableObject { let lastLoopCompletion = lastLoopCompletion ?? Date().addingTimeInterval(.minutes(16)) age = abs(min(0, lastLoopCompletion.timeIntervalSinceNow)) } else { - let mostRecentGlucoseDataDate = mostRecentGlucoseDataDate() ?? Date().addingTimeInterval(.minutes(16)) - let mostRecentPumpDataDate = mostRecentPumpDataDate() ?? Date().addingTimeInterval(.minutes(16)) - age = abs(max(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow), min(0, mostRecentPumpDataDate.timeIntervalSinceNow))) + let mostRecentGlucoseDataDate = mostRecentGlucoseDataDate ?? Date().addingTimeInterval(.minutes(16)) + let mostRecentPumpDataDate = mostRecentPumpDataDate ?? Date().addingTimeInterval(.minutes(16)) + age = max(abs(min(0, mostRecentPumpDataDate.timeIntervalSinceNow)), abs(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow))) } return LoopCompletionFreshness(age: age) @@ -139,8 +139,8 @@ public class SettingsViewModel: ObservableObject { automaticDosingStatus: AutomaticDosingStatus, automaticDosingStrategy: AutomaticDosingStrategy, lastLoopCompletion: Published.Publisher, - mostRecentGlucoseDataDate: @escaping () -> Date?, - mostRecentPumpDataDate: @escaping () -> Date?, + mostRecentGlucoseDataDate: Published.Publisher, + mostRecentPumpDataDate: Published.Publisher, availableSupports: [SupportUI], isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, @@ -159,8 +159,8 @@ public class SettingsViewModel: ObservableObject { self.automaticDosingStatus = automaticDosingStatus self.automaticDosingStrategy = automaticDosingStrategy self.lastLoopCompletion = nil - self.mostRecentGlucoseDataDate = mostRecentGlucoseDataDate - self.mostRecentPumpDataDate = mostRecentPumpDataDate + self.mostRecentGlucoseDataDate = nil + self.mostRecentPumpDataDate = nil self.availableSupports = availableSupports self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate @@ -190,7 +190,12 @@ public class SettingsViewModel: ObservableObject { 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) } } @@ -215,8 +220,8 @@ extension SettingsViewModel { automaticDosingStatus: AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true), automaticDosingStrategy: .automaticBolus, lastLoopCompletion: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, - mostRecentGlucoseDataDate: { nil }, - mostRecentPumpDataDate: { nil }, + mostRecentGlucoseDataDate: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, + mostRecentPumpDataDate: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, availableSupports: [], isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index 8b60983c65..d9c920bc79 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -173,8 +173,8 @@ public final class LoopCompletionHUDView: BaseHUDView { caption?.text = "–" accessibilityLabel = nil } - } else if let mostRecentPumpDataDate, let mostRecentGlucoseDataDate { - let ago = abs(max(min(0, mostRecentPumpDataDate.timeIntervalSinceNow), min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow))) + } else if !loopIconClosed, let mostRecentPumpDataDate, let mostRecentGlucoseDataDate { + let ago = max(abs(min(0, mostRecentPumpDataDate.timeIntervalSinceNow)), abs(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow))) freshness = LoopCompletionFreshness(age: ago) @@ -220,7 +220,7 @@ extension LoopCompletionHUDView { public var loopCompletionMessage: (title: String, message: String) { switch freshness { case .fresh: - if loopStateView.open { + if !loopIconClosed { let reason = closedLoopDisallowedLocalizedDescription ?? LocalizedString( "Tap Settings to toggle Closed Loop ON if you wish for the app to automate your insulin.", comment: "Instructions for user to close loop if it is allowed." @@ -257,7 +257,7 @@ extension LoopCompletionHUDView { ) } case .aging: - if loopStateView.open { + if !loopIconClosed { return ( title: LocalizedString( "Caution", @@ -285,7 +285,7 @@ extension LoopCompletionHUDView { ) } case .stale: - if loopStateView.open { + if !loopIconClosed { return ( title: LocalizedString( "Device Error", From f417eaf503c80d3ba50fd993ad7a415761e37823 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 19 Sep 2024 13:49:56 -0700 Subject: [PATCH 170/184] [LOOP-4975] Update Open Loop Tests (#707) --- Loop/Managers/LoopAppManager.swift | 2 -- Loop/Managers/LoopDataManager.swift | 7 +++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 9f3adb516d..116e838a03 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -290,8 +290,6 @@ class LoopAppManager: NSObject { loopDataManager = LoopDataManager( lastLoopCompleted: ExtensionDataManager.context?.lastLoopCompleted, - publishedMostRecentGlucoseDataDate: ExtensionDataManager.context?.mostRecentGlucoseDataDate, - publishedMostRecentPumpDataDate: ExtensionDataManager.context?.mostRecentPumpDataDate, temporaryPresetsManager: temporaryPresetsManager, settingsProvider: settingsManager, doseStore: doseStore, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 801557619e..4a826461e2 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -150,8 +150,6 @@ final class LoopDataManager: ObservableObject { init( lastLoopCompleted: Date?, - publishedMostRecentGlucoseDataDate: Date?, - publishedMostRecentPumpDataDate: Date?, temporaryPresetsManager: TemporaryPresetsManager, settingsProvider: SettingsProvider, doseStore: DoseStoreProtocol, @@ -167,8 +165,6 @@ final class LoopDataManager: ObservableObject { ) { self.lastLoopCompleted = lastLoopCompleted - self.publishedMostRecentGlucoseDataDate = publishedMostRecentGlucoseDataDate - self.publishedMostRecentPumpDataDate = publishedMostRecentPumpDataDate self.temporaryPresetsManager = temporaryPresetsManager self.settingsProvider = settingsProvider self.doseStore = doseStore @@ -182,6 +178,9 @@ final class LoopDataManager: ObservableObject { self.carbAbsorptionModel = carbAbsorptionModel self.usePositiveMomentumAndRCForManualBoluses = usePositiveMomentumAndRCForManualBoluses + self.publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate + self.publishedMostRecentPumpDataDate = mostRecentPumpDataDate + // Required for device settings in stored dosing decisions UIDevice.current.isBatteryMonitoringEnabled = true From 96c84814fa54a118d533cc763e48fc06b46e68d6 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 20 Sep 2024 07:27:59 -0500 Subject: [PATCH 171/184] Track automation history (#708) --- Loop.xcodeproj/project.pbxproj | 14 ++- Loop/Extensions/UserDefaults+Loop.swift | 20 ++++ Loop/Managers/DeviceDataManager.swift | 6 ++ Loop/Managers/LoopDataManager.swift | 34 +++++-- Loop/Models/AutomationHistoryEntry.swift | 49 ++++++++++ LoopTests/Mocks/LoopControlMock.swift | 5 + .../Models/AutomationHistoryEntryTests.swift | 97 +++++++++++++++++++ 7 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 Loop/Models/AutomationHistoryEntry.swift create mode 100644 LoopTests/Models/AutomationHistoryEntryTests.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 3652945ff3..495f9140b2 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -415,6 +415,8 @@ 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 */; }; + C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */; }; + C152B9F72C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */; }; C159C82F286787EF00A86EC0 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C82E286787EF00A86EC0 /* LoopKit.framework */; }; C15A8C492A7305B1009D736B /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; }; C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */; }; @@ -1347,6 +1349,8 @@ 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 = ""; }; + C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntry.swift; sourceTree = ""; }; + C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntryTests.swift; sourceTree = ""; }; C155A8F32986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; C155A8F42986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; C155A8F52986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/ckcomplication.strings; sourceTree = ""; }; @@ -1860,6 +1864,7 @@ A987CD4824A58A0100439ADC /* ZipArchive.swift */, C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, + C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */, ); path = Models; sourceTree = ""; @@ -2603,13 +2608,14 @@ A9E6DFED246A0460005B1A1C /* Models */ = { isa = PBXGroup; children = ( + C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */, A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */, A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */, - A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */, C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */, - A9C1719625366F780053BCBD /* WatchHistoricalGlucoseTest.swift */, - A9BD28E6272226B40071DF15 /* TestLocalizedError.swift */, C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */, + A9BD28E6272226B40071DF15 /* TestLocalizedError.swift */, + A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */, + A9C1719625366F780053BCBD /* WatchHistoricalGlucoseTest.swift */, ); path = Models; sourceTree = ""; @@ -3438,6 +3444,7 @@ 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */, 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */, + C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */, 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */, 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */, 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */, @@ -3744,6 +3751,7 @@ 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 */, diff --git a/Loop/Extensions/UserDefaults+Loop.swift b/Loop/Extensions/UserDefaults+Loop.swift index a663c1e8a4..fe57219067 100644 --- a/Loop/Extensions/UserDefaults+Loop.swift +++ b/Loop/Extensions/UserDefaults+Loop.swift @@ -18,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? { @@ -110,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/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 4719b5a677..12a8b1a996 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -19,6 +19,7 @@ protocol LoopControl { var lastLoopCompleted: Date? { get } func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async throws func loop() async + func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] } protocol ActiveServicesProvider { @@ -1140,6 +1141,11 @@ extension DeviceDataManager: DoseStoreDelegate { func doseStoreHasUpdatedPumpEventData(_ doseStore: DoseStore) { uploadEventListener.triggerUpload(for: .pumpEvent) } + + func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] { + return try await loopControl.automationHistory(from: start, to: end) + } + } // MARK: - DosingDecisionStoreDelegate diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 4a826461e2..ffe957a931 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -146,6 +146,12 @@ final class LoopDataManager: ObservableObject { var usePositiveMomentumAndRCForManualBoluses: Bool + var automationHistory: [AutomationHistoryEntry] { + didSet { + UserDefaults.standard.automationHistory = automationHistory + } + } + lazy private var cancellables = Set() init( @@ -177,10 +183,10 @@ final class LoopDataManager: ObservableObject { self.analyticsServicesManager = analyticsServicesManager self.carbAbsorptionModel = carbAbsorptionModel self.usePositiveMomentumAndRCForManualBoluses = usePositiveMomentumAndRCForManualBoluses - self.publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate self.publishedMostRecentPumpDataDate = mostRecentPumpDataDate - + self.automationHistory = UserDefaults.standard.automationHistory + // Required for device settings in stored dosing decisions UIDevice.current.isBatteryMonitoringEnabled = true @@ -228,8 +234,20 @@ final class LoopDataManager: ObservableObject { automaticDosingStatus.$automaticDosingEnabled .removeDuplicates() .dropFirst() - .sink { - if !$0 { + .sink { [weak self] enabled in + guard let self else { + return + } + if self.automationHistory.last?.enabled != enabled { + self.automationHistory.append(AutomationHistoryEntry(startDate: Date(), enabled: enabled)) + + // Clean up entries older than 36 hours; we should not be interpolating basal data before then. + let now = Date() + self.automationHistory = self.automationHistory.filter({ entry in + now.timeIntervalSince(entry.startDate) < .hours(36) + }) + } + if !enabled { self.temporaryPresetsManager.clearOverride(matching: .preMeal) Task { try? await self.cancelActiveTempBasal(for: .automaticDosingDisabled) @@ -436,7 +454,7 @@ final class LoopDataManager: ObservableObject { logger.error("Error updating Loop state: %{public}@", String(describing: loopError)) } displayState = newState - publishedMostRecentGlucoseDataDate = mostRecentGlucoseDataDate + publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate publishedMostRecentPumpDataDate = mostRecentPumpDataDate await updateRemoteRecommendation() } @@ -1444,7 +1462,11 @@ extension LoopDataManager: DiagnosticReportGenerator { } } -extension LoopDataManager: LoopControl { } +extension LoopDataManager: LoopControl { + 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) diff --git a/Loop/Models/AutomationHistoryEntry.swift b/Loop/Models/AutomationHistoryEntry.swift new file mode 100644 index 0000000000..8d55541924 --- /dev/null +++ b/Loop/Models/AutomationHistoryEntry.swift @@ -0,0 +1,49 @@ +// +// AutomationHistoryEntry.swift +// Loop +// +// Created by Pete Schwamb on 9/19/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopAlgorithm + +struct AutomationHistoryEntry: Codable { + var startDate: Date + var enabled: Bool +} + +extension Array where Element == AutomationHistoryEntry { + func toTimeline(from start: Date, to end: Date) -> [AbsoluteScheduleValue] { + guard !isEmpty else { + return [] + } + + var out = [AbsoluteScheduleValue]() + + var iter = makeIterator() + + var prev = iter.next()! + + func addItem(start: Date, end: Date, enabled: Bool) { + out.append(AbsoluteScheduleValue(startDate: start, endDate: end, value: enabled)) + } + + while let cur = iter.next() { + guard cur.enabled != prev.enabled else { + continue + } + if cur.startDate > start { + addItem(start: Swift.max(prev.startDate, start), end: Swift.min(cur.startDate, end), enabled: prev.enabled) + } + prev = cur + } + + if prev.startDate < end { + addItem(start: prev.startDate, end: end, enabled: prev.enabled) + } + + return out + } +} diff --git a/LoopTests/Mocks/LoopControlMock.swift b/LoopTests/Mocks/LoopControlMock.swift index 29be4a17bb..cdb8837439 100644 --- a/LoopTests/Mocks/LoopControlMock.swift +++ b/LoopTests/Mocks/LoopControlMock.swift @@ -8,6 +8,7 @@ import XCTest import Foundation +import LoopAlgorithm @testable import Loop @@ -25,4 +26,8 @@ class LoopControlMock: LoopControl { func loop() async { } + + func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] { + return [] + } } 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) + } +} From a6118677a4e23728f20135738d914ef1e4d15385 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 20 Sep 2024 08:11:37 -0500 Subject: [PATCH 172/184] Fix initialization order (#709) --- Loop/Managers/LoopDataManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index ffe957a931..34f8585bdd 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -183,9 +183,9 @@ final class LoopDataManager: ObservableObject { self.analyticsServicesManager = analyticsServicesManager self.carbAbsorptionModel = carbAbsorptionModel self.usePositiveMomentumAndRCForManualBoluses = usePositiveMomentumAndRCForManualBoluses + self.automationHistory = UserDefaults.standard.automationHistory self.publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate self.publishedMostRecentPumpDataDate = mostRecentPumpDataDate - self.automationHistory = UserDefaults.standard.automationHistory // Required for device settings in stored dosing decisions UIDevice.current.isBatteryMonitoringEnabled = true From 5c129d746c8036036a7e26fc8015cd8f0c831a8b Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 2 Oct 2024 15:26:27 -0300 Subject: [PATCH 173/184] [PAL-798] assign deliveryDelegate (#711) --- .../CarbAbsorptionViewController.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index 31f06e96a2..be8327bba8 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -461,9 +461,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif let originalCarbEntry = carbStatuses[indexPath.row].entry - let viewModel = CarbEntryViewModel(delegate: loopDataManager, originalCarbEntry: originalCarbEntry) - viewModel.analyticsServicesManager = analyticsServicesManager - viewModel.deliveryDelegate = deviceManager + let viewModel = createCarbEntryViewModel(originalCarbEntry: originalCarbEntry) let carbEntryView = CarbEntryView(viewModel: viewModel) .environmentObject(deviceManager.displayGlucosePreference) .environment(\.dismissAction, carbEditWasCanceled) @@ -478,6 +476,18 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif } } + 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() { navigationController?.popToViewController(self, animated: true) } @@ -493,8 +503,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) present(navigationWrapper, animated: true) } else { - let viewModel = CarbEntryViewModel(delegate: loopDataManager) - viewModel.analyticsServicesManager = analyticsServicesManager + let viewModel = createCarbEntryViewModel() let carbEntryView = CarbEntryView(viewModel: viewModel) .environmentObject(deviceManager.displayGlucosePreference) let hostingController = DismissibleHostingController(rootView: carbEntryView, isModalInPresentation: false) From 63c11b470c8fbe4c79208a98cbcbab5f47b0960e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 2 Oct 2024 17:18:28 -0500 Subject: [PATCH 174/184] Support remote data services with automation history (#710) --- Loop/Managers/DeviceDataManager.swift | 6 ---- Loop/Managers/LoopAppManager.swift | 5 +-- Loop/Managers/LoopDataManager.swift | 9 ++++-- Loop/Managers/RemoteDataServicesManager.swift | 31 ++++++++++++++----- Loop/Managers/SettingsManager.swift | 7 ++++- .../StatusTableViewController.swift | 3 ++ LoopTests/Mocks/LoopControlMock.swift | 3 -- LoopTests/Mocks/MockSettingsProvider.swift | 5 ++- 8 files changed, 47 insertions(+), 22 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 12a8b1a996..4719b5a677 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -19,7 +19,6 @@ protocol LoopControl { var lastLoopCompleted: Date? { get } func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async throws func loop() async - func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] } protocol ActiveServicesProvider { @@ -1141,11 +1140,6 @@ extension DeviceDataManager: DoseStoreDelegate { func doseStoreHasUpdatedPumpEventData(_ doseStore: DoseStore) { uploadEventListener.triggerUpload(for: .pumpEvent) } - - func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] { - return try await loopControl.automationHistory(from: start, to: end) - } - } // MARK: - DosingDecisionStoreDelegate diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 116e838a03..0c3e9e24a6 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -332,10 +332,11 @@ class LoopAppManager: NSObject { dosingDecisionStore: dosingDecisionStore, glucoseStore: glucoseStore, cgmEventStore: cgmEventStore, - settingsStore: settingsManager.settingsStore, + settingsProvider: settingsManager, overrideHistory: temporaryPresetsManager.overrideHistory, insulinDeliveryStore: doseStore.insulinDeliveryStore, - deviceLog: deviceLog + deviceLog: deviceLog, + automationHistoryProvider: loopDataManager ) settingsManager.remoteDataServicesManager = remoteDataServicesManager diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 34f8585bdd..519c92518b 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -321,7 +321,10 @@ final class LoopDataManager: ObservableObject { // This moves the start time back to ensure basal covers dosesStart = min(dosesStart, doses.map { $0.startDate }.min() ?? dosesStart) - let basal = try await settingsProvider.getBasalHistory(startDate: dosesStart, endDate: baseTime) + // Doses with a start time before baseTime might still end after baseTime + let dosesEnd = max(baseTime, doses.map { $0.endDate }.max() ?? baseTime) + + let basal = try await settingsProvider.getBasalHistory(startDate: dosesStart, endDate: dosesEnd) guard !basal.isEmpty else { throw LoopError.configurationError(.basalRateSchedule) @@ -1462,7 +1465,9 @@ extension LoopDataManager: DiagnosticReportGenerator { } } -extension LoopDataManager: LoopControl { +extension LoopDataManager: LoopControl {} + +extension LoopDataManager: AutomationHistoryProvider { func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] { return automationHistory.toTimeline(from: start, to: end) } diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index c1d9a3306c..a14710b69c 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -8,6 +8,7 @@ import os.log import Foundation +import LoopAlgorithm import LoopKit import UIKit @@ -38,6 +39,10 @@ struct UploadTaskKey: Hashable { } } +protocol AutomationHistoryProvider { + func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] +} + @MainActor final class RemoteDataServicesManager { @@ -138,12 +143,14 @@ 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, @@ -152,10 +159,11 @@ final class RemoteDataServicesManager { dosingDecisionStore: DosingDecisionStoreProtocol, glucoseStore: GlucoseStore, cgmEventStore: CgmEventStore, - settingsStore: SettingsStore, + settingsProvider: SettingsProvider, overrideHistory: TemporaryScheduleOverrideHistory, insulinDeliveryStore: InsulinDeliveryStore, - deviceLog: PersistentDeviceLog + deviceLog: PersistentDeviceLog, + automationHistoryProvider: AutomationHistoryProvider ) { self.alertStore = alertStore self.carbStore = carbStore @@ -164,10 +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) { @@ -343,9 +352,9 @@ extension RemoteDataServicesManager { case .success(let queryAnchor, let created, let deleted): Task { do { + continueUpload = queryAnchor != previousQueryAnchor try await remoteDataService.uploadDoseData(created: created, deleted: deleted) UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .dose, queryAnchor) - continueUpload = queryAnchor != previousQueryAnchor self.uploadSucceeded(key) } catch { self.log.error("Error synchronizing dose data: %{public}@", String(describing: error)) @@ -506,7 +515,7 @@ 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)) @@ -612,7 +621,15 @@ extension RemoteDataServicesManager { // RemoteDataServiceDelegate extension RemoteDataServicesManager: RemoteDataServiceDelegate { - func fetchDeviceLogs(startDate: Date, endDate: Date) async throws -> [LoopKit.StoredDeviceLogEntry] { + 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) } } diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index f564e7a7d6..4da04fc75c 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -328,9 +328,14 @@ protocol SettingsProvider { 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 {} +extension SettingsManager: SettingsProvider { + func executeSettingsQuery(fromQueryAnchor queryAnchor: SettingsStore.QueryAnchor?, limit: Int, completion: @escaping (SettingsStore.SettingsQueryResult) -> Void) { + settingsStore.executeSettingsQuery(fromQueryAnchor: queryAnchor, limit: limit, completion: completion) + } +} // MARK: - SettingsStoreDelegate extension SettingsManager: SettingsStoreDelegate { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index a6532903e6..e4db2c3d21 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -153,6 +153,7 @@ final class StatusTableViewController: LoopChartsTableViewController { automaticDosingStatus.$automaticDosingEnabled .receive(on: DispatchQueue.main) + .dropFirst() .sink { self.automaticDosingStatusChanged($0) } .store(in: &cancellables) @@ -1690,12 +1691,14 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func automaticDosingStatusChanged(_ automaticDosingEnabled: Bool) { + log.debug("automaticDosingStatusChanged -> %{public}@", String(describing: automaticDosingEnabled)) updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) hudView?.loopCompletionHUD.loopIconClosed = automaticDosingEnabled hudView?.loopCompletionHUD.closedLoopDisallowedLocalizedDescription = deviceManager.closedLoopDisallowedLocalizedDescription if automaticDosingEnabled { Task { + log.debug("Triggering loop() from automatic dosing flag") await loopManager.loop() } } diff --git a/LoopTests/Mocks/LoopControlMock.swift b/LoopTests/Mocks/LoopControlMock.swift index cdb8837439..ef5847651c 100644 --- a/LoopTests/Mocks/LoopControlMock.swift +++ b/LoopTests/Mocks/LoopControlMock.swift @@ -27,7 +27,4 @@ class LoopControlMock: LoopControl { func loop() async { } - func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] { - return [] - } } diff --git a/LoopTests/Mocks/MockSettingsProvider.swift b/LoopTests/Mocks/MockSettingsProvider.swift index 4fcfe6e34f..823f0901f8 100644 --- a/LoopTests/Mocks/MockSettingsProvider.swift +++ b/LoopTests/Mocks/MockSettingsProvider.swift @@ -13,7 +13,6 @@ import LoopAlgorithm @testable import Loop class MockSettingsProvider: SettingsProvider { - var basalHistory: [AbsoluteScheduleValue]? func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { return basalHistory ?? settings.basalRateSchedule?.between(start: startDate, end: endDate) ?? [] @@ -42,6 +41,10 @@ class MockSettingsProvider: SettingsProvider { ) } + func executeSettingsQuery(fromQueryAnchor queryAnchor: SettingsStore.QueryAnchor?, limit: Int, completion: @escaping (SettingsStore.SettingsQueryResult) -> Void) { + completion(.success(SettingsStore.QueryAnchor(), [])) + } + var settings: StoredSettings init(settings: StoredSettings) { From 72078f0df9668111ebf5f9a6c3720449f3760e4c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 11 Oct 2024 08:05:51 -0500 Subject: [PATCH 175/184] LOOP-5088 Update Loop for LoopKit api changes for avoiding thread blocking (#712) * Update Loop for LoopKit api changes for avoiding thread blocking * Fix non-deterministic test behavior * Updates to use latest LoopAlgorithm package --- .../StatusWidgetTimelineProvider.swift | 39 ++++++------ .../GlucoseStore+SimulatedCoreData.swift | 4 +- Loop/Managers/CGMStalenessMonitor.swift | 44 ++++++------- .../CriticalEventLogExportManager.swift | 18 +----- Loop/Managers/DeviceDataManager.swift | 59 ++++++++++-------- Loop/Managers/LoopAppManager.swift | 35 +++++++---- Loop/Managers/LoopDataManager.swift | 3 +- Loop/Managers/RemoteDataServicesManager.swift | 61 +++++++++---------- Loop/Managers/WatchDataManager.swift | 14 +++-- Loop/Models/StoredDataAlgorithmInput.swift | 2 + .../Managers/CGMStalenessMonitorTests.swift | 36 ++++++----- .../Managers/DeviceDataManagerTests.swift | 7 +-- .../Managers/MealDetectionManagerTests.swift | 3 +- .../ViewModels/BolusEntryViewModelTests.swift | 3 +- .../Managers/LoopDataManager.swift | 33 +++++----- 15 files changed, 185 insertions(+), 176 deletions(-) diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift index 314ea4542b..b48bb1f7bf 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift @@ -30,10 +30,16 @@ 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)) @@ -90,29 +96,22 @@ class StatusWidgetTimelineProvider: TimelineProvider { } func update(completion: @escaping (StatusWidgetTimelimeEntry) -> Void) { - let group = DispatchGroup() - - var glucose: [StoredGlucoseSample] = [] let startDate = Date(timeIntervalSinceNow: -LoopAlgorithm.inputDataRecencyInterval) - group.enter() - glucoseStore.getGlucoseSamples(start: startDate) { (result) in - switch result { - case .failure: + Task { + + var glucose: [StoredGlucoseSample] = [] + + do { + glucose = try await glucoseStore.getGlucoseSamples(start: startDate) + self.log.default("Fetched glucose: last = %{public}@, %{public}@", String(describing: glucose.last?.startDate), String(describing: glucose.last?.quantity)) + } catch { self.log.error("Failed to fetch glucose after %{public}@", String(describing: startDate)) - glucose = [] - case .success(let samples): - self.log.default("Fetched glucose: last = %{public}@, %{public}@", String(describing: samples.last?.startDate), String(describing: samples.last?.quantity)) - glucose = samples } - group.leave() - } - group.wait() - let finalGlucose = glucose + let finalGlucose = glucose - Task { @MainActor in guard let defaults = self.defaults, let context = defaults.statusExtensionContext, let contextUpdatedAt = context.createdAt, diff --git a/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift b/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift index e5cc830a70..e30a548a4a 100644 --- a/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift +++ b/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift @@ -82,8 +82,8 @@ extension GlucoseStore { return addError } - func purgeHistoricalGlucoseObjects(completion: @escaping (Error?) -> Void) { - purgeCachedGlucoseObjects(before: historicalEndDate, completion: completion) + func purgeHistoricalGlucoseObjects() async throws { + try await purgeCachedGlucoseObjects(before: historicalEndDate) } } diff --git a/Loop/Managers/CGMStalenessMonitor.swift b/Loop/Managers/CGMStalenessMonitor.swift index 60fe0d06b2..25c0365e1e 100644 --- a/Loop/Managers/CGMStalenessMonitor.swift +++ b/Loop/Managers/CGMStalenessMonitor.swift @@ -12,7 +12,7 @@ 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 { @@ -21,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 { @@ -57,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: -LoopAlgorithm.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(LoopAlgorithm.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 546c7986fe..50489ff1a7 100644 --- a/Loop/Managers/CriticalEventLogExportManager.swift +++ b/Loop/Managers/CriticalEventLogExportManager.swift @@ -199,16 +199,6 @@ public class CriticalEventLogExportManager { calendar.timeZone = TimeZone(identifier: "UTC")! return calendar }() - - // MARK: - Background Tasks - - func registerBackgroundTasks() { - if Self.registerCriticalEventLogHistoricalExportBackgroundTask({ self.handleCriticalEventLogHistoricalExportBackgroundTask($0) }) { - log.debug("Critical event log export background task registered") - } else { - log.error("Critical event log export background task not registered") - } - } } // MARK: - CriticalEventLogBaseExporter @@ -567,11 +557,7 @@ fileprivate extension FileManager { // MARK: - Critical Event Log Export extension CriticalEventLogExportManager { - 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) } - } + static var historicalExportBackgroundTaskIdentifier: String { "com.loopkit.background-task.critical-event-log.historical-export" } public func handleCriticalEventLogHistoricalExportBackgroundTask(_ task: BGProcessingTask) { dispatchPrecondition(condition: .notOnQueue(.main)) @@ -602,7 +588,7 @@ extension CriticalEventLogExportManager { public func scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: Bool = false) { do { let earliestBeginDate = isRetry ? retryExportHistoricalDate() : nextExportHistoricalDate() - let request = BGProcessingTaskRequest(identifier: Self.criticalEventLogHistoricalExportBackgroundTaskIdentifier) + let request = BGProcessingTaskRequest(identifier: Self.historicalExportBackgroundTaskIdentifier) request.earliestBeginDate = earliestBeginDate request.requiresExternalPower = true diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 4719b5a677..99403a37a4 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -288,7 +288,11 @@ final class DeviceDataManager { glucoseStore.delegate = self cgmEventStore.delegate = self doseStore.insulinDeliveryStore.delegate = self - + + Task { + await cgmStalenessMonitor.checkCGMStaleness() + } + setupPump() setupCGM() @@ -1179,28 +1183,25 @@ extension DeviceDataManager { return } - let devicePredicate = HKQuery.predicateForObjects(from: [testingPumpManager.testingDevice]) let insulinDeliveryStore = doseStore.insulinDeliveryStore Task { do { try await doseStore.resetPumpData() - } catch { - completion?(error) - 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) + 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() + completion?(nil) + return } - return - } - - insulinDeliveryStore.purgeAllDoseEntries(healthKitPredicate: devicePredicate) { error in + + try await insulinDeliveryStore.purgeDoseEntriesForDevice(testingPumpManager.testingDevice) + completion?(nil) + } catch { completion?(error) + return } } } @@ -1210,19 +1211,25 @@ extension DeviceDataManager { 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) + + Task { + let glucoseSharingDenied = self.healthStore.authorizationStatus(for: HealthKitSampleStore.glucoseType) == .sharingDenied + guard !glucoseSharingDenied else { + // only clear cache since access to health kit is denied + do { + try await glucoseStore.purgeCachedGlucoseObjects() + } catch { + completion?(error) + } + return } - return - } - let predicate = HKQuery.predicateForObjects(from: [testingCGMManager.testingDevice]) - glucoseStore.purgeAllGlucoseSamples(healthKitPredicate: predicate) { error in - completion?(error) + do { + try await glucoseStore.purgeAllGlucose(for: testingCGMManager.testingDevice) + completion?(nil) + } catch { + completion?(error) + } } } } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 0c3e9e24a6..23d064f509 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -8,6 +8,7 @@ import UIKit import Intents +import BackgroundTasks import Combine import LoopKit import LoopKitUI @@ -133,9 +134,27 @@ class LoopAppManager: NSObject { self.state = state.next } + 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") + } + } + func launch() { precondition(isLaunchPending) + registerBackgroundTasks() + Task { await resumeLaunch() } @@ -248,7 +267,7 @@ class LoopAppManager: NSObject { observationStart: Date().addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) ) - self.doseStore = DoseStore( + self.doseStore = await DoseStore( healthKitSampleStore: insulinHealthStore, cacheStore: cacheStore, cacheLength: localCacheDuration, @@ -263,7 +282,7 @@ class LoopAppManager: NSObject { observationStart: Date().addingTimeInterval(-.hours(24)) ) - self.glucoseStore = GlucoseStore( + self.glucoseStore = await GlucoseStore( healthKitSampleStore: glucoseHealthStore, cacheStore: cacheStore, cacheLength: localCacheDuration, @@ -390,9 +409,6 @@ class LoopAppManager: NSObject { directory: FileManager.default.exportsDirectoryURL, historicalDuration: localCacheDuration) - criticalEventLogExportManager.registerBackgroundTasks() - - statusExtensionManager = ExtensionDataManager( deviceDataManager: deviceDataManager, loopDataManager: loopDataManager, @@ -1045,6 +1061,7 @@ extension LoopAppManager: SimulatedData { Task { @MainActor in do { try await self.doseStore.purgeHistoricalPumpEvents() + try await self.glucoseStore.purgeHistoricalGlucoseObjects() } catch { completion(error) return @@ -1059,13 +1076,7 @@ extension LoopAppManager: SimulatedData { completion(error) return } - self.glucoseStore.purgeHistoricalGlucoseObjects() { error in - guard error == nil else { - completion(error) - return - } - self.settingsManager.purgeHistoricalSettingsObjects(completion: completion) - } + self.settingsManager.purgeHistoricalSettingsObjects(completion: completion) } } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 519c92518b..e11363a446 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -435,7 +435,8 @@ final class LoopDataManager: ObservableObject { carbAbsorptionModel: carbAbsorptionModel, recommendationInsulinModel: insulinModel(for: deliveryDelegate?.pumpInsulinType ?? .novolog), recommendationType: .manualBolus, - automaticBolusApplicationFactor: effectiveBolusApplicationFactor) + automaticBolusApplicationFactor: effectiveBolusApplicationFactor, + useMidAbsorptionISF: false) } func loopingReEnabled() async { diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index a14710b69c..153dd008a7 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -431,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): - Task { - do { - try await remoteDataService.uploadGlucoseData(data) - UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .glucose, queryAnchor) - continueUpload = queryAnchor != previousQueryAnchor - self.uploadSucceeded(key) - } catch { - self.log.error("Error synchronizing glucose data: %{public}@", String(describing: error)) - self.uploadFailed(key) - } - semaphore.signal() - } } } @@ -472,25 +470,22 @@ extension RemoteDataServicesManager { let semaphore = DispatchSemaphore(value: 0) let previousQueryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .pumpEvent) ?? DoseStore.QueryAnchor() var continueUpload = false - - self.doseStore.executePumpEventQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.pumpEventDataLimit ?? Int.max) { result in - switch result { - case .failure(let error): + Task { + do { + let (queryAnchor, data) = try await self.doseStore.executePumpEventQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.pumpEventDataLimit ?? Int.max) + do { + try await remoteDataService.uploadPumpEventData(data) + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .pumpEvent, queryAnchor) + continueUpload = queryAnchor != previousQueryAnchor + self.uploadSucceeded(key) + } catch { + self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) + self.uploadFailed(key) + } + semaphore.signal() + } catch { self.log.error("Error querying pump event data: %{public}@", String(describing: error)) semaphore.signal() - case .success(let queryAnchor, let data): - Task { - do { - try await remoteDataService.uploadPumpEventData(data) - UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .pumpEvent, queryAnchor) - continueUpload = queryAnchor != previousQueryAnchor - self.uploadSucceeded(key) - } catch { - self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) - self.uploadFailed(key) - } - semaphore.signal() - } } } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index dc0997b791..c73af7aeea 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -205,12 +205,14 @@ final class WatchDataManager: NSObject { return } + log.default("*** sendWatchContextIfNeeded") + guard case .activated = session.activationState else { session.activate() return } - Task { @MainActor in + Task { let context = await createWatchContext() self.sendWatchContext(context) } @@ -464,13 +466,13 @@ extension WatchDataManager: WCSessionDelegate { } case GlucoseBackfillRequestUserInfo.name?: if let userInfo = GlucoseBackfillRequestUserInfo(rawValue: message) { - glucoseStore.getSyncGlucoseSamples(start: userInfo.startDate.addingTimeInterval(1)) { (result) in - switch result { - case .failure(let error): + Task { + do { + let samples = try await glucoseStore.getSyncGlucoseSamples(start: userInfo.startDate.addingTimeInterval(1)) + replyHandler(WatchHistoricalGlucose(samples: samples).rawValue) + } catch { self.log.error("Failure getting sync glucose objects: %{public}@", String(describing: error)) replyHandler([:]) - case .success(let samples): - replyHandler(WatchHistoricalGlucose(samples: samples).rawValue) } } } else { diff --git a/Loop/Models/StoredDataAlgorithmInput.swift b/Loop/Models/StoredDataAlgorithmInput.swift index 321614a99c..84151fb995 100644 --- a/Loop/Models/StoredDataAlgorithmInput.swift +++ b/Loop/Models/StoredDataAlgorithmInput.swift @@ -51,4 +51,6 @@ struct StoredDataAlgorithmInput: AlgorithmInput { var recommendationType: DoseRecommendationType var automaticBolusApplicationFactor: Double? + + var useMidAbsorptionISF: Bool } diff --git a/LoopTests/Managers/CGMStalenessMonitorTests.swift b/LoopTests/Managers/CGMStalenessMonitorTests.swift index 89afce784b..9da44f7f00 100644 --- a/LoopTests/Managers/CGMStalenessMonitorTests.swift +++ b/LoopTests/Managers/CGMStalenessMonitorTests.swift @@ -30,7 +30,7 @@ class CGMStalenessMonitorTests: XCTestCase { XCTAssert(monitor.cgmDataIsStale) } - func testStalenessWithRecentCMGSample() { + func testStalenessWithRecentCMGSample() async throws { let monitor = CGMStalenessMonitor() fetchExpectation = expectation(description: "Fetch latest cgm glucose") latestCGMGlucose = storedGlucoseSample @@ -46,13 +46,16 @@ class CGMStalenessMonitorTests: XCTestCase { } monitor.delegate = self - waitForExpectations(timeout: 2) - + + await monitor.checkCGMStaleness() + + await fulfillment(of: [fetchExpectation!, exp], timeout: 2) + XCTAssertNotNil(cancelable) XCTAssertEqual(receivedValues, [true, false]) } - func testStalenessWithNoRecentCGMData() { + func testStalenessWithNoRecentCGMData() async throws { let monitor = CGMStalenessMonitor() fetchExpectation = expectation(description: "Fetch latest cgm glucose") latestCGMGlucose = nil @@ -68,13 +71,16 @@ class CGMStalenessMonitorTests: XCTestCase { } monitor.delegate = self - waitForExpectations(timeout: 2) - + + await monitor.checkCGMStaleness() + + await fulfillment(of: [fetchExpectation!, exp], timeout: 2) + XCTAssertNotNil(cancelable) XCTAssertEqual(receivedValues, [true, true]) } - func testStalenessNewReadingsArriving() { + func testStalenessNewReadingsArriving() async throws { let monitor = CGMStalenessMonitor() fetchExpectation = expectation(description: "Fetch latest cgm glucose") latestCGMGlucose = nil @@ -90,19 +96,21 @@ class CGMStalenessMonitorTests: XCTestCase { } monitor.delegate = self - + + await monitor.checkCGMStaleness() + monitor.cgmGlucoseSamplesAvailable([newGlucoseSample]) - - waitForExpectations(timeout: 2) - + + await fulfillment(of: [fetchExpectation!, exp], timeout: 2) + XCTAssertNotNil(cancelable) - XCTAssertEqual(receivedValues, [true, false]) + XCTAssertEqual(receivedValues, [true, true, false]) } } extension CGMStalenessMonitorTests: CGMStalenessMonitorDelegate { - func getLatestCGMGlucose(since: Date, completion: @escaping (Result) -> Void) { - completion(.success(latestCGMGlucose)) + public func getLatestCGMGlucose(since: Date) async throws -> StoredGlucoseSample? { fetchExpectation?.fulfill() + return latestCGMGlucose } } diff --git a/LoopTests/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift index 6c5c09cf5c..c72a955cab 100644 --- a/LoopTests/Managers/DeviceDataManagerTests.swift +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -34,7 +34,7 @@ final class DeviceDataManagerTests: XCTestCase { } } - override func setUpWithError() throws { + override func setUp() async throws { let mockUserNotificationCenter = MockUserNotificationCenter() let mockBluetoothProvider = MockBluetoothProvider() let alertPresenter = MockPresenter() @@ -56,7 +56,7 @@ final class DeviceDataManagerTests: XCTestCase { cacheLength: .days(1) ) - let doseStore = DoseStore( + let doseStore = await DoseStore( cacheStore: persistenceController ) @@ -72,8 +72,7 @@ final class DeviceDataManagerTests: XCTestCase { } let deviceLog = PersistentDeviceLog(storageFile: deviceLogDirectory.appendingPathComponent("Storage.sqlite")) - - let glucoseStore = GlucoseStore(cacheStore: persistenceController) + let glucoseStore = await GlucoseStore(cacheStore: persistenceController) let cgmEventStore = CgmEventStore(cacheStore: persistenceController) diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index 5b97629de5..dae4f15129 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -234,7 +234,8 @@ class MealDetectionManagerTests: XCTestCase { includePositiveVelocityAndRC: true, carbAbsorptionModel: .piecewiseLinear, recommendationInsulinModel: ExponentialInsulinModelPreset.rapidActingAdult.model, - recommendationType: .automaticBolus + recommendationType: .automaticBolus, + useMidAbsorptionISF: false ) // These tests don't actually run the loop algorithm directly; they were written to take ICE from fixtures, compute carb effects, and subtract them. diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 275b4c3743..4606f1e8a2 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -867,7 +867,8 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { carbAbsorptionModel: .piecewiseLinear, recommendationInsulinModel: ExponentialInsulinModelPreset.rapidActingAdult, recommendationType: .manualBolus, - automaticBolusApplicationFactor: 0.4 + automaticBolusApplicationFactor: 0.4, + useMidAbsorptionISF: false ) func fetchData(for baseTime: Date, disablingPreMeal: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput { diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 1a0be226f2..b8b2d4a50f 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -18,7 +18,7 @@ import LoopAlgorithm class LoopDataManager { let carbStore: CarbStore - let glucoseStore: GlucoseStore + var glucoseStore: GlucoseStore! @PersistedProperty(key: "Settings") private var rawWatchInfo: LoopSettingsUserInfo.RawValue? @@ -69,16 +69,19 @@ class LoopDataManager { cacheLength: .hours(24), // Require 24 hours to store recent carbs "since midnight" for CarbEntryListController syncVersion: 0 ) - glucoseStore = GlucoseStore( - cacheStore: cacheStore, - cacheLength: .hours(4) - ) self.watchInfo = LoopSettingsUserInfo( loopSettings: LoopSettings(), scheduleOverride: nil, preMealOverride: nil ) + + Task { + glucoseStore = await GlucoseStore( + cacheStore: cacheStore, + cacheLength: .hours(4) + ) + } if let rawWatchInfo = rawWatchInfo, let watchInfo = LoopSettingsUserInfo(rawValue: rawWatchInfo) { self.watchInfo = watchInfo @@ -96,7 +99,9 @@ extension LoopDataManager { if activeContext == nil || context.shouldReplace(activeContext!) { if let newGlucoseSample = context.newGlucoseSample { - self.glucoseStore.addGlucoseSamples([newGlucoseSample]) { (_) in } + Task { + try? await self.glucoseStore.addGlucoseSamples([newGlucoseSample]) + } } activeContext = context } @@ -153,8 +158,10 @@ extension LoopDataManager { WCSession.default.sendGlucoseBackfillRequestMessage(userInfo) { (result) in switch result { case .success(let context): - self.glucoseStore.setSyncGlucoseSamples(context.samples) { (error) in - if let error = error { + Task { + do { + try await self.glucoseStore.setSyncGlucoseSamples(context.samples) + } catch { self.log.error("Failure setting sync glucose samples: %{public}@", String(describing: error)) } } @@ -198,14 +205,12 @@ extension LoopDataManager { return } - glucoseStore.getGlucoseSamples(start: .earliestGlucoseCutoff) { result in + Task { var historicalGlucose: [StoredGlucoseSample]? - switch result { - case .failure(let error): + do { + historicalGlucose = try await glucoseStore.getGlucoseSamples(start: .earliestGlucoseCutoff) + } catch { self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - historicalGlucose = nil - case .success(let samples): - historicalGlucose = samples } let chartData = GlucoseChartData( unit: activeContext.displayGlucoseUnit, From fd1130e2034dc4edead00537e0e0215f869f12dd Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 15 Oct 2024 09:43:12 -0700 Subject: [PATCH 176/184] [LOOP-5107] async cgm manager wants deletion (#714) --- Loop/Managers/DeviceDataManager.swift | 73 +++------ Loop/Managers/LoopAppManager.swift | 12 +- Loop/Managers/TestingScenariosManager.swift | 154 +++++++++--------- .../StatusTableViewController.swift | 8 +- 4 files changed, 115 insertions(+), 132 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 99403a37a4..7d29b8f742 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -857,14 +857,17 @@ extension DeviceDataManager: PersistedAlertStore { // MARK: - CGMManagerDelegate extension DeviceDataManager: CGMManagerDelegate { nonisolated - func cgmManagerWantsDeletion(_ manager: CGMManager) { - 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) + 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.settingsManager.storeSettings() } } @@ -1177,60 +1180,38 @@ extension DeviceDataManager: CgmEventStoreDelegate { // 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 insulinDeliveryStore = doseStore.insulinDeliveryStore - Task { - do { - 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() - completion?(nil) - return - } + try await doseStore.resetPumpData() - try await insulinDeliveryStore.purgeDoseEntriesForDevice(testingPumpManager.testingDevice) - completion?(nil) - } catch { - completion?(error) - return - } + 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 } - Task { - let glucoseSharingDenied = self.healthStore.authorizationStatus(for: HealthKitSampleStore.glucoseType) == .sharingDenied - guard !glucoseSharingDenied else { - // only clear cache since access to health kit is denied - do { - try await glucoseStore.purgeCachedGlucoseObjects() - } catch { - completion?(error) - } - return - } - - do { - try await glucoseStore.purgeAllGlucose(for: testingCGMManager.testingDevice) - completion?(nil) - } catch { - completion?(error) - } + let glucoseSharingDenied = self.healthStore.authorizationStatus(for: HealthKitSampleStore.glucoseType) == .sharingDenied + guard !glucoseSharingDenied else { + // only clear cache since access to health kit is denied + try await glucoseStore.purgeCachedGlucoseObjects() + return } + + try await glucoseStore.purgeAllGlucose(for: testingCGMManager.testingDevice) } } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 23d064f509..935726187a 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -908,8 +908,16 @@ extension LoopAppManager: ResetLoopManagerDelegate { } func resetTestingData(completion: @escaping () -> Void) { - deviceDataManager.deleteTestingCGMData { [weak deviceDataManager] _ in - deviceDataManager?.deleteTestingPumpData { _ in + Task { [weak self] in + await withTaskGroup(of: Void.self) { group in + group.addTask { + try? await self?.deviceDataManager.deleteTestingCGMData() + } + group.addTask { + try? await self?.deviceDataManager?.deleteTestingPumpData() + } + + await group.waitForAll() completion() } } diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index 69af2eb992..0bc469e864 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -230,67 +230,68 @@ extension TestingScenariosManager { completion(error) } - Task { - guard FeatureFlags.scenariosEnabled else { - fatalError("\(#function) should be invoked only when scenarios are enabled") - } - - 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 = await reloadCGMManager(withIdentifier: cgmManager.pluginIdentifier) - } else { - testingCGMManager = cgmManager - } - } else { - bail(with: ScenarioLoadingError.noTestingCGMManagerEnabled) - return + guard FeatureFlags.scenariosEnabled else { + fatalError("\(#function) should be invoked only when scenarios are enabled") + } + + 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() + } + }) } - } - - if instance.hasPumpData { - if let pumpManager = deviceManager.pumpManager as? TestingPumpManager { - if instance.shouldReloadManager?.pump == true { - testingPumpManager = reloadPumpManager(withIdentifier: pumpManager.pluginIdentifier) + + 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 { - testingPumpManager = pumpManager + bail(with: ScenarioLoadingError.noTestingCGMManagerEnabled) + return } - } else { - bail(with: ScenarioLoadingError.noTestingPumpManagerEnabled) - return - } - } - - wipeExistingData { error in - guard error == nil else { - bail(with: error!) - return } - - self.carbStore.addNewCarbEntries(entries: instance.carbEntries) { error in - if let error { - bail(with: error) + + 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 { - testingPumpManager?.reservoirFillFraction = 1.0 - testingPumpManager?.injectPumpEvents(instance.pumpEvents) - testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) - self.activeScenario = scenario - completion(nil) + bail(with: ScenarioLoadingError.noTestingPumpManagerEnabled) + return } } - } - - instance.deviceActions.forEach { [testingCGMManager, testingPumpManager] action in - if testingCGMManager?.pluginIdentifier == action.managerIdentifier { + + 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) - } else if testingPumpManager?.pluginIdentifier == action.managerIdentifier { testingPumpManager?.trigger(action: action) } + + completion(nil) + } catch { + bail(with: error) } } } @@ -343,32 +344,21 @@ extension TestingScenariosManager { } } - 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.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() + }) } } } @@ -377,13 +367,17 @@ extension TestingScenariosManager { private extension CarbStore { /// Errors if getting carb entries errors, or if deleting any individual entry errors. - func deleteAllCarbEntries(completion: @escaping (Error?) -> 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) + } } } } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index e4db2c3d21..a58000c0dd 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1599,13 +1599,13 @@ final class StatusTableViewController: LoopChartsTableViewController { private func presentSettings() { let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in (self?.deviceManager.pumpManager is TestingPumpManager) ? { - [weak self] in self?.deviceManager.deleteTestingPumpData() - } : nil + Task { [weak self] in try? await self?.deviceManager.deleteTestingPumpData() + }} : nil } let deleteCGMDataFunc: () -> CGMManagerViewModel.DeleteTestingDataFunc? = { [weak self] in (self?.deviceManager.cgmManager is TestingCGMManager) ? { - [weak self] in self?.deviceManager.deleteTestingCGMData() - } : nil + Task { [weak self] in try? await self?.deviceManager.deleteTestingCGMData() + }} : nil } let pumpViewModel = PumpManagerViewModel( image: { [weak self] in (self?.deviceManager.pumpManager as? PumpManagerUI)?.smallImage }, From 7804046946831b7c0f0d055a5333df77126cef54 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 15 Oct 2024 13:52:10 -0500 Subject: [PATCH 177/184] Enable mid-absorption ISF, and update forecast on settings change (#715) --- Loop/Managers/LoopDataManager.swift | 16 ++++++++++++++-- Loop/Models/StoredDataAlgorithmInput.swift | 2 +- .../Managers/MealDetectionManagerTests.swift | 4 +--- .../ViewModels/BolusEntryViewModelTests.swift | 3 +-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index e11363a446..50495e947e 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -225,6 +225,19 @@ final class LoopDataManager: ObservableObject { await self.updateDisplayState() self.notify(forChange: .insulin) } + }, + NotificationCenter.default.addObserver( + forName: .LoopDataUpdated, + object: nil, + queue: nil + ) { (note) in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopUpdateContext.RawValue + if case .preferences = LoopUpdateContext(rawValue: context) { + Task { @MainActor in + self.logger.default("Received notification of settings changing") + await self.updateDisplayState() + } + } } ] @@ -435,8 +448,7 @@ final class LoopDataManager: ObservableObject { carbAbsorptionModel: carbAbsorptionModel, recommendationInsulinModel: insulinModel(for: deliveryDelegate?.pumpInsulinType ?? .novolog), recommendationType: .manualBolus, - automaticBolusApplicationFactor: effectiveBolusApplicationFactor, - useMidAbsorptionISF: false) + automaticBolusApplicationFactor: effectiveBolusApplicationFactor) } func loopingReEnabled() async { diff --git a/Loop/Models/StoredDataAlgorithmInput.swift b/Loop/Models/StoredDataAlgorithmInput.swift index 84151fb995..ae33304c3c 100644 --- a/Loop/Models/StoredDataAlgorithmInput.swift +++ b/Loop/Models/StoredDataAlgorithmInput.swift @@ -52,5 +52,5 @@ struct StoredDataAlgorithmInput: AlgorithmInput { var automaticBolusApplicationFactor: Double? - var useMidAbsorptionISF: Bool + let useMidAbsorptionISF: Bool = true } diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index dae4f15129..7acfe1b660 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -234,9 +234,7 @@ class MealDetectionManagerTests: XCTestCase { includePositiveVelocityAndRC: true, carbAbsorptionModel: .piecewiseLinear, recommendationInsulinModel: ExponentialInsulinModelPreset.rapidActingAdult.model, - recommendationType: .automaticBolus, - useMidAbsorptionISF: false - ) + 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) diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 4606f1e8a2..275b4c3743 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -867,8 +867,7 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { carbAbsorptionModel: .piecewiseLinear, recommendationInsulinModel: ExponentialInsulinModelPreset.rapidActingAdult, recommendationType: .manualBolus, - automaticBolusApplicationFactor: 0.4, - useMidAbsorptionISF: false + automaticBolusApplicationFactor: 0.4 ) func fetchData(for baseTime: Date, disablingPreMeal: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput { From cc8f3280dc0203098e93fe9c49ec990ab1884737 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 16 Oct 2024 15:48:44 -0500 Subject: [PATCH 178/184] LOOP-4665 Fix bugs relating to determining span of time to use for ISF timeline (#716) * Fix a couple of bugs in determining span of time to use for ISF timeline * Use isf interval helper, and fix bugs with bolus preview and forecast details --- Loop/Managers/LoopDataManager.swift | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 50495e947e..d79b8c2db9 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -343,7 +343,7 @@ final class LoopDataManager: ObservableObject { throw LoopError.configurationError(.basalRateSchedule) } - let forecastEndTime = baseTime.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(.minutes(GlucoseMath.defaultDelta)) + let forecastEndTime = baseTime.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(GlucoseMath.defaultDelta) let carbsStart = baseTime.addingTimeInterval(CarbMath.dateAdjustmentPast + .minutes(-1)) // additional minute to handle difference in seconds between carb entry and carb ratio @@ -366,9 +366,24 @@ final class LoopDataManager: ObservableObject { let glucose = try await glucoseStore.getGlucoseSamples(start: carbsStart, end: baseTime) - let sensitivityStart = min(carbsStart, dosesStart) + let dosesWithModel = doses.map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) } - let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: sensitivityStart, endDate: forecastEndTime) + let recommendationInsulinModel = insulinModel(for: deliveryDelegate?.pumpInsulinType ?? .novolog) + + let recommendationEffectInterval = DateInterval( + start: baseTime, + duration: recommendationInsulinModel.effectDuration + ) + let neededSensitivityTimeline = LoopAlgorithm.timelineIntervalForSensitivity( + doses: dosesWithModel, + glucoseHistoryStart: glucose.first?.startDate ?? baseTime, + recommendationEffectInterval: recommendationEffectInterval + ) + + let sensitivity = try await settingsProvider.getInsulinSensitivityHistory( + startDate: neededSensitivityTimeline.start, + endDate: neededSensitivityTimeline.end + ) let target = try await settingsProvider.getTargetRangeHistory(startDate: baseTime, endDate: forecastEndTime) @@ -382,7 +397,7 @@ final class LoopDataManager: ObservableObject { throw LoopError.configurationError(.maximumBasalRatePerHour) } - var overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: forecastEndTime) + var overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: neededSensitivityTimeline.start, endDate: forecastEndTime) // Bug (https://tidepool.atlassian.net/browse/LOOP-4759) pre-meal is not recorded in override history // So currently we handle automatic forecast by manually adding it in, and when meal bolusing, we do not do this. @@ -433,7 +448,7 @@ final class LoopDataManager: ObservableObject { return StoredDataAlgorithmInput( glucoseHistory: glucose, - doses: doses.map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) }, + doses: dosesWithModel, carbEntries: carbEntries, predictionStart: baseTime, basal: basalWithOverrides, @@ -446,7 +461,7 @@ final class LoopDataManager: ObservableObject { useIntegralRetrospectiveCorrection: UserDefaults.standard.integralRetrospectiveCorrectionEnabled, includePositiveVelocityAndRC: true, carbAbsorptionModel: carbAbsorptionModel, - recommendationInsulinModel: insulinModel(for: deliveryDelegate?.pumpInsulinType ?? .novolog), + recommendationInsulinModel: recommendationInsulinModel, recommendationType: .manualBolus, automaticBolusApplicationFactor: effectiveBolusApplicationFactor) } @@ -1018,6 +1033,7 @@ extension StoredDataAlgorithmInput { carbRatio: carbRatio, algorithmEffectsOptions: effectsOptions, useIntegralRetrospectiveCorrection: self.useIntegralRetrospectiveCorrection, + useMidAbsorptionISF: true, carbAbsorptionModel: self.carbAbsorptionModel.model ) return prediction.glucose From fb2ba32b50f856a15dfac284b07d827ae2099677 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 23 Oct 2024 05:27:19 -0300 Subject: [PATCH 179/184] [LOOP-5119] handle history events across 2 sections (#717) --- .../InsulinDeliveryTableViewController.swift | 67 ++++++++++--------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/Loop/View Controllers/InsulinDeliveryTableViewController.swift b/Loop/View Controllers/InsulinDeliveryTableViewController.swift index 46bdec8e3c..e2e3ea9488 100644 --- a/Loop/View Controllers/InsulinDeliveryTableViewController.swift +++ b/Loop/View Controllers/InsulinDeliveryTableViewController.swift @@ -201,7 +201,7 @@ public final class InsulinDeliveryTableViewController: UITableViewController { case manualEntryDoses([DoseEntry]) } - private enum HistorySection: Int { + fileprivate enum HistorySection: Int { case today case yesterday } @@ -401,7 +401,7 @@ public final class InsulinDeliveryTableViewController: UITableViewController { return 0 case .display: switch self.values { - case .history(let values): return values.valuesBeforeToday.isEmpty ? 1 : 2 + case .history(let pumpEvents): return pumpEvents.pumpEventsBeforeToday.isEmpty ? 1 : 2 default: return 1 } } @@ -411,10 +411,10 @@ public final class InsulinDeliveryTableViewController: UITableViewController { switch values { case .reservoir(let values): return values.count - case .history(let values): + case .history(let pumpEvents): switch HistorySection(rawValue: section) { - case .today: return values.valuesFromToday.count - case .yesterday: return values.valuesBeforeToday.count + case .today: return pumpEvents.pumpEventsFromToday.count + case .yesterday: return pumpEvents.pumpEventsBeforeToday.count case .none: return 0 } case .manualEntryDoses(let values): @@ -426,13 +426,13 @@ public final class InsulinDeliveryTableViewController: UITableViewController { switch state { case .display: switch self.values { - case .history(let values): + case .history(let pumpEvents): switch HistorySection(rawValue: section) { case .today: - guard let firstValue = values.valuesFromToday.first else { return nil } + guard let firstValue = pumpEvents.pumpEventsFromToday.first else { return nil } return dateFormatter.string(from: firstValue.date).uppercased() case .yesterday: - guard let firstValue = values.valuesBeforeToday.first else { return nil } + guard let firstValue = pumpEvents.pumpEventsBeforeToday.first else { return nil } return dateFormatter.string(from: firstValue.date).uppercased() case .none: return nil } @@ -457,24 +457,18 @@ public final class InsulinDeliveryTableViewController: UITableViewController { cell.detailTextLabel?.text = time cell.accessoryType = .none cell.selectionStyle = .none - case .history(let values): - let filterValues: [PersistedPumpEvent] - if HistorySection(rawValue: indexPath.section) == .today { - filterValues = values.valuesFromToday - } else { - filterValues = values.valuesBeforeToday - } - let entry = filterValues[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] @@ -517,14 +511,13 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } } } - 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) @@ -555,23 +548,23 @@ public final class InsulinDeliveryTableViewController: UITableViewController { } public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if case .display = state, case .history(let history) = values { - let entry = history[indexPath.row] + if case .display = state, case .history(let pumpEvents) = values { + let pumpEvent = pumpEvents.pumpEventForIndexPath(indexPath) let vc = CommandResponseViewController(command: { (completionHandler) -> String in var description = [String]() - description.append(self.timeFormatter.string(from: entry.date)) + description.append(self.timeFormatter.string(from: pumpEvent.date)) - if let title = entry.title { + if let title = pumpEvent.title { description.append(title) } - if let dose = entry.dose { + if let dose = pumpEvent.dose { description.append(String(describing: dose)) } - if let raw = entry.raw { + if let raw = pumpEvent.raw { description.append(raw.hexadecimalString) } @@ -688,13 +681,23 @@ extension PersistedPumpEvent { extension InsulinDeliveryTableViewController: IdentifiableClass { } fileprivate extension Array where Element == PersistedPumpEvent { - var valuesFromToday: [PersistedPumpEvent] { + var pumpEventsFromToday: [PersistedPumpEvent] { let startOfDay = Calendar.current.startOfDay(for: Date()) return self.filter({ $0.date >= startOfDay}) } - var valuesBeforeToday: [PersistedPumpEvent] { + 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] + } } From 3fe998bcf21376ccf66c7a2f8c6212bc46e362e0 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 25 Oct 2024 14:39:04 -0500 Subject: [PATCH 180/184] LOOP-5122 onboarding updates (#719) * OnboardingManager is a PluginHost * Update Common/Extensions/NSBundle.swift Co-authored-by: Cameron Ingham --------- Co-authored-by: Cameron Ingham --- Common/Extensions/NSBundle.swift | 22 +++++++++++++++++++ Loop/Managers/OnboardingManager.swift | 30 +++++++++++--------------- Loop/Managers/ServicesManager.swift | 31 +++++++++------------------ 3 files changed, 45 insertions(+), 38 deletions(-) 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/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index 781d4272d4..6435e126ed 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -429,22 +429,6 @@ extension OnboardingManager: ServiceProvider { var activeServices: [Service] { servicesManager.activeServices } var availableServices: [ServiceDescriptor] { servicesManager.availableServices } - - func onboardService(withIdentifier identifier: String) -> Swift.Result, Error> { - guard let service = activeServices.first(where: { $0.pluginIdentifier == identifier }) else { - return servicesManager.setupService(withIdentifier: identifier) - } - - if service.isOnboarded { - return .success(.createdAndOnboarded(service)) - } - - guard let serviceUI = service as? ServiceUI else { - return .failure(OnboardingError.invalidState) - } - - return .success(.userInteractionRequired(serviceUI.settingsViewController(colorPalette: .default))) - } } // MARK: - TherapySettingsProvider @@ -455,10 +439,22 @@ extension OnboardingManager: TherapySettingsProvider { } } +// MARK: - PluginHost + +extension OnboardingManager: PluginHost { + nonisolated var hostIdentifier: String { + return Bundle.main.hostIdentifier + } + + nonisolated var hostVersion: String { + return Bundle.main.hostVersion + } +} + // MARK: - OnboardingProvider extension OnboardingManager: OnboardingProvider { - var allowDebugFeatures: Bool { FeatureFlags.allowDebugFeatures } // NOTE: DEBUG FEATURES - DEBUG AND TEST ONLY + nonisolated var allowDebugFeatures: Bool { FeatureFlags.allowDebugFeatures } // NOTE: DEBUG FEATURES - DEBUG AND TEST ONLY } // MARK: - SupportProvider diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 26c27f44a1..3751c2651a 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -259,31 +259,20 @@ extension ServicesManager: StatefulPluggableDelegate { } } -// MARK: - ServiceDelegate - -extension ServicesManager: ServiceDelegate { - var hostIdentifier: String { - var identifier = Bundle.main.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.Looo" - } - return identifier +// 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 From 95901af7f45703dcfbb8b16c879d9873c9c401e8 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 27 Oct 2024 12:57:24 -0500 Subject: [PATCH 181/184] merge --- Loop/Managers/LoopDataManager.swift | 337 +--------------------------- 1 file changed, 1 insertion(+), 336 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index d4be0b0924..e01db004a0 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -814,126 +814,9 @@ extension LoopDataManager { predictedGlucose: bolusDosingDecision.predictedGlucose, manualBolusRecommendation: bolusDosingDecision.manualBolusRecommendation, manualBolusRequested: bolusDosingDecision.manualBolusRequested) - dosingDecisionStore.storeDosingDecision(dosingDecision) {} - } - - // Actions - - /// Runs the "loop" - /// - /// Executes an analysis of the current data, and recommends an adjustment to the current - /// temporary basal rate. - /// - func loop() { - - if let lastLoopCompleted, Date().timeIntervalSince(lastLoopCompleted) < .minutes(2) { - print("Looping too fast!") - } - - let available = loopLock.withLockIfAvailable { - loopInternal() - return true - } - if available == nil { - print("Loop attempted while already looping!") - } - } - - func loopInternal() { - - dataAccessQueue.async { - - // If time was changed to future time, and a loop completed, then time was fixed, lastLoopCompleted will prevent looping - // until the future loop time passes. Fix that here. - if let lastLoopCompleted = self.lastLoopCompleted, Date() < lastLoopCompleted, self.trustedTimeOffset() == 0 { - self.logger.error("Detected future lastLoopCompleted. Restoring.") - self.lastLoopCompleted = Date() - } - - // Partial application factor assumes 5 minute intervals. If our looping intervals are shorter, then this will be adjusted - self.timeBasedDoseApplicationFactor = 1.0 - if let lastLoopCompleted = self.lastLoopCompleted { - let timeSinceLastLoop = max(0, Date().timeIntervalSince(lastLoopCompleted)) - self.timeBasedDoseApplicationFactor = min(1, timeSinceLastLoop/TimeInterval.minutes(5)) - self.logger.default("Looping with timeBasedDoseApplicationFactor = %{public}@", String(describing: self.timeBasedDoseApplicationFactor)) - } - - self.logger.default("Loop running") - NotificationCenter.default.post(name: .LoopRunning, object: self) - - self.lastLoopError = nil - let startDate = self.now() - - var (dosingDecision, error) = self.update(for: .loop) - - if error == nil, self.automaticDosingStatus.automaticDosingEnabled == true { - error = self.enactRecommendedAutomaticDose() - } else { - self.logger.default("Not adjusting dosing during open loop.") - } - - self.finishLoop(startDate: startDate, dosingDecision: dosingDecision, error: error) - } + await dosingDecisionStore.storeDosingDecision(dosingDecision) } - private func finishLoop(startDate: Date, dosingDecision: StoredDosingDecision, error: LoopError? = nil) { - let date = now() - let duration = date.timeIntervalSince(startDate) - - if let error = error { - loopDidError(date: date, error: error, dosingDecision: dosingDecision, duration: duration) - } else { - loopDidComplete(date: date, dosingDecision: dosingDecision, duration: duration) - } - - logger.default("Loop ended") - notify(forChange: .loopFinished) - - if FeatureFlags.missedMealNotifications { - let samplesStart = now().addingTimeInterval(-MissedMealSettings.maxRecency) - carbStore.getGlucoseEffects(start: samplesStart, end: now(), effectVelocities: insulinCounteractionEffects) {[weak self] result in - guard - let self = self, - case .success((_, let carbEffects)) = result - else { - if case .failure(let error) = result { - self?.logger.error("Failed to fetch glucose effects to check for missed meal: %{public}@", String(describing: error)) - } - return - } - - glucoseStore.getGlucoseSamples(start: samplesStart, end: now()) {[weak self] result in - guard - let self = self, - case .success(let glucoseSamples) = result - else { - if case .failure(let error) = result { - self?.logger.error("Failed to fetch glucose samples to check for missed meal: %{public}@", String(describing: error)) - } - return - } - - self.mealDetectionManager.generateMissedMealNotificationIfNeeded( - glucoseSamples: glucoseSamples, - insulinCounteractionEffects: self.insulinCounteractionEffects, - carbEffects: carbEffects, - pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits, - bolusDurationEstimator: { [unowned self] bolusAmount in - return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount) - } - ) - } - } - } - - // 5 second delay to allow stores to cache data before it is read by widget - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - self.widgetLog.default("Refreshing widget. Reason: Loop completed") - WidgetCenter.shared.reloadAllTimelines() - } - - updateRemoteRecommendation() - } fileprivate enum UpdateReason: String { case loop @@ -941,224 +824,6 @@ extension LoopDataManager { case updateRemoteRecommendation } - fileprivate func update(for reason: UpdateReason) -> (StoredDosingDecision, LoopError?) { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - var dosingDecision = StoredDosingDecision(reason: reason.rawValue) - let latestSettings = latestStoredSettingsProvider.latestSettings - dosingDecision.settings = StoredDosingDecision.Settings(latestSettings) - dosingDecision.scheduleOverride = latestSettings.scheduleOverride - dosingDecision.controllerStatus = UIDevice.current.controllerStatus - dosingDecision.pumpManagerStatus = delegate?.pumpManagerStatus - if let pumpStatusHighlight = delegate?.pumpStatusHighlight { - dosingDecision.pumpStatusHighlight = StoredDosingDecision.StoredDeviceHighlight( - localizedMessage: pumpStatusHighlight.localizedMessage, - imageName: pumpStatusHighlight.imageName, - state: pumpStatusHighlight.state) - } - dosingDecision.cgmManagerStatus = delegate?.cgmManagerStatus - dosingDecision.lastReservoirValue = StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue) - - let warnings = Locked<[LoopWarning]>([]) - - let updateGroup = DispatchGroup() - - let historicalGlucoseStartDate = Date(timeInterval: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval, since: now()) - let inputDataRecencyStartDate = Date(timeInterval: -LoopCoreConstants.inputDataRecencyInterval, since: now()) - - // Fetch glucose effects as far back as we want to make retroactive analysis and historical glucose for dosing decision - var historicalGlucose: [HistoricalGlucoseValue]? - var latestGlucoseDate: Date? - updateGroup.enter() - glucoseStore.getGlucoseSamples(start: min(historicalGlucoseStartDate, inputDataRecencyStartDate), end: nil) { (result) in - switch result { - case .failure(let error): - self.logger.error("Failure getting glucose samples: %{public}@", String(describing: error)) - latestGlucoseDate = nil - warnings.append(.fetchDataWarning(.glucoseSamples(error: error))) - case .success(let samples): - historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } - latestGlucoseDate = samples.last?.startDate - } - updateGroup.leave() - } - _ = updateGroup.wait(timeout: .distantFuture) - - guard let lastGlucoseDate = latestGlucoseDate else { - dosingDecision.appendWarnings(warnings.value) - dosingDecision.appendError(.missingDataError(.glucose)) - return (dosingDecision, .missingDataError(.glucose)) - } - - let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) - - let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) - let nextCounteractionEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate - let insulinEffectStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-5)) - - if glucoseMomentumEffect == nil { - updateGroup.enter() - glucoseStore.getRecentMomentumEffect(for: now()) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("Failure getting recent momentum effect: %{public}@", String(describing: error)) - self.glucoseMomentumEffect = nil - warnings.append(.fetchDataWarning(.glucoseMomentumEffect(error: error))) - case .success(let effects): - self.glucoseMomentumEffect = effects - } - updateGroup.leave() - } - } - - if insulinEffect == nil || insulinEffect?.first?.startDate ?? .distantFuture > insulinEffectStartDate { - self.logger.debug("Recomputing insulin effects") - updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: now()) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("Could not fetch insulin effects: %{public}@", error.localizedDescription) - self.insulinEffect = nil - warnings.append(.fetchDataWarning(.insulinEffect(error: error))) - case .success(let effects): - self.insulinEffect = effects - } - - updateGroup.leave() - } - } - - if insulinEffectIncludingPendingInsulin == nil { - updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: nil) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("Could not fetch insulin effects including pending insulin: %{public}@", error.localizedDescription) - self.insulinEffectIncludingPendingInsulin = nil - warnings.append(.fetchDataWarning(.insulinEffectIncludingPendingInsulin(error: error))) - case .success(let effects): - self.insulinEffectIncludingPendingInsulin = effects - } - - updateGroup.leave() - } - } - - _ = updateGroup.wait(timeout: .distantFuture) - - if nextCounteractionEffectDate < lastGlucoseDate, let insulinEffect = insulinEffect { - updateGroup.enter() - self.logger.debug("Fetching counteraction effects after %{public}@", String(describing: nextCounteractionEffectDate)) - glucoseStore.getCounteractionEffects(start: nextCounteractionEffectDate, end: nil, to: insulinEffect) { (result) in - switch result { - case .failure(let error): - self.logger.error("Failure getting counteraction effects: %{public}@", String(describing: error)) - warnings.append(.fetchDataWarning(.insulinCounteractionEffect(error: error))) - case .success(let velocities): - self.insulinCounteractionEffects.append(contentsOf: velocities) - } - self.insulinCounteractionEffects = self.insulinCounteractionEffects.filterDateRange(earliestEffectDate, nil) - - updateGroup.leave() - } - - _ = updateGroup.wait(timeout: .distantFuture) - } - - if carbEffect == nil { - updateGroup.enter() - carbStore.getGlucoseEffects( - start: retrospectiveStart, end: nil, - effectVelocities: insulinCounteractionEffects - ) { (result) -> Void in - switch result { - case .failure(let error): - self.logger.error("%{public}@", String(describing: error)) - self.carbEffect = nil - self.recentCarbEntries = nil - warnings.append(.fetchDataWarning(.carbEffect(error: error))) - case .success(let (entries, effects)): - self.carbEffect = effects - self.recentCarbEntries = entries - } - - updateGroup.leave() - } - } - - if carbsOnBoard == nil { - updateGroup.enter() - carbStore.carbsOnBoard(at: now(), effectVelocities: insulinCounteractionEffects) { (result) in - switch result { - case .failure(let error): - switch error { - case .noData: - // when there is no data, carbs on board is set to 0 - self.carbsOnBoard = CarbValue(startDate: Date(), value: 0) - default: - self.carbsOnBoard = nil - warnings.append(.fetchDataWarning(.carbsOnBoard(error: error))) - } - case .success(let value): - self.carbsOnBoard = value - } - updateGroup.leave() - } - } - updateGroup.enter() - doseStore.insulinOnBoard(at: now()) { result in - switch result { - case .failure(let error): - warnings.append(.fetchDataWarning(.insulinOnBoard(error: error))) - case .success(let insulinValue): - self.insulinOnBoard = insulinValue - } - updateGroup.leave() - } - - _ = updateGroup.wait(timeout: .distantFuture) - - if retrospectiveGlucoseDiscrepancies == nil { - do { - try updateRetrospectiveGlucoseEffect() - } catch let error { - logger.error("%{public}@", String(describing: error)) - warnings.append(.fetchDataWarning(.retrospectiveGlucoseEffect(error: error))) - } - } - - do { - try updateSuspendInsulinDeliveryEffect() - } catch let error { - logger.error("%{public}@", String(describing: error)) - } - - dosingDecision.appendWarnings(warnings.value) - - dosingDecision.date = now() - dosingDecision.historicalGlucose = historicalGlucose - dosingDecision.carbsOnBoard = carbsOnBoard - dosingDecision.insulinOnBoard = self.insulinOnBoard - dosingDecision.glucoseTargetRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule() - - // These will be updated by updatePredictedGlucoseAndRecommendedDose, if possible - dosingDecision.predictedGlucose = predictedGlucoseIncludingPendingInsulin - dosingDecision.automaticDoseRecommendation = recommendedAutomaticDose?.recommendation - - // If the glucose prediction hasn't changed, then nothing has changed, so just use pre-existing recommendations - guard predictedGlucose == nil else { - - // If we still have a bolus in progress, then warn (unlikely, but possible if device comms fail) - if lastRequestedBolus != nil, dosingDecision.automaticDoseRecommendation == nil, dosingDecision.manualBolusRecommendation == nil { - dosingDecision.appendWarning(.bolusInProgress) - } - - return (dosingDecision, nil) - } - - return updatePredictedGlucoseAndRecommendedDose(with: dosingDecision) - } - private func notify(forChange context: LoopUpdateContext) { NotificationCenter.default.post(name: .LoopDataUpdated, object: self, From 88054524b0b2682ede8e7d6d883b8a7cab49b84e Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 29 Oct 2024 12:46:29 -0300 Subject: [PATCH 182/184] [PAL-818] block mock service when simulators are not allowed (#721) --- Loop/Managers/Service.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/Service.swift b/Loop/Managers/Service.swift index fa4a056779..6a6cc25764 100644 --- a/Loop/Managers/Service.swift +++ b/Loop/Managers/Service.swift @@ -16,6 +16,12 @@ let staticServicesByIdentifier: [String: Service.Type] = [ MockService.serviceIdentifier: MockService.self ] -let availableStaticServices: [ServiceDescriptor] = [ - ServiceDescriptor(identifier: MockService.serviceIdentifier, localizedTitle: MockService.localizedTitle) -] +var availableStaticServices: [ServiceDescriptor] { + if FeatureFlags.allowSimulators { + return [ + ServiceDescriptor(identifier: MockService.serviceIdentifier, localizedTitle: MockService.localizedTitle) + ] + } else { + return [] + } +} From 3654d9d5757a08eaaed14a3e59263c348a1933aa Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 30 Oct 2024 16:29:57 -0300 Subject: [PATCH 183/184] [PAL-818] pass allowDebugFeatures to service (#722) --- Loop/Managers/ServicesManager.swift | 2 +- Loop/View Controllers/StatusTableViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 3751c2651a..5fbc5b7f41 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -87,7 +87,7 @@ class ServicesManager { return .failure(UnknownServiceIdentifierError()) } - let result = serviceUIType.setupViewController(colorPalette: .default, pluginHost: self) + let result = serviceUIType.setupViewController(colorPalette: .default, pluginHost: self, allowDebugFeatures: FeatureFlags.allowDebugFeatures) if case .createdAndOnboarded(let serviceUI) = result { serviceOnboarding(didCreateService: serviceUI) serviceOnboarding(didOnboardService: serviceUI) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index a58000c0dd..c77c8ae3bf 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -2304,7 +2304,7 @@ extension StatusTableViewController: ServicesViewModelDelegate { } fileprivate func showServiceSettings(_ serviceUI: ServiceUI) { - var settingsViewController = serviceUI.settingsViewController(colorPalette: .default) + var settingsViewController = serviceUI.settingsViewController(colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures) settingsViewController.serviceOnboardingDelegate = servicesManager settingsViewController.completionDelegate = self show(settingsViewController, sender: self) From 438096a8184fd26fd11c60c96e15aeb8ad6c25d3 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 31 Oct 2024 11:09:35 -0500 Subject: [PATCH 184/184] Use current data for manual injection prediction --- .../ManualEntryDoseViewModel.swift | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Loop/View Models/ManualEntryDoseViewModel.swift b/Loop/View Models/ManualEntryDoseViewModel.swift index f6d1235df6..5b2d45e4c6 100644 --- a/Loop/View Models/ManualEntryDoseViewModel.swift +++ b/Loop/View Models/ManualEntryDoseViewModel.swift @@ -30,6 +30,8 @@ protocol ManualDoseViewModelDelegate: AnyObject { func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) async func insulinModel(for type: InsulinType?) -> InsulinModel + + func fetchData(for baseTime: Date, disablingPreMeal: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput } @MainActor @@ -227,7 +229,11 @@ final class ManualEntryDoseViewModel: ObservableObject { return } - let state = await delegate.algorithmDisplayState + let displayState = await delegate.algorithmDisplayState + self.activeInsulin = displayState.activeInsulin?.quantity + self.activeCarbs = displayState.activeCarbs?.quantity + + let startDate = now() let insulinModel = delegate.insulinModel(for: selectedInsulinType) @@ -239,21 +245,15 @@ final class ManualEntryDoseViewModel: ObservableObject { insulinModel: insulinModel ) - self.activeInsulin = state.activeInsulin?.quantity - self.activeCarbs = state.activeCarbs?.quantity - + do { + let input = try await delegate.fetchData(for: startDate, disablingPreMeal: false, ensureDosingCoverageStart: nil) - if let input = state.input { self.glucoseValues = input.glucoseHistory - do { - predictedGlucoseValues = try input - .addingDose(dose: enteredBolusDose) - .predictGlucose() - } catch { - predictedGlucoseValues = [] - } - } else { + predictedGlucoseValues = try input + .addingDose(dose: enteredBolusDose) + .predictGlucose() + } catch { predictedGlucoseValues = [] }

k6}Wl9r$OAr=ZcN8@^o9ll82VYs2GBB4#q zN?<6?I~lClm(8L>Z(b_0r5CQkx%8!gNiW^LL<1+8n?<4+HEB|x&sBk{EMpXBc$U8} z3i7T_P*d6~PXed|0fGxjPeTNe+QAygv6s zruG?q7FTUn4|#OVdcuMktLv?)_pCXw7`i&u`9e!%FiP<#-v9fvVGLd8;>KoX30*hu zi>VANj5UL0r-+5vFwVdKSkp61M89FIE~pL4@RjKJb9U{a+tyNwnf{Bgwa}Bdf0za- zj>wr2lv5@-XWapo!_@Fw61x3}w>G8iQ1j1Mg(iNplrWL{MqUR99AOP)j}gt@9@PVR1;*FAHOZG10y zUU`nvhQhO`T}7+HCqnBfQ&Q|?DT_Bl&gPsPCTOPl+q?M3lL$%8#$9ZUz(BkJdd*4v z11x$H{GyjAqWzOD$)`(JGsbn_@F!ie6mjJ@ zvP@0Fxe%NnMhZg;rPq-wCVx3=$rLCP0-NJpenE%p^ZNz>|ydh5aO|AFM9MsFpoL8X@me(O-NL yaEah}L#>xfC&L)H>5mNJ9=Kk1zfA_Zb3()qhYn1|P+MLCJ~|o(>ZNM7A^!(9 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, -20) + .padding(.bottom, -22) + } + } + .insetGroupedListStyle() + .navigationTitle(NSLocalizedString("iOS Focus Modes", comment: "View title for iOS focus modes")) + } +} From 26ffff00043ce53a773cc7b4282fc8f0d2214776 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 11 Jun 2024 18:13:10 -0400 Subject: [PATCH 077/184] [LOOP-4882] Mute App Sounds UI Updates --- .../focus-mode-1.imageset/Contents.json | 11 ++++++++++- .../focus-mode-2.imageset/Contents.json | 11 ++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Contents.json b/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Contents.json index 8c6a6923f5..1ddc177843 100644 --- a/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Contents.json +++ b/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Contents.json @@ -1,8 +1,17 @@ { "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, { "filename" : "Focus.png", - "idiom" : "universal" + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/Loop/DefaultAssets.xcassets/focus-mode-2.imageset/Contents.json b/Loop/DefaultAssets.xcassets/focus-mode-2.imageset/Contents.json index 8c6a6923f5..1ddc177843 100644 --- a/Loop/DefaultAssets.xcassets/focus-mode-2.imageset/Contents.json +++ b/Loop/DefaultAssets.xcassets/focus-mode-2.imageset/Contents.json @@ -1,8 +1,17 @@ { "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, { "filename" : "Focus.png", - "idiom" : "universal" + "idiom" : "universal", + "scale" : "3x" } ], "info" : { From 133818fc2f28c2c4033c42ecf55b16ba7582df30 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 12 Jun 2024 10:16:05 -0400 Subject: [PATCH 078/184] [LOOP-4882] Mute App Sounds UI Updates --- .../focus-mode-1.imageset/Contents.json | 21 ------------------ .../focus-mode-1.imageset/Focus.png | Bin 103490 -> 0 bytes .../focus-mode-2.imageset/Contents.json | 21 ------------------ .../focus-mode-2.imageset/Focus.png | Bin 175217 -> 0 bytes 4 files changed, 42 deletions(-) delete mode 100644 Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Contents.json delete mode 100644 Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Focus.png delete mode 100644 Loop/DefaultAssets.xcassets/focus-mode-2.imageset/Contents.json delete mode 100644 Loop/DefaultAssets.xcassets/focus-mode-2.imageset/Focus.png diff --git a/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Contents.json b/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Contents.json deleted file mode 100644 index 1ddc177843..0000000000 --- a/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "Focus.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Focus.png b/Loop/DefaultAssets.xcassets/focus-mode-1.imageset/Focus.png deleted file mode 100644 index 01cea7de97bf91521d9e877f4563f213b00fb1e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 103490 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%0A}wWT2RN}KM55Y~^X~Nm#dWjMPf$>V4Or3u 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 %^*_bAZ#arONq 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∾2Q!86vO?d}=ZB`oCoM;V>8he;rKN)5zX=(sK(|U#UqlYV2EdGsyC;fg#76*<&+fOo&c^*EvjKXYU}Ye!@iP*EM+(g&iTHKkuHb{s1UZ zY!EYnl!@Rm7v+@>*%g~TxxHl0RV{Pv0DDusdzD6^E&Utbi}};nf#}-b>i%y~m4L~5 zUf4$IMwimj_|ux&h^%s_)_*{WM&?_QFa&4-SwcmsRNHAGJ?{I-8NwgL?s{9Q53wHq zBd3q1r8G)n&6OAP&WAfnAXNsZDq)riRM@{_1v2aMF{n0!F%@#v10zW%!AVBcytyin z(y)D%z$;xXHhL41P($W{pm{pWt5R;S-EB`Xuk8x*Ynh*`({tQe;Q|H82aHBwCljn?(AmF?d)z`G2pM?ZWnAn?ulto z9RJG3pyB6RkQiA00c2txYFR`9G`dV0FA9-LMbNy1L04+(%5PJe(6+Lxx!oN&i@DB9 zrmsA9cUBff;PETw4~Qb7a>br&0x+`L?~%XYd9>)YJM)%Yd&;^Jw3%+Yo)LyHv>j?A z`Dpa1*0#lbFL4>3aIfIk+0MDgOHBbNE^9JYDu=%J%%1Z000tY#K6_z*2blTi3=ls(CNh&;GILjSSJ5oBu7iTsVQ_D2E6&Qw zyq?u-a=Wh1kkx|g%&DG$odLUgmr2-&o{K-(uSpZz(Xpudpx@UXz`96Tiqjx1Y6*JU zPLLtB6JORP<+fi=@}++$TOU5HH-lh;`@BWjE^S})Xze3i$by2*S5;-`C0~7q`pR!) zFQ*vQiMZcU*(tat)scp^qVUG8RdRRkw&;m-4~Oh!C+{AOGfWByFO+j=@#IJ_((W&? zN0g=d&Aa_@Pu%6892Ez%3w^W^5)r8YT(NvMxN7w*G`%|pA;LrMgL%O#D<7C9{iQ_Q zkWtTFW5~hfonBje+wXtz&e<15t;H&&Tv=pye}lg~AU!e0)&qrH7oVl}DH+#7sLj{X z)Rfz_#XZr`BE;Y9bn+7+5WpQk7z58Wv`mvm;(A2h)lV)HLCe?~M)ZUM@8#?G9LXTr zSUt2UasSOYvsNDq6NfbDN3H6gR?iO*LJ*vOgRCeY!miyEcbIY7l8e zh#@b8YPMOcmyintn30Q#qjvU#!gdjUdf{)`?owmiqGJ!udN48M&%}KQGHL^Lb!(xc zK|*fdtCgIe^nBUvEm53pb1mEtSX~^U4(u@Vig@o=`O^uG^ z^r<)REkuJY4j@G`Uxb!N<3nBx){^4Agg#yJnm2KDb)+zPg_HU)k&@{73He5o(UR3= z(Xh#tlKYGLBZ5yE`mrG8SfDa zW9mm^1_#)j3k{Gx?U8l9+Adq6dK&k{n&sF)*E^S}o3g05Nz7zead7h|(Zdn;QX)<8 ziowHd%@4fWd z@ARB-w1&_bpxxQ$( zZ<#�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%0A!;CIND3 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