From 26e156081a481d04967dc6b36b19048f8f41084c Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 25 Sep 2023 15:22:19 -0300 Subject: [PATCH 001/421] [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/421] 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/421] 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/421] 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/421] 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/421] [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/421] [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/421] [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/421] [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/421] 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/421] 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/421] [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/421] [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/421] 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/421] [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/421] [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/421] 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/421] [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/421] [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/421] [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/421] [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/421] 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/421] [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/421] [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/421] [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/421] [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/421] [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/421] 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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] 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/421] [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/421] [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/421] [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/421] 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/421] [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/421] 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/421] 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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] 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/421] 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/421] 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/421] 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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] 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/421] [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/421] [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/421] [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/421] [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 zDx4FgDQ(R}pXUoB}P?EZYD7YQePBaYEr9tMv`WjT`53G^%6P3?G z*_Eey2_;`0DP14~UP){*w?xo$1mfchR^0kY=-|*Vw(huRZroIN&PI?oLbQMQl|Dvh z!Ty1Nz**Y`PQPk#R#UH!bSs+x@QUm%!&VX&{(vZugi=NR;_3$5bh92wF@e(yfMx03 zH9kz{V?5;YR_Z(htC6djJ$F!9#5luh>>kGQ`^HUFQ=y4<~ozEL09dapLMf>HCq_#rmNVWnq210E!)uh{%_T5Py<+?GVf$a@%=`=yb9k!afiOFg>5Swe2kV=F z$#E`F*i+%)rgHdtdZxgd;MBF)zUzy&EJd({lbLgz` z0&Dg8qwrHYj*huHGS@bCdb*cTM!_G!$CAOzoLI|j=!&`2(qrTM3?NPv5@I3O`tolG zi#j7NQK&Lw{S#lQ=uk0Q-u57~OLjZr-2|{jbN>Phsqkrd9#xeS^kr~y;R^9KWa;Z8 zw@WS{DZefKgQA@!E}eC8D7aOVCN%PMk^|(?IRjL9>F{=berre(Qz#HdHQ^Sa0dCmo@-h=yd3@V;s0Z0VLeVX_f9Hlr ziE$Rh6t+V_ z&p%1`jplqvKznVI$Y>#R=Uo?RmVQ~7Yk6|sTW_`=J3ufoV{cALN2sYk{l zgR>7BA{dvj{CAq*Mz_6bqCX(-o48GllT7V&;~sx6QC-rVCy}f}Ka1}Frg+Cvc{3YS z=(Ia2z7sBkYiY4bK)4U=CB~Hzzd+plDsY1KW!F$2lF$OAJf}SpSL`3R4|R%=abCE& zIruN?nc~-^668+_7#?L5SuI1om5!C--~H;tBL?=&Fg_7H3|~q8FrhM70c3|rTcHLs zA+&7eY=Xhf#BTI6YV&}T>tZ*Meuncqr2(uT>Eq6_6@#(+2)tWw>j58HS&`SY0>l-+ zDE{%$Q6R-)C_Ra%GDEkRa{N1A6OVrJMyCf%#XF$Zg`@Yp7=TPLyTRNF9`m*wBvM!k z3nt?X${h`ff0-)@%VoanXCKerLLSTbLYd6tD4aq-oHr+us5_-8E_hHM`|tph14#(u z;&a6Z0~Rk=x`KAP0!#~jfJYxFehHfv$9@rwez%!{AmTn}T*{blv({vuXk4Y;c7CAZ zQ|gtHm7J;%lQDZB*Dh4Nl+1}=n?wFaVP^6TX)|9~#pO&BFm3FUOU^9p&oAhN*szp@ zJl9r$J8@U#%b@OPMeZ=ZowIsIILN!jJ;P}+_;!oD)Xbp)B%=4d%d0L}uePMLq_(Rs z1jbM#{KsggcS577QIT&05q)lDEUZ_wrH^ENJ--bLc~wy@IQCTkn;8?7-?L2*J&!Jd~C zJDVzn&&z97Z;Yf}wv+f>694ROuul8F+ecm~Mf3K+UWKhjhFad7n>u!G1syKJPdO#)lD5(u6FK;CaoMQj#-2k#4~?2PEnUsn>3AsIZs6-?#}ja9 zHQB2htjJeWTx?<%eM@?bcHdfPQ(L+bl%KW{{Q5z4$g#4b#cA7?x{;14;^&ljCcFgU z;4L0&Cb0U891dkh-4Tko2L6NxFhw{}ic7J{lEzA`lAM9DkP{WfmFVTtEi2W#f^(yI zw1yakk{!RR&_5!+wwAp0$Zorv&Z5Ug@psPXY$zaNq?Xi@*LV8v`8Zo=#}WHP8p*oe zM4hhrf}2`YOIVTg}=i?4sWGi9q$jWyc?GZU|C2ZXN}p5(=a(~0+cO?>l2v7!f0_maAllb zd{a{JUGwOTc*=zl?y@vkvO{)?JS`ccsij&;!jTpHg4k^zOJ8^jix#R``DEHf2=gt5Jw|59j6;33hNx4sn^UvEWcw{jCCz&d+ySlW zGcDB@5Y{h_-D`Y`hGQtQJ_mF?|CHDIwGHlfDvi4YT6g!%xRrSA=3WloSIXnUpDNQs zwesUP7DmnFbof|Q)a9XBUo;M7dD1R6)dTvZGu&zOgZs_h`E!l$Ro-6$(TUxelZHVz z(r2K{4K531dAVn^X7F?%FDeIjHT1RPF{7<7>3*+@16F#jx{I}?M{-boH)HogWqap{ zp#+(+)_~HhYH(zluA07hrB$W>LZ%Px%~EzVc|rdJGcQ zB?kT+EV?3&Teu{5#$t5o>ADum=n4DaNq4d=S`ZSCi$A*p<}#&sbMNmJ_czm#x*^9o z@Y{l_pj#bzjN2Tc#W0~BM%5rHb0R*0rAYDjD-4BndS<=U{1vd;w+y`cm$Ey$AD9Ga zm2v5HB*v$y#H$#muz)jbEW&5<7maeedC*;(y06(E>9pakcl1UZ(XDY}MzN6?=+07c z?12?7UUsskLN(sO}ca2!5md`}U$lKyVnJa%pyCk=z zfpKsCdZT34VUvyu@MRqQrnb5qe^{P+_GGSUCuPhz>}}MPS$**iqsi9b9?{a-jb3ff zP_cDB@?GuJiCowH2Z=AOE7x(St=!vrwuO2g-l8iQlpq%KG@ zj3=yJvVy-gI9*qa8%KBZ{xsq>@Vj1U&WZGQ1LubLJGj0YP-z4wk&PSFN!>$&32;JV z`DjP4DneQ$oU=K6Xf3%Ax~lXM0^#u7zEXrF!A!nEYA<_c{U?`7D5TcY)y~E>KF^64 zZ`D<>`0H(ny;HO`%66fIPpwfvWl;6vNYltXa!~op1Amx;xT}*88ERE-k|59y{k^4{Ut&>ANcm81Z$EFvR zdujI;uE{65*J2wr8=@YiY3JJ%`R1J+?aJ%z;c!HSGrbU-o?)C4n*9_|RaJL!sHMO> zC3S3h8FTF4J0<5-itSoXl*@GH9eT|+RcEI2M>2~6$OMtKYX~<~BUYy(9bz-2239+3 z^&H-cjr(STagl!hXE_toM5!~5BU?sPe8r!eEJ^nE*C`qtZ;*$im4#MW4R1RM(Efc2 z_B@Y-{yaHTaH!g;ovsZ#*Bm5ox;$-jD-*4*`Deawc);ktza(j-ZsGnriBzw*8{GVs zaeNq3jC@Y{t$LUbLEZUYj=6{t3YmHs^TTsvFoxby&mJoWfUXA zag=N67ge$xNVt~@BBXeEJdIZN9ONW5 zJ@JHR7#pPt=GMONlv;83Om)((R+hpM}hYtO$H-K1nx?C z?|bsJMU&EfX*dfD(|ps7#{O+^b!?)Ipsxa~H99dQ%fEXfpP|=k^rs?U=_k(}&x^Y) z{Wkva9zEHzz8id74L^1`JnB(LR9qqh{U=P&FQ+?SU!|DjCvD$T4^HYWNJ>Qkah`2I zCp4cq{%d>GXtnUZ;sK(n{Zo&j9(oCJf&m&|moGyg<3f7WWEHLK(silZ-c6S#O>>f@ z;g*Q17ST9@yE#U4#F|G&kDt) z_nn@e8&}EKW1QifYm>sBR;Anyiz!!|MtT?%4z9h=`L3yq_PqlkQqd&?5 z?qK!c@-aPlE6W_GSr%sWR{+!nm!g7@AN_8l%0P1E z`9%J^qLxoI$rmTBrvE~3lO7tj;_K%;2IcbX$j)3QyJ}vhQ7`L3>Rh48W(w)ia*Az+>iANZ za0nqNQ-z(cGV0=%0sn+lx=%2~WbV&-l#p-7X2GtgGmcI2Oy=n2-_}xDc~iMgJW=x5 z(D>2Rc8XKz?-nUosT-D@niS#0?E)KhW$Sp}o1!!-W*M`Lbmo*T!$~dM>xLOjJtr#k z17q4$xeD1QIv)0>$XFP0qMf;=B`D#5|55DdWNI*IypD$R!=xy@UQCB_CV}{+=WT0? zm|`Y~@((Pg^u1pB&uAw&uJJLS*~NRU@Doh9L^FyR6~epu2S?|}8@zhbBd6x{Blc?y zwHr1@de~Yy#0frW39o3G@*>4)!>#_ZJ~4Oqm+MYn*|)%~`BLL@u9wxpNW+o6nWjH7 zXF^Jiw_VB<#035ZTnbW^wM z5A^=J9G`TVI&8~-VrGim{Ycf%%yW~weq@tkc;V=?s7{q?ZXAuHZ;yBpNUY4S0F~i( z7c#Hmg194d(N(4;!y)%#9~hA=uJx?LcNs>|Wpt1om8B&enos(Vg>Wa(JAjW*$T@NLeW(Aw*Ns)z3 zy&-B}1Vmzmi>4l#ajkY^q~a(2aj|Hp>^qAw!pcuYbrh$bhJ)rn8qlYCvgp+)!&n=U zOPM6%ow4@>=b=Qb2VpdeZTrdL0Yi4Jjej+?|LtXK@aZ8AT8Xov+7i7Q8Ap=437(It zjn)@Y8eRuU*RA3(tG}6TFy^W98X^1e+p^tMc7zc2vp@kuyxmjaFUzXT^sg9n%?>Rb zv&s`MP%^h2w7?BNZO*zr`GuXwC;CyGwxcw|dB2DBUkPRJ7_Ro1ve!}0OL@K;s`WwQ zrf4s_VLTUdgMR#_-6szx?%5`Ol|TEWn6^MC^#S}yPEW*wK8csaRwd>u3-2f)8Y%N$%_Xz6Yxx;or2eEp$BRfXN{9}ruS5uyQ=GDCyk<)bJXjEcg*f1 zT-r4gTjYE#ng@ew14Cs*^k5Mrc*e%e3Ay09*gwuJ;>t_Tc4gQ`{U-G;~n3&_O)#Xq}-HdsQGfGRsYZLc>&BsA|SN zmhQVn@azul>+BC>C=2#&LB4OX_8lgyLowUJyH5}P+&K-^esWMs;S7t8L$(bA(bQUZ z_~9q&1LU}D+mP0jXBp2YuDhSeFGY&2izJ4)-hb(%$FE1_zUac=@(0JctJ-vgkfYkL zk^FJI#CD09{_pY~zCO0A7Pj-oUTJ?>b~qvMrU%4;kEHe!4Q62r<#!ta3&n3=Z%tsa z--><(Og$vF-7^bIA5w!IemMZPLTAEBbfYZTaIX>@g}ui747XMvhbHPVWrGyg4OmsdOA}mZTPzxBb-EE0N%KqFD)k67Va|NB+b%wud+4SS(iVP za>41K(m=sZ=@pX%ojX8x5LUji?7qtPz+BRBBZPLg@syJ2P!46R#jHq}BfCFK60N0d zGJ(izC$kZ74+fwYn&|Ri43wBrRa*SJo(Zd;q?^NHL7dw6-dH_TU1{78+~*_s-mAiJ z4M?_AvCiXA&RkcXfjt9tmc^*|TuIX8kmLY&mU!~L>9O%Tch<~1pjZ6+7a^ATNDSce zm)viSci_KJZ09)0m&|cpCj2&U7~Ay5yE3`eogQ&<(v^js$~wJJyZ7?N0@ksCvmK^| z{W2KP=}#FEXiw}%YhD&!FLn{W`Z(k4AECzHLRnneT_?qx)!JOJSy|x;MJkO2AY8X4I*hXH#3t*GE3WsugwYC>6R+)hFG$ulzy3 zH?t4sZFTvDw4Zg}5`DoF>y=x$fbS|AI=28lk{S50QzQ@9ySSR{OgYqLgnf{_!X6J$ zRL#Bqv!uzb76))dCiAmn(kbibzsn}clLw{FWN$7b?dW!LJfeaNq_$rYL&kCJK3Vau1&f|uTn$ z_uItgzl*FORz{6XvOoQ0rvzh&nv3P~V7tM>RYP#)bLI9|H!H!sd(+Aq_j*TGl-g6e z$fsw&PCjX*o)!?QUiYtk(6;Hpc{!M2S!ewWrbiPeHE6}nd5aqsjy!aZl|Zx~^89>} zb>bEjxch1Oj7{$UCn_884&%5Fos{p$kgHTtW(Ns_G{lx*c-dar=s+w5-%s#W2s{8yyO5F?RfN4(_(jU z1rQ(pNsroaY*@w7rpPOh;KA?v?jgk4Z8fat%^)<|YiSRp7TRLj?du~zI(E07m znI|WMBrfm#-)W>@hOs?*KaQXiL$__eUZM~xk#=>;V;J>vEqiU#^pX zw(^~A(U2zBbEP1R!2!Z2dpW-ZG!=QYeLY_5LMOO9x}0@@WGqGDm-Ey;YpdNkT$E|) zq;F~bKR79$nw+p6$uxbx^9IYs$uHW5p`YR!WrI!^DZBfQ{h)>J^l#1B%??Raf(*_K zcys)g@r7z_Z9y`&pf2hjM`u21b1@^QCh4(M%fxZ*9p@Z^`$dv^Z{8`N#d#E$RRV7! z`uweGx_BT^4ZO)fE`Y{JrA{XZ`Gn*D2XQ|9PT3c07 zdq$}(_TFmOs@l}vJNDkYMvNf#jx9C&rr+mz{)gA^$(>xcM>qiV3+x%0=%EpkhFpj*uf9=deCw#Q(2k^lZd121^Kj$ObOm&JsTqJ%(3^pj+ zVYDlMy2Mp{=TSW0_(w8uOJvDH@9sYhuzv3!h8VBG=!92o1LGoU&-7xj{(0DPzv6H& zFTNsuQJN6Bfr&V+2ro^Qca9lD<=W=^XMT;8IM8HjV-7UYw(9G;-adO?Y`pRX^+TEi z;~>AqW7TugtjueVaVc%TC)sSg1CG)0Z{$`O{{Ub4oa7+_i+~b%Y&R8k*zVnc=Rd~x z%O;e*0%tNn>?W8`-mks*e}L2>BF_WH;DQ(ygUH%A#cgxd*-M$1DbFmzx_s!F|DnK) ziZ2H(p2Dz&E`w^{vXX9LAZ4W7u)NQa`I#*L!~Vu#nw_|K)PBvx_QAyCTlnW=#}e+- zJLdij1g8D3Hxy$_9w~Ul4HHvIEe%T}wxIq`X_~(%_4RR?)P9uvKj;`)Xz1k8OrK`e zkt?W3y7kWu%l(e7BvAm9qN2Rq2j4N}KOwI3L*F9j`6LIjBAAhYRMP)On4I+A^Krw1 zM$0E9EuGsAj*sV53;wKj^u0=SZnEHlf|WMd|4_LW zGF}}#bY5As?_*L+7&bTt>Ho3QS`5;k_?qGhvi!-PE1TVI)wX*4uP>LCqzxNpiJywz z-zz<{^;$n`i~Ee++acPJKV>%3tA#)zvxjJ z8zOF2*y81DN@UTCky{i1PyWSUfGMZ0zXgQ+M;lWzMWJwi_PN*M@%7{7NvZWfy z7mqYJLld#r77q_!RysrzSb-f zaAzm?b?Xr=!RIwk^M7d0Al1mH_pc47heh`0%^TMB@MIyC`vP%cC4aSKn z7=I3!iynF%A#!o?V;_D@)KsWua!5e6kkrXozGlEM4y@mZ?f%opo)i85{=<_c)-Gh^3u0jNi|=Wa{+)oB zi*X>F>p$FIi$vdJpg-UA-y!~6ef0ldxc(ku1^8bZ>EcD{czTZh^D`P(WK%?dVJY{Z z-AZ)rlt>36KgvZz^$Ljmw8v=u+yB6KFeOC@f=kc5fJzY{ux_nh!?`??=06-#)#OLJ z`xbIHjP9zfX?~C!wRsspNJJ zwuk5Xl)QBAXqoIuY%AOElay08co#(vG1IY#=o$tG6*B;StpTSaY&lmU&eV7 z79j2P)$I;b+PA6%$5aV*a9Z$)k*w$FKB zeoo8lij}7hed_^-ewmU)WO5m`&Z#3?Za&--y?_@F(fQ4-8&DYe)=s2LuJpt9k!s%i zXXgq&_A5;j|M2j22h{%4Sq&+BpPS?Jlk;!7?Y*g-`kVI`Nc7gC-Y6q8;OTZ@JKKwy z3+jD^P^BV|QoCl^Vazxcja z6=M6Li`z>ZV{rtNm}Uq$-aLx|ydsf!+xtp&zaI6D_0jIKNUaI!tu$!2NhCAOU_^F! zB@s)c?F;{FO0$1&Vs37JvfSwZAqx01C;n~Zwcq{Ct^Yhf$aaK_z`1i|$FHMJDwWe% zqDaB%CY>TXh^OT=w(Yca3?iI_doFVMK!U6U_y-h|>1|`0V6SG0yZ@2VcZaRRF|+^G zw=t`W@Z|zg^q)5#NZTOYb^d{3j%)3|z04BcvKnm#D~yaftl$Gk@&fPSd42Yi%23-W zq_|80uqhRpzrjrkDaaNE_dEzVO#8#V_E^YoRRI%G7u<2KC(}EaPay-*Uv#tof$0A> zD4L)trR%PSbiIuv>)Khw7^lw;`bPi|Hezb*XbwZkEYDsXtGq*hWap`s`yqR=C*z0L z_`W*0Czm@s0ZY*%Nak}sq$wCO>ZIzDFjQXMI=ap?mU(S}Plrzac!VR2dR2r>MyrdR z_x}c}sed#E?W8n$oEjS(XIq3_Err>vZDa$_vJvgYN;wVLjq?1+&gA~H_|obY3rDkIei*U2 z>OU_5202)U5pD;@WVS9AQ&BLDsPEobi22j!9Vy6cw=@uo=ng{j=S?H)=`j~$MeFUC z)O|9?>1FCiY4m+}Jl6U<3Wn@OAJlkm8q}NR0v>ze5;ENn@~%#k7Qcak*r z5>uY zcN|HizHD#b&atiI90!4p;r zwXK;pOYFakng4kWTxqtirxo|O^GyKUW;|b5Abeh>kj~w4^}#W!pNz8EIn;lFRsekH zuneAw`;~+N#xQTRtjSWV<~d>_ufTBVo?Jd;%l7?^H!*U{zYW>4`u$5?=<6aZOguyz z+FToBwn}KPAYSDx3-YmL?8B&^HzK0Y?)M8g`g76C`!FHM?YzLr*=Oez>yd=1D|Td9 z>hFGfV~p9bbv$lLpH~Mu`m`=pVsRnkGv$7*z6@^tO|egE7&Vf*mlLqIcX>6de)&oI zZbVTRan(V}eS#@xk!XA81rXRr8;)i* zB(1Ki{9jA?#Ypg*g&YA+QzT3b@01Ls`Wt`x{FH#Tws-FYs2{R?cSNh0pz(Qauar$S z_XV7`4@pk62<%j+-~IV-8+YpY#nj%MNmCBW$$+Hn<3&3&UAHXa;gwXF>R05~5A^R9 zBQ5o=@qB2#ulU(R(W6L?(B{LxsMkGQNY_E`g(6=4$yI~}I*U-b6 zrbuzU|GV%3DnK*VF(^1Gxks~3*DVm(FwBFX{Gu7)t0cXTl^kNl(Ln#0S?5fcqfJjU zm3S2!TlGlTc4Nv!i10-BPL8%98U5bXd;|8cpO_cEmZAPYf3^O=PgJGR8R9Wb?lH-Y zFs5DChkG6dby8H3bk_gzX~_s&I=VxhB8Q{iJw~x*>z#}9LD3}YWvTh{hC$_xS|!bA zzGuej(2#U6In>cteSLF@6nZwYj_M)hLgZu=x8q&WI2DH%=z1XabT1)3lHP>o#d^|0 z2B&50uT?_w%x`#eeQodK=(LonlFzILDYm`|Of{q}R9%~R<9`h%-j05LK9Xir&2u*Q z^IHaIh!m%QnuBRK#cpAm(Qb7*ZRA0C(Rsp9rLc?QN0Dj;p$ z`VJcEmBx|5N?8hR0mo zgMGmcy}4md{!MUr*dCd8Lb;u&(T^LVaE7dHme`p6RaLV0Sjr(3^3aYAcY53w1&$OO z(rD1mdJr?>mmLiKQp!rIuD~v8at;G-h6DjlBKEE8(9#?+Lm%~#6A(&EOy@pOx+)V< zB*fffW~X|Rs7^Q2IXug2LBE4l$022N8p&b?Z&hr28FCX+h`O2pfVsn1`fyM6g5~3A2+fqrMjY zG6N(r?{-Dw!5@7`^)5aK2yWd>m$SVRT?78cPL!u2>AA`&fG_ngn=Z$R6=_^NZN*E$ z@A1EcvJgrP)gef}xc=3yR=u!f`zw+vWXUC!WTN^tA%OM8l$)3M9@O>m^4rrOpSU(j z%wpkF(3K4vtFx?XAaVhW|ApgL4zncMR@ycCo260dKdrKE8u(Ef~?qMyBeyAqdJYBcJ~|$ZB5@;W`MSt1&7@2 z0+>%5XqkSrG7k2rRGoPG`;2uSG0&k+p-8xHj)zh`20p3L-rchi%n5@gUjD84@HltvyhZUdNz|aBbIt;my+Rs+wOv*Ev&h}I`I$RD=97Owef5)fG zdYF2O@>JA0A_ZYN1-ALqKT``o<;-R_O!MBOdbc|5SmHX`{uY6cXWx&o0kR>yw%L%k zWQZx(6DoB7r~4ia^0vD?6Fz;XofO>`-;}Oqo4!W)?bI(8QjTzJb+(EFS0AWHZ5Ni| z@|D)%{dIMKUp$WVU0~O#p-&jlZy0NRM+1Dv7>~qJVhE7;7bVZo%7$`0q3)!^Lw>jC zJMrB+V;=8q&CD2jP(pjKLPi~gr?Ee^7arsas?^xl(OuyRzcWjv;UEYpUI{ou`xmY& z{Vg5p7T~mE@1N_RN8P|HFnQZUmd<0dMijz}*}Q!M_ko{>STE!;WzsvDz#_#v!PRad zK90_mn=b`1ry^g;6OdzarpUNWvtf5gJl3{BeScp=gkh7W@ZWD)20bw(*v@umlMK1c4vJKrV7~<7O2)| zbfFN_!#9#x(fIbh!`DR10(w}K3H;Ld26NN?*t-3=r1o>a;v+Qw9YFQM`wn!m)9>aEq;;7zpK7`-z>iQqoMaZSAwDOO>3uIHf4gq*|-71 z#0AX`vjN|-meO@{)XmQ`gj6^(Uzv#!526?eY=)c3Mgl@mygO7v?r|B1r4eS3xCX4$ zwocDPQ~4^RQj>+W?*Z+cU|q_J5AgD;mZG=ppt&d_`R7s^Ao8oDs*Z(beQaE$jR~&P* z{?ue>GD$&NB`(_YzMgsxiMN+*b(%l&ATW?Hn_SMJ8&8#q>PmW#*yE{xntDBTZr6=q zHG5PN1B#7Lwt?C7WN3du6#+J2=mYv420}8)#PA+j?{c*x(Kk;IY|m8ppD~8kt1>Zu zI$$P}xJt;$6<1jccso;_}->P=V)oTl~7k zs=SP*BnnXG<`&@2KZrGFq95`FJ8i_ilI_!Z_gM zU+;jFW6P9C9AIv<3KT?YmBKnm9D$5AWmhw2FR9`qXC{tkw*_%tnEhWWFj$#ObQkcL zOo`ap}j*PCDv2JE8Y3-lW z9lUT4{K?UW1~(qu9CwW>B=TgD+*yt|<@_zSKCfNV?hbxnuAz5%495{)PPVH@4{v3t zgtR_APo+M8RFRw(u5v~Q*w3xOFRqT2>BWPeTvEz~>C?M<5o4;l)~xb>vRlvc@n&(B zM6Qc$FMD`_)~U~(rp^qPj#x=1UA!zlRB^-_Pxc>sIJOK7C1Ot*v*N~CW>+sg9W`@} zNnC~z7sMm)nsi{%_C4(w4mGJ@GUji@dH7C$>IMbVW!OWcIx}m*ya|l2p9^dYyaCG! zV8h0;so=1e;f=BS*49(Cs&!nXrvf%GmCK8<>KXyv%#E(OTNi6sjH$J05YoIHQtgYW zUkze1^|JP(PfSAiJ|6kA-Z{Ngb1$(wp~S|NiP(vK`0h~=>O0bf3`|l!xY91L@iPD( zDjjcIdyO)BOiV&I@LFDV;RDiR&d@k@tMRShqy+iGT!y6`Tc|Dh6Rv`Sh9kryf~b2# zW$**Gmft@L7Nd!x_g;-JufEly<}FQWn&Bz_BB#!1Iu*j`G!_?8 zZY@~FZGdlNd!lUSC1W-284ubKP&QSuKX-1oGQ*w}Uc%T)Faj}Umsau@X)67?HI_M` z8+~J6=4C>W4RJJK4_)R+Ty);+A^Di*{?;v^`1zCuk0OaU+>YA=f$2*!zWIsR6*kCQ3a|%GTlQku86;N)usV9Q0J^t>n=yxW zP(ppl(V3k7n&Rw;1_lz&{H;x8dU*U~itgb0MY|eXac$;68GfD++W+Cu+y{ zIXj&aU!-<#hcEFR136W*nZRkS4<>N*?TRa7Shms+zcERvbj{qSkk>J@7g}`LrO03f zMtd4@A6v^)uce(+qjhkky9g|l&Ac*EXhLdz^8zrWcY1Cu!cL&6Xk2skJEG4e!}w^- z=(ZZPF3K1WzDNSH@)G|kZn>A3!<*(Q#TzAto&k%-7)prlB(3t-(z{_#zv5)6)v|sz zketY^`BE1$iRh)HT~Ve|yw=UuLcUSaVfn z;4K}Hr?r<2z&}YmqLkdSkBJb3%c?S9(xrZk#CaAHE$X@lPQUkik8Y0G8RY~B6Qq{D zHj3_d9;dYMk`6TwGQa8I7S`LuQKf*TTLAb@~Mj2}HSi}kb#wXId%+CX2x!h@zuh;;n(*=v0 znk0wgetDUkhWa+d0sX6wL%e5Hr-Q2`iu9uy ztQtdp(<1eIJKcKd({K24a)y6Y*vz`pfI|SmGELA=0&ZO#!`F`&XdV*eGZlg6)9UH_ zSgQP%GOE^)G8Z+>-<{d3r@zEM5{K&RI$++)&>#(n@OyeIU<+Co^RTjA;w%LnZDt5c z_k}R=o10+&XQR&QHvq9doXZ+VUgQrVCiL3LpQ|XTzN}Yuc8u>M_NYO$hS_LK{sy%~ zKvvbj_Y&F6p25@VOEyOhd`WSgahc$;-dAaeSFk@aDXO4~Y{7Tg5c zyQ9yUi3fi&nZjWe1OHVYmER{M)rB&9LeU|ooOrW>LqRp+K8Qg@^%+axzrC;A=trnwr-& zyEr6$wG_Wn*QANt*A5du-`1H#>ZRYB6NKPmimid>9{t9+)*Bz37KlO(e{Qo;r!N}d z7>R9+Nuf(2d&teOW^}p0nw6p50x-zu)&^px_6W_gDSVr{qs?I@J-JnZ*J|q!+#ZV( z;4+$rxb^X+rXpr7e)}aVsNG74;CI;0cNxRvsyt4k^6>V#&yE{6s! zAL9J*bsf^WO1ZSNbnI#=Xl7kyK3za?&}xoz9dj#H{H_~~NFOEWJqHcip@H9<@Q(=Z zmz6}C);3Y_cu&5~lQ14*WjS=KO8rd1L?27y0%9+Q!nkmjW2P-oChr-Ewcm68A|K}5 z&azlFN{S3+{Ju^k@eP;z5ff2AIj_^qBR4cd7$Bvg9;VO!mmO+?{oF7*zGy1qbEsam zVB)8>vDVGnbVS=Mj?@JyXO$N*=IvYXUtxzuSS9dbEwL4a+5dvP&sVyrGk@=r${f2o zj*1M9aYx^asX=iuu^rA+#xiBI{MILau8{n^rt0Sua(8$yjQjB{P8@!=;IbU=KWwSE z4{2Qcy9BJ^kz=h+c_wn}*JWg25yrV1k?w9r%iVKTmn7itXKNcY`SH_8AG4})s6>K8 zNi-OUfq+vWbVmDAjPz|#<9QOM@J5irD|TD2U~^m|H_=vq_5LpN`2oCtaf=`H`(0WmzugVz-_?Qoh*pyX%>K!zSIkLKC0G-$r%vBX5vZ+?w=9Ek>P`opyC z+DUnd*7*H;%)2!=6{()l=bwhDh*13s<5SjX=I#uwuk!MJiHW+@^M0gJ6lX6@5hZUSeoolOo{)kt^U&tV3^Rf#1%T}(Dmqk`l*>0++@A4UU|M| zuE|J*-%H7B?sqha4^_Uh7&$IqyLgtqo<7_y7T4yeV$G03Pno!$^g;N;0`KNci~SM# zN|Sv#QE~_#KlfEfzLxHoZliC}wf+b$#hsbpk*3_s*mjTaQFgV>;d=CzvlGAi9{WD4 zFQnBE9cFh#9dRCQJL`51r-)0vn_MulIi<^{NWYQ`7{?nfv!^2aihU_3S@O0t!BCjn z2yILlp>FYs(rSf`j2oQgQEZ;e8;P$-_M0$3sl}bAb^VJ5t7ETA@Y9>HDC&iiEo+s4 zy@3wJr8ku!$Mb0dNGCWDenfm+@jVc~H+63+F{V+%Q@Nka@WU2#eLJPG+B4dT=r>k+ zX0Azi$sfR?Sz%PZ;p4|ci>3RD@4TD(GU62c*Tg$_wM52g8JSVux{C1#Tki#3k#H-i ziY}HwDpE{Cdaa46{4piJFjG)ZLE-c30|C($K&rRWPW35Tb|J#eJ4Vw;p*t zNA>ZDp}thiZtEAR&B>J_I@ZVcoZsf9=Mc+I6^u`f zZeb2phzjhg305_Z?}f;`c5tj1U>cq}Htc?dX^pRk6Fg4>edNC$V0eBGh!u<^MCmKmhhOp- zGW7lOM#Ms*GNwyWLF0RY^P#SnA%>Wzc}7R~wa?NPY^_)3AJqefc9JO`L+@j|McSRT zEIUjE7d28{ywWytK`H@PE1SRUIPVAcX2Ml-NLim!2#;%@3LSD%QaRj;R5-1}r4>K* zNg5TRG=jfg=oZ*1!I?R)lx8DGz4FE%2Ucafu8-FwJZW&R$qO6AZ)U;Vqnc=ucaY=6aR zF{ZovPk<{)Rv#SB@W2LKt1bVrzE)>*rRlD|i*T4<_bbMvGA}K)F6K$}>+gQMPEZ%% zwNbX>7@~~}jPrvn$A7sMis*goNLvLLMbo>9O6Wx9sF1_9iJ>iQ?#aBTUVf&ouBI^+ zeL?#Ln;-l9s2uZ4(>GVy zU)59fX#D-BeMFMmUHi2^8RkO91iMa^5u@Y91E~~dYRjh0bmj@s;xr+1Y7T*M@5Vo* zRq4uIF@#=%wkoe3G$(UejyB0hJ${7?rZtYkwS~XZE4bEfs3b zN3bg^icRTi+uD@AKe6QMrBheOr&;8WbMayPGs3X-Ds5WG`MYLL@9TS^Xl`ktqU}#ig&+a z-<9j56Oqo}z4wIpggLE)n>IVoeWZ&GOsw)I*{RVkipKlZN`{n=`0tUwmu3S(nsFa_ zj`2=bSY7E5Gjeb$=@;u&Z=VYWhe=FMRn*_)NBYh7iLK&7b|1dJT`l>x6U(5e*V_!d z!%a}?;&DE7k!0E1T%*`XAJ@ok^p8JK?dC>bQE-s`2GfKJtKNL9sFwkR)Q>B(x$alr z?h!+*30TfHcTAUaJ_{9{_IW z)gP{JS8WAW`>Z)h3AB(ej6?|Go)9nZKL^ql#d>yqrnWk-704t8jLTcJrb&=i+=B72 zouJ)_BA*H1hB1oy49 zBw~K#z$74-=hhx zW~H$5^YaUIim*+}U@d*)azi1ur=WLxb49c3A|C6tAXki*iZh-R#z3S2unUp_V!A4C zF;B3-O3z4^tU8VfVmhjh7oYgj6y5m9vhs3S` zKIz~(@J^6s-K^G`#KqRh=WqbUs?U@^10b;o*P*Uzc+eGXs=?X=Hh#=Q88^^@PWdYc zCVxoVAb5lsV^Ls~Yh*Fa2S7B;)n263&k2ErC^e;5`WYhWerc}cs@CLg{@h`viD@m$ z5pd^4l_DXawMQjiq=_!0P@Gn(a>xI^DI$}wK&K=0_x2mv80fwZg3TUn>~qqXX%`wq z-Y~X-AI#ADHb2Nhn!+ACB=-ES$CM^6`hxi=@W-CyA$m3{YKg%8bl_jk}TG4=G5} zvxP<`CPv7!=figf3+#+uav;pLwBjy=B(2O#S_Sdne&vUM+Nzw_68uDlQYu)u{l|T; ze#SxW4;APs5uaDmW!Xqu%ug3PC5L_@YtQ(816_2HvWBMu_muFJAMR;7+Foo6JzB33Ve!~5vDEx-E;RNzglRfLT26nbHqAD!RJzj=TAA$v8d6O;z zQAY(_6Aexdq2hUx1kbo0vKzAb@Pr|%%d?pSrF6|^F!wuM%e_!$46j^!6HZ`czOxM2t$lRzIy z&`y2O#=KB#lx_?1z{y4xP=mYTH-u^3L9EeA$!wx!g0@o742y zQ=^dIy9(Z86w7P>Y^r2X zLZ;~=uY-KppB!~oGnz8GT1nE1xT!~1{ zSkb}tr<-Pw{)s68KkL;s5l^c)qvh8;Y}Q}so-#c8b*dnB?*EV)g#M^jOxY;tzr=s$}krdlV8WfOc3iNgrhX5>gw z%hP&o6NMKl_$*%D1seHk+;z~OcimA2)sA~K9)*8@T6Pitr|7T!8b_Ul72ia;OH@1Y zxDEG6*NWmpN4H)x62-#|R^Syqt%&?bf27{Ap+uhTc|O)&o5s({vJM&y(62D!VK`?bK?$s!DOW;D#UGz1K}ywvp7>x(&2?EwE)A z74Av9q}B!ElPViINvO|hhZrS(JG$E)%bvG%X6_}14EFgcH3{C#dEWDZA8mGfr}-zj zm*g*O#EH*{Vf(LTzxK9%lXhMt+>5Wv(>7EeTE{)J1v;699oopENLTbRg%HVOEdwSC z0OUF%^!pO)h3E$5>0LxUL=+0pq!KA8wZkLK5kF>o3@xUvdn|A8y#=NW;Y{_OK&o@L z&EFach)D0s2P17-HRc-lmJKs8q~;qV2IVs6@{47(ewNN?4aVtP%lX{roU5Kf;7Vi! zG4uY}eNzaB^E7KMph}~+K{pv^#}_HZvdpmsp0L_eEcCR35m2wprl&?>jEk~z*9cFg zm0p0|9H@aw3ldUhvRxDA@t%4&L;Qs3gbu>JcUR&){Dz4+@3qk?g|sDpHtgGZ2|-!6sKM!FHDTzixd_heL5pP9V=lO# z_l@s5rwujJ>HL9hOWVK$g@|-T5603%h^KI|w>^CtQ_UoKk0c__vSqw3SH+#E2mQ0WN_Lwpgr8}D>FsT6Wr`}O|+i!OAXBl&wFC~oFHtW zF&v^gp);If^> z4L{e6X{arO6=qgjNWKJfuN8K3G*t~ulBvF|B{AvB^n-Hlzbar3&e>0K5xds$JN)q%;J6+ydBugu3Ql3OX>*eE#T+u?FpIPL8& zdPrwZ>T2>x(xk8gpB~|QQQB@pHGteATR21_g`e93pBCdE_0~kw`&OmSa78Tl8uUTy z$*!wszBR_|7BZ%WX6K{`Exu-t1_fHi&y{JfxBO>UUVP~b8x66O$P{}?=>Aver*A1J zx~Y~QUBA;Wztf(fHL+o9rgtHXR`2ykLcQt@zl(l0sZuQank>3ZK9I<(>9m*aKIdT+ zS4L-rJbv$aW@oDBz0xi#6f>xJPy7GC97Ly>zuqq&RUZZQYWN*$dpor(3W;P-%*O6% zvquM9ah8|zAqsDbpY!9Q_vmJPKJ-WH@;|3&?fku3drxuSHuW_Xx6o{Gfuh`Y&xD*G zKK)$voqjR|6=yxFU|qi_y0cSNxH|E}C~XrI#8nsy@pLg&4qscMWl zkl`FJnQDaq@epk`re$Qr+@jd(avHx`m9Z0f{lWg+8l#Ity^>M}NLLkGU^Z~B>=vr@ z+j@*FgBb&42!~}z1p{?zg$zq13YMxIt^cyDJYi?MHgyi!cDsVDs6H8qr_QzVw~H8t zyPn=DjOrZ!)YBr0$M7{hZ|dPzp>-756Sy*b9f*_uRTKSRzQRj-h|<;6Y(@s?m0#f; z`Vn^r%DgkKLdQW2g`Z@JgP?ek%dynLga#F)vPGCLdXOOSJ5-o$l-Ixy4`pm;C&LcD zcj!c^E}MYvmj=My$M|N-K{w2CLB$yC{$?AHi#qaQ6#w_IXB|5W21>~{;R&TN)VC-y z9d0l%f2jaTvsLCP2l~`+)CNUf-3(&>M$_zn(D5&Hw=mXM6v9KE>fT`fCTH?tumy$_ zv6EAD!WVo6UL=(K0rQxblt0-fj)!T9;$5S4`U^xtTI}D+9xmA;9^_F~S&KffWdt)y zRiu3@Fyx@%Q*9Ok#GZbE;2M769&FFSNnb-Q1HW-{l9EmlHuHg@YFga($0N_!K@dk{ zy!m>(T$>qClt|&Qqk@A!435YL@(<+Li6&-?b}M0*GQ1APq2l=ldO3l8_wnFs#KHIa zz2M6?2bj8(V&TJgyd%4A17pu$u$%De>=xWUO*F5VZGWB+F0Rq+hzHVYi02tHRf7%a zy{dcO!9CJ5xepW+V7!y{n;WJijK6|&u~{u8_R0*6T8I-i__MQRT~ue%HKH2D>s-}cJsfb$R!2(?{eSd$89Aun(PfX z;_r~)XG@I_B`yzH9vP<2-)7j-_G`6cetL)h1#d_1*%p&!42Jev4}lVjt=YE5qwZHj z6v7q=o?&(3+=L}r(vEkls^BKXy8Z+uYA`s$I=IX-rD58|?SEd&} z9qtTZsW#x-%$kx|d(N%(;1X7NoR2a0nwQSQVF zk2C!byUlO?8_-iKpRTcOj6I>;D!YpajDys~1oH<{(~XFBg)iS!G45hv+2pTCcppm=&I z?$eI@DmM^_Y?V!n8wqYFdd#VERGQ{zKz&-Y_R8RYN=^yV{kc7>zO<1{sXPHAvS z@Y35LqrLKQ)AG18n-I1%AAeH`Kk=B8T%VcAODmYz@>j@cMxb^-K6SRbdn&$QZbi;G z_mAhsShqT6XJ?rmyB?eT7Y_Em+vgmLM%;L5hscxlGlHJLRVDEwFo#fNe`SLoY zsKmYqBD->;?x_N$_`^#NQNP`oz#A;Ws3-wkhGLHx-nZr8iz;Ac88{QF2{dxmbx6fI^$)o)H$x9 zM1wrj#sJ>wxgyLJ)_Vpt#*}BsZ?K(&;#2)YE4@^P=t_$)rNDTdYkJ3h zNfV(>dX6IDypb_QpgYw=7;eIipkx_)E9>&?btzFOdgz%bi$6n^z|H-lS3H(;Vj#aC zLVSJ3iiZEkTh*YX=0`%;j0~y@c4r3mW_n<->?~lHINmL7$b4S zL!HAM=+`kO9$RNR8JF-JO8*wpO+s+xqC2?B11{}?fE1#TF zGDy_3W8`yzd*2BU-V)WTLs8>=hp2DT2@pT~#j0=f*|E;MLTV%rCkg|*19gOI?1mF8 zwkut!B)#WRCeBIOewCKKr$1V(wWxDH`CK}{Fsg?KlOS)-D@XrbS;6}lXXlt{9$fGX z1Rxcm7?jZXmdtVbn8~a@n4s5UI`fbG#|H2@+>FK^`!`eX`4}Ho7IcM}%iN550_HyI zj@dV_vi7k)9Z#wkaj$+l{uzg*<+TD6p>jP5@2q!ePkMDf{<)ym{ zR`wse^5S9xZbNYHDig_VgO0u;a)`z=Z@U`#oX4ttyM|~yC4|G&&#z-VOI+7Lzg;5B z0DHxCZ}Pw*-O&U#7xw6H)gHa&so-T?v335RMhoXON%~kmherqU*qHt5|C`8 zpeonULnn($>N_2s6xS!oWssKR>~h)OV3y9&CT0D6?SmFE-%NUjUtT5!kMZ1I&bwEZ zabkpV@#|aoS45)&Dinns1xDAXXga#rhj*L_iq`@Od9;bXv|l;vFLktXYMg}2TNN|j z?TT72?G@fc+YyejP2xbFUkhoRG=Cys)vKLdTVhY+G5UQn+Tp%P$K4Hnvf%-9FL;O6 zZPp_h^VMusj6@&uybJf?>B$<02r)osmZ)n{)sd41zwN@X${`d} zXXSIFZAw!^@8&nVXfaBT=~QPmEd=~da4^l_=p@nyN# z79hXwz=l9uQn4e$TDnS@li9U4^#kt|TQsYiCi2!?voFiO8W{;+fqDcgWmelL=7pBS z3@bwrcugZ7oS8gT1UKuo zWp!J(aY9sZzI#V@%}JHr%;S5)Q^KS7ra^3^lNe*=bD;LJDvo85N!lR={jGz8x}A@E zg=-TIqJ3|;0SO2Br@%|-kn4K{+Og!z6`=ySSw9xoWv6b95WxVaZjel#sfraF>iArp zz2rG$tZhHuiO|AY#9Z;-{o{GF=<{NyyPo7UXW+1fF5rn+b~MC;kP)L>g$`AJ8nxF@ zx;^IXL-3EqBF@a4LL%bn17zWa7HxcVnYMVzdbDQ?*XJ5dfctvKhj{gN-R0%`-3-UW zNkOqe&!v3VP>M}tUjchG1|hZrlR!XSHMB@#a^-4{VTP61LD;ekuiUWd{jRi(C{V1z7X7zkg&XkR9u{fO z<2)H7TRFz%u^8e}DEST_w8A0tPb)eRXj$xY1Y&sxOXu^b*tIS#IGJpOP)EsmXEbG*Z0;KtWT~~{ zcGfm`Cyc^)`r4sD$-+b>jXPzf$suK_?DAU&%y50JMO(-o`nq}D-={%ROhMe(OJTP( zj_Ppz8(HQNs({jsYtDTp+%jK+ZBBYv{ItDXEQhw0G|v6m*kjX)$8b17bBVoQ?ZWHi zgAgLX^nJIXR7aH(dRKODGtOkQeTzp-uky-b6%~sKKu6}M4G`Vws+Esnp;vg_Z zL2EZhx-2=0>iy9`4#q4Fq}`FI?qB58;ZB}73EedAm-Nt^*NrE!D=CgW{FxUEHZ_7W z{}7I@gZ%-+7m>u`DhuB3RP;g^ad$_=9%_F~womEuwF^riW3Y3j)WezF$(yBhNOh>b zavC1w`qO+kGW%xYl#2-p_%JURtm(H&niGjB((MnT$;1;|i3i;1xtZj0YtrGjkH4k$ z9mg4zUi??~SL$4~q*nYb7{hp;<*%;k0)d9nNEun_^ z$9u#PP~?yff&+0w8oLj@S{C--n9$R^Zq1N0epfGbFYX|YsTlxusWg&)b*#pNYpoFS z+T`Z2ls%fk$fsfq9Q1Q8zxLImq2iy%P4|q=c{J`ydQ^LR2+j#_zo0iKw(5P10+Pk8 zE-wCB@fcJb;_ka~T;~H1*{=+BI>kDvdphb(7x6+KQVkqtrj+rkid3nk?^i0a5zT{~ z=dbCP@fMVPdQg(NfMO}!E4eWq;>)6=xkuVuDrK$xFg8QjUOBKzJZprb7Mk_N4yV^;4W}4)Ls~Nxqut-$g9U(zH^b)Y`pBmjkGJ?A{?|sG&Zn2PRc0O+f z9Mg7i8jnep$;Y9OLwAv%oAYXA+D7v%KHBtC(K4dn$_%;Zy<9aZ7IXK!Wh}uBKFJk` znB?OO`Gk_M9XDAIC^}5uu<|+IWLawVh`)n#&8K_nb(roABUSq4N-K<((@|{(Q6|Y4 zsiCsci+KVOiNMTevYtrH7BVe_J4=M! z2+w=YPuorbe(J=){j*%oDs07K>{Ji8is|=_Oqf|;=f?2oN6IQoV=+0_bh5-M{_@R} zn}+S%oF+VC-5jZZ(b4(P%$_V(xS-@j-=Ly5t?5xe*U4RC7w=Ao7oRvgeg!@9%6P>QWkW^lOZnsL3~n#1K;2k6btNkI;&$6Xtk{{39yb&BikS zg$g4MoieUD5poTKu9}$5JD8!9dWjsg58O=gGlEas! z#2>Xp#PVDGt;y_BqnSbzuL(K|+JYHgLFbmmzSucx5BX_e+4sU0>m;g1QS5OyU*3no zLcl}y&+@S$FUh6QTjk!aTD#`Js6v4#VuxThryL7Ey@@SXy>r9_ek#mAK!t#~sf2&i zD;$X5Pf$e*_k#M|_zw*=IZL#`wL)Zx%WG&hy|*tcP2W7l$a{3Ca1- zPVs=^yw_^I33HG2=<~nihy_5?%IEx|_ITkQ71293K9mgIu7{Z~UrSbGgT&{>(ICsX zs*xP{-+FsvZqQHZ3_04jrWeZHKa~spG5x3k?)2m_*0NtF?96@a!}Z^v?iv1n?7c-; zoKNsBin|k>!QCYU7~BaVxa(kn;O-Dy0>K01hf8nOO>l{^KtAH-;rbuC%;bWEVmK^9rID!2_Mq0=qf!q2|VuYBmJrgz)tZaO z^9`ohLAp{9sS6(Ipx}qOyaai+o5a1s_uGD9y<8@oRdEIl+p1_u;n4VdECGzDxPIi=?Gl6IZWpHONQ7aembG&p{BWro`p)=E#QIOf;@)2Gp4{H2;3 zCGvvrT5+VjDK3F~R7M8l`p`%#ukvd?->{$}h5Ku3m|fBBQBzk;-JciAK+ z&%iB*MZ#5>>$jp)DjbyF%ZB+hFF41sk1K{Y^{|BSJgWR1oLIkT5G&Y)Uz^H*A`JR?~HUwq7DOc?JF;}Dwgf{ZUtu#xIoJ^Z5i#=zF? zq<#<&dfUI6=&pG0w*W68d(&#`8NdqoJr-3v&5M~h7FULSATIO_eA717Pi2f4+X}}f zHT~R1P28Me^q<|#%7i(?_8z%zZe6F@7w2zk`PSfP-^BV3IP^^PR;+5T?F;A3?;otW z(^IJJk`cdJy05vJ9d8^s13Z#LrL)D*S~W5Vq|!w{v>~1b7(_Gr{1lkPzh%k2A~y+T zeIF+(IT{5V2wC6hkDk9gnEuc4CiWApHq5v;yg7#RPkq}Ty5Dq{8hA0~o%VlxC8b{O z_L3DSp&?G*WwwmDLIm-?i68`g%Uih4@&bdZdXa_`meV<|&|&STKavs#pf%!6Q3KjJ zl2d);XzxF+pN8FhP6L(j3rUdoqp&3=Pu_T$;1F2xVeXO-_ckd4b~>ss7M-LQgiaU4 z<~tWarzC-i4k5N8C~RCu7w)Tw@}_FK=mg2xSbQ?Y(oFdJlKA+lx@FGkiX<$yJoMkT~jqn~b^p>CQef zC+S!C09WA+zjmpRL|65-YfO5^=$Qh{AIN5^huekPZhD3!j-%+F^&pC&ci0BWq385% zML#>%w-wQDSq(7wKIdR!DTNGR(-uv_79M6yq_y!p`KD(gzT*HwWl~Qu3SaecJJa`% zQ5FrYQi{mJup_vE7cxnjIWUK0vBDhVwh!0;xq|cGMk3vU3{-31%c+94wA(lUf~Juez;C_IhnZx(aMruwx54lb2=wF`&)Y$!0O@d)h(j!aInuA)_ds5BZ^8#c ztr#2dz_Ud;+#%rEK0#HClQZ?!;-Y4L7%9QQQ;JdYi^|fbwGDl?2ciP$w^)F^R z@0t$E}q(bb^$!T-?{+E7!YAE#ACHid44WugRm zC}0#~vP%K74DbVkni(s^f-o`*fyo%lXl{`=Vf97C70}6Y7!o!b@bFN>YRitbtl{OQ z%hjxsV~bPF+m6iNm`p@2D;(bD5>aWt1tHT&U^`b$!mSENL^OE4$F3T7_-fPvn~iu9 zd6zKfj)(YZIGqam8O{p^N_cf^B)eTO=@uWjA3-N0<}S80cvA@*7|&?FZQZEJfeXu@ z#^Kg_o0>klPTq(Lj#$7*7%;*)?9?w(gj$#SLc9}YV*8a6j-LxUd%jcTG81$YeEL4S zvz#yQIu(_#N{JW)bP)>e9?vWNjK&_)} zazBRR+n`9x0LJA5ka%J_$r5d+y&Mma}|RB{dc zOK2%PONGfczPW+gxwE2S)oVaB)N}DjkpV&Mvo}=PoJ5}v#9h1m zRgx1xdDRXE$(p1~V%RoA9@t28x$T4L+Uu}9R{Mrr7srU#@U{9P{q#kT(TIOUYY*r3 zAtIx0RORt9FK1kPC-_Dpyj%q#JUxz@ehZZA#sj%MpFtKo00`l^fib^%!^<>U2nNsR zNCVPCEn-BB`pCRlZc;`cXDb1-}dSlWmGT4Z_3GE|2(=YoQt_n{V3=% zbF$v*g=P`Dt=(2su{h{W@KQT~K2-hzVu=9oHks;}Nz|p7z6jT7lBIc!t`tDQMiQ*n z)&q>M;4q?1X#pN3gPv!~2dKBNh7qH&B>w4DSz9$@NfT#u)6or|Y@QyG7bz&zP8 zP$@rPMK#~gv^(c)EVkpb1By%}9)ALyh(&!rFi`*mz=+Nw={@ZXd6un;vb40D26{p? z^;g?yiIdC_Tl~1Lu~9Ksi6j!BZvYO~JiR5@Y={YGVZ3&S %^*_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/421] [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/421] 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/421] [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/421] [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/421] 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/421] [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/421] [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/421] [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/421] [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/421] 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/421] 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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] 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/421] [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/421] [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/421] [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/421] 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/421] 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/421] [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/421] [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/421] [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/421] [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/421] [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/421] [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/421] 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/421] [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/421] [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/421] [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/421] [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/421] 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/421] [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/421] [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/421] 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/421] [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/421] 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/421] [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/421] [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/421] 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/421] [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/421] [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/421] 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/421] 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/421] 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/421] 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/421] 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/421] [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/421] [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/421] 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/421] [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/421] [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/421] 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/421] 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/421] [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/421] 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/421] 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/421] 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/421] [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/421] 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/421] 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/421] 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/421] [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/421] 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/421] 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/421] [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/421] 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/421] [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/421] 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/421] 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/421] [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/421] 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/421] 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/421] 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/421] 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/421] 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/421] 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/421] 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/421] 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/421] 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/421] [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/421] 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/421] [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/421] [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/421] [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/421] [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/421] 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/421] 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/421] [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/421] 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/421] 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/421] [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/421] 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/421] 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/421] [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/421] 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 88054524b0b2682ede8e7d6d883b8a7cab49b84e Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 29 Oct 2024 12:46:29 -0300 Subject: [PATCH 181/421] [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 182/421] [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 7119c0b2a49d00fdfa92764aae4b6f78e365b4ec Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 31 Oct 2024 13:38:28 -0300 Subject: [PATCH 183/421] [COASTAL-1389] update progress if cancelling bolus fails (#723) --- Loop/View Controllers/StatusTableViewController.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index c77c8ae3bf..b5902fe6b0 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -812,6 +812,9 @@ final class StatusTableViewController: LoopChartsTableViewController { if oldDose.syncIdentifier != newDose.syncIdentifier { tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } + case (.cancelingBolus, .bolusing(let oldDose)): + // this occurs when a cancel command fails + tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) case (.canceledBolus(let oldDose), .canceledBolus(let newDose)): if oldDose != newDose { tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) @@ -819,8 +822,6 @@ final class StatusTableViewController: LoopChartsTableViewController { // these updates cause flickering and/or confusion. case (.cancelingBolus, .cancelingBolus): break - case (.cancelingBolus, .bolusing(_)): - break case (.canceledBolus(_), .cancelingBolus): break case (.canceledBolus(_), .bolusing(_)): From 904b8f98ebf67b42c9e81dbf1bac97fdaf0f44f0 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 6 Nov 2024 14:51:14 -0400 Subject: [PATCH 184/421] [COASTAL-1395] UI flicker (#724) * only update timezone as needed * redundant request to reloadData * redundant tableview updates * use updateBolusProgress instead of reloadData --- Loop/Managers/SettingsManager.swift | 6 ++++++ .../StatusTableViewController.swift | 19 +++---------------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index 4da04fc75c..ff80a6170b 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -177,6 +177,12 @@ class SettingsManager { /// /// - Parameter timeZone: The time zone func setScheduleTimeZone(_ timeZone: TimeZone) { + let shouldUpdate = settings.basalRateSchedule?.timeZone != timeZone || + settings.carbRatioSchedule?.timeZone != timeZone || + settings.insulinSensitivitySchedule?.timeZone != timeZone || + settings.glucoseTargetRangeSchedule?.timeZone != timeZone + guard shouldUpdate else { return } + self.mutateLoopSettings { settings in settings.basalRateSchedule?.timeZone = timeZone settings.carbRatioSchedule?.timeZone = timeZone diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index b5902fe6b0..eeb1ad1437 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -293,7 +293,7 @@ final class StatusTableViewController: LoopChartsTableViewController { loopManager.startGlucoseValueStalenessTimerIfNeeded() } } - + private var bolusState: PumpManagerStatus.BolusState = .noBolus { didSet { if oldValue != bolusState { @@ -307,10 +307,6 @@ final class StatusTableViewController: LoopChartsTableViewController { default: break } - Task { @MainActor in - refreshContext.update(with: .status) - await reloadData(animated: true) - } } } } @@ -611,7 +607,6 @@ final class StatusTableViewController: LoopChartsTableViewController { self.currentCOBDescription = nil } - self.tableView.beginUpdates() if let hudView = self.hudView { // CGM Status if let glucose = self.loopManager.latestGlucose { @@ -641,8 +636,6 @@ final class StatusTableViewController: LoopChartsTableViewController { redrawCharts() - tableView.endUpdates() - reloading = false let reloadNow = !self.refreshContext.isEmpty @@ -2120,14 +2113,8 @@ extension StatusTableViewController: CompletionDelegate { extension StatusTableViewController: PumpManagerStatusObserver { func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { log.default("PumpManager:%{public}@ did update status", String(describing: type(of: pumpManager))) - Task { @MainActor in - - basalDeliveryState = status.basalDeliveryState - bolusState = status.bolusState - - refreshContext.update(with: .status) - await self.reloadData(animated: true) - } + basalDeliveryState = status.basalDeliveryState + bolusState = status.bolusState } } From 7c90bddb67b9ecbe4954f425b856e6e436842d6b Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 18 Nov 2024 05:48:15 -0400 Subject: [PATCH 185/421] [COASTAL-1433] only start bolus when previous state was noBolus or initiating (#725) --- Loop/View Controllers/StatusTableViewController.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index eeb1ad1437..1b075628d2 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -300,8 +300,11 @@ final class StatusTableViewController: LoopChartsTableViewController { switch bolusState { case .inProgress(_): guard case .inProgress = oldValue else { - // Bolus starting - bolusProgressReporter = deviceManager.pumpManager?.createBolusProgressReporter(reportingOn: DispatchQueue.main) + guard case .canceling = oldValue else { + // Bolus starting + bolusProgressReporter = deviceManager.pumpManager?.createBolusProgressReporter(reportingOn: DispatchQueue.main) + break + } break } default: From b723d23229c1cf1920f54908bafffe877bf8b113 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 4 Dec 2024 12:43:12 -0800 Subject: [PATCH 186/421] [LOOP-5153] Remove HealthKit dependency from LoopAlgorithm (#726) --- Common/Extensions/GlucoseRangeSchedule.swift | 10 +-- Common/Extensions/HKUnit.swift | 18 +---- Common/Extensions/NumberFormatter.swift | 8 +-- Common/Extensions/SampleValue.swift | 7 +- Common/Models/StatusExtensionContext.swift | 18 ++--- Common/Models/WatchContext.swift | 17 +++-- Common/Models/WatchHistoricalGlucose.swift | 4 +- Common/Models/WatchPredictedGlucose.swift | 3 +- Learn/Configuration/QuantityRangeEntry.swift | 1 - Learn/Lessons/ModalDayLesson.swift | 1 - Learn/Lessons/TimeInRangeLesson.swift | 1 - Learn/Managers/DataManager.swift | 1 - .../Components/GlucoseView.swift | 1 - .../Timeline/StatusWidgetTimelimeEntry.swift | 5 +- .../StatusWidgetTimelineProvider.swift | 11 +-- .../CarbStore+SimulatedCoreData.swift | 4 +- .../DoseStore+SimulatedCoreData.swift | 4 +- ...osingDecisionStore+SimulatedCoreData.swift | 14 ++-- .../GlucoseStore+SimulatedCoreData.swift | 8 +-- .../SettingsStore+SimulatedCoreData.swift | 2 +- Loop/Managers/AnalyticsServicesManager.swift | 8 +-- Loop/Managers/DeviceDataManager.swift | 6 +- Loop/Managers/ExtensionDataManager.swift | 6 +- Loop/Managers/LoopAppManager.swift | 9 +-- .../LoopDataManager+CarbAbsorption.swift | 1 - Loop/Managers/LoopDataManager.swift | 13 ++-- .../MealDetectionManager.swift | 7 +- Loop/Managers/NotificationManager.swift | 4 +- Loop/Managers/SettingsManager.swift | 9 ++- .../Store Protocols/CarbStoreProtocol.swift | 1 - .../GlucoseStoreProtocol.swift | 1 - Loop/Managers/TemporaryPresetsManager.swift | 1 - Loop/Managers/WatchDataManager.swift | 7 +- Loop/Models/ApplicationFactorStrategy.swift | 6 +- .../ConstantApplicationFactorStrategy.swift | 5 +- ...lucoseBasedApplicationFactorStrategy.swift | 6 +- Loop/Models/GlucoseDisplay.swift | 8 +-- Loop/Models/GlucoseEffectVelocity.swift | 5 +- Loop/Models/LoopConstants.swift | 20 +++--- Loop/Models/ManualBolusRecommendation.swift | 3 +- Loop/Models/PredictionInputEffect.swift | 3 +- Loop/Models/SimpleBolusCalculator.swift | 10 +-- Loop/Models/StoredDataAlgorithmInput.swift | 5 +- Loop/Models/WatchContext+LoopKit.swift | 3 +- .../CarbAbsorptionViewController.swift | 15 ++-- .../GlucoseThresholdTableViewController.swift | 6 +- .../LoopChartsTableViewController.swift | 1 - .../PredictionTableViewController.swift | 9 ++- .../StatusTableViewController.swift | 7 +- .../TextFieldTableViewController.swift | 1 - Loop/View Models/BolusEntryViewModel.swift | 49 +++++++------ Loop/View Models/CarbEntryViewModel.swift | 7 +- .../FavoriteFoodAddEditViewModel.swift | 8 +-- .../FavoriteFoodInsightsViewModel.swift | 3 +- Loop/View Models/FavoriteFoodsViewModel.swift | 4 +- .../ManualEntryDoseViewModel.swift | 19 +++-- Loop/View Models/SettingsViewModel.swift | 1 - Loop/View Models/SimpleBolusViewModel.swift | 39 +++++----- Loop/Views/BolusEntryView.swift | 14 ++-- Loop/Views/BolusProgressTableViewCell.swift | 12 ++-- Loop/Views/CarbEntryView.swift | 1 - Loop/Views/Charts/CarbEffectChartView.swift | 3 +- Loop/Views/Charts/GlucoseCarbChartView.swift | 3 +- .../Charts/PredictedGlucoseChartView.swift | 3 +- .../FavoriteFoodDetailView.swift | 1 - .../FavoriteFoodInsightsChartsView.swift | 1 - Loop/Views/ManualEntryDoseView.swift | 12 ++-- Loop/Views/ManualGlucoseEntryRow.swift | 6 +- Loop/Views/SettingsView.swift | 1 - Loop/Views/SimpleBolusView.swift | 15 ++-- LoopCore/HKUnit.swift | 12 ---- LoopCore/LoopSettings.swift | 13 ++-- LoopCore/NSUserDefaults.swift | 1 - .../Managers/CGMStalenessMonitorTests.swift | 6 +- .../Managers/DeviceDataManagerTests.swift | 7 +- LoopTests/Managers/DoseEnactorTests.swift | 1 - LoopTests/Managers/LoopDataManagerTests.swift | 20 +++--- .../Managers/MealDetectionManagerTests.swift | 29 ++++---- LoopTests/Mock Stores/MockCarbStore.swift | 1 - LoopTests/Mock Stores/MockGlucoseStore.swift | 1 - LoopTests/Mocks/MockSettingsProvider.swift | 9 ++- LoopTests/Models/SetBolusUserInfoTests.swift | 6 +- .../Models/SimpleBolusCalculatorTests.swift | 71 +++++++++---------- .../Models/WatchHistoricalGlucoseTest.swift | 11 +-- .../ViewModels/BolusEntryViewModelTests.swift | 52 +++++++------- .../CGMStatusHUDViewModelTests.swift | 24 +++---- .../ManualEntryDoseViewModelTests.swift | 7 +- .../SimpleBolusViewModelTests.swift | 10 +-- LoopUI/ViewModel/CGMStatusHUDViewModel.swift | 4 +- LoopUI/Views/CGMStatusHUDView.swift | 4 +- LoopUI/Views/DeviceStatusHUDView.swift | 1 - LoopUI/Views/GlucoseHUDView.swift | 4 +- LoopUI/Views/GlucoseTrendHUDView.swift | 1 - LoopUI/Views/GlucoseValueHUDView.swift | 1 - LoopUI/Views/PumpStatusHUDView.swift | 1 - .../Controllers/ActionHUDController.swift | 4 +- .../CarbAndBolusFlowController.swift | 1 - .../Controllers/CarbEntryListController.swift | 7 +- .../Controllers/ChartHUDController.swift | 1 - .../Controllers/HUDRowController.swift | 14 ++-- WatchApp Extension/ExtensionDelegate.swift | 18 +++-- .../Extensions/CLKComplicationTemplate.swift | 7 +- .../Extensions/WatchContext+WatchApp.swift | 14 ++-- .../Managers/ComplicationChartManager.swift | 3 +- .../Managers/LoopDataManager.swift | 2 +- .../Models/GlucoseChartData.swift | 17 +++-- .../Models/GlucoseChartScaler.swift | 11 ++- .../Scenes/GlucoseChartScene.swift | 1 - .../Scenes/GlucoseChartValueHashable.swift | 15 ++-- .../CarbAndBolusFlowViewModel.swift | 6 +- .../Views/Carb Entry & Bolus/BolusInput.swift | 4 +- .../Carb Entry & Bolus/CarbAndBolusFlow.swift | 3 +- 112 files changed, 436 insertions(+), 515 deletions(-) diff --git a/Common/Extensions/GlucoseRangeSchedule.swift b/Common/Extensions/GlucoseRangeSchedule.swift index 9b092d6ad3..a7bfef414f 100644 --- a/Common/Extensions/GlucoseRangeSchedule.swift +++ b/Common/Extensions/GlucoseRangeSchedule.swift @@ -6,14 +6,14 @@ // import LoopKit -import HealthKit +import LoopAlgorithm extension GlucoseRangeSchedule { - func minQuantity(at date: Date) -> HKQuantity { - return HKQuantity(unit: unit, doubleValue: value(at: date).minValue) + func minQuantity(at date: Date) -> LoopQuantity { + return LoopQuantity(unit: unit, doubleValue: value(at: date).minValue) } - func maxQuantity(at date: Date) -> HKQuantity { - return HKQuantity(unit: unit, doubleValue: value(at: date).maxValue) + func maxQuantity(at date: Date) -> LoopQuantity { + return LoopQuantity(unit: unit, doubleValue: value(at: date).maxValue) } } diff --git a/Common/Extensions/HKUnit.swift b/Common/Extensions/HKUnit.swift index 36f7576d80..69cf8fb40c 100644 --- a/Common/Extensions/HKUnit.swift +++ b/Common/Extensions/HKUnit.swift @@ -6,13 +6,13 @@ // Copyright © 2016 LoopKit Authors. All rights reserved. // -import HealthKit +import LoopAlgorithm import LoopCore // Code in this extension is duplicated from: // https://github.com/LoopKit/LoopKit/blob/master/LoopKit/HKUnit.swift // to avoid pulling in the LoopKit extension since it's not extension-API safe. -extension HKUnit { +extension LoopUnit { // A formatting helper for determining the preferred decimal style for a given unit var preferredFractionDigits: Int { if self == .milligramsPerDeciliter { @@ -22,20 +22,6 @@ extension HKUnit { } } - var localizedShortUnitString: String { - if self == HKUnit.millimolesPerLiter { - return NSLocalizedString("mmol/L", comment: "The short unit display string for millimoles of glucose per liter") - } else if self == .milligramsPerDeciliter { - return NSLocalizedString("mg/dL", comment: "The short unit display string for milligrams of glucose per decilter") - } else if self == .internationalUnit() { - return NSLocalizedString("U", comment: "The short unit display string for international units of insulin") - } else if self == .gram() { - return NSLocalizedString("g", comment: "The short unit display string for grams") - } else { - return String(describing: self) - } - } - /// The smallest value expected to be visible on a chart var chartableIncrement: Double { if self == .milligramsPerDeciliter { diff --git a/Common/Extensions/NumberFormatter.swift b/Common/Extensions/NumberFormatter.swift index 51f411ae7d..c0f8c3addb 100644 --- a/Common/Extensions/NumberFormatter.swift +++ b/Common/Extensions/NumberFormatter.swift @@ -7,11 +7,11 @@ // import Foundation -import HealthKit +import LoopAlgorithm extension NumberFormatter { - static func glucoseFormatter(for unit: HKUnit) -> NumberFormatter { + static func glucoseFormatter(for unit: LoopUnit) -> NumberFormatter { let numberFormatter = NumberFormatter() numberFormatter.numberStyle = .decimal @@ -24,11 +24,11 @@ extension NumberFormatter { return string(from: NSNumber(value: number)) } - func string(from quantity: HKQuantity, unit: HKUnit) -> String? { + func string(from quantity: LoopQuantity, unit: LoopUnit) -> String? { return string(from: quantity.doubleValue(for: unit), unit: unit) } - func string(from number: Double, unit: HKUnit) -> String? { + func string(from number: Double, unit: LoopUnit) -> String? { return string(from: number, unit: unit.localizedShortUnitString) } diff --git a/Common/Extensions/SampleValue.swift b/Common/Extensions/SampleValue.swift index dd9c901ecf..b85fec0145 100644 --- a/Common/Extensions/SampleValue.swift +++ b/Common/Extensions/SampleValue.swift @@ -5,15 +5,14 @@ // Copyright © 2018 LoopKit Authors. All rights reserved. // -import HealthKit import LoopKit import LoopAlgorithm extension Collection where Element == SampleValue { /// O(n) - var quantityRange: ClosedRange? { - var lowest: HKQuantity? - var highest: HKQuantity? + var quantityRange: ClosedRange? { + var lowest: LoopQuantity? + var highest: LoopQuantity? for sample in self { if let l = lowest { diff --git a/Common/Models/StatusExtensionContext.swift b/Common/Models/StatusExtensionContext.swift index cf486fd1a8..114f2a416f 100644 --- a/Common/Models/StatusExtensionContext.swift +++ b/Common/Models/StatusExtensionContext.swift @@ -8,7 +8,7 @@ // This class allows Loop to pass context data to the Loop Status Extension. import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI import LoopAlgorithm @@ -25,24 +25,24 @@ struct GlucoseDisplayableContext: GlucoseDisplayable { let isStateValid: Bool let stateDescription: String let trendType: GlucoseTrend? - let trendRate: HKQuantity? + let trendRate: LoopQuantity? let isLocal: Bool let glucoseRangeCategory: GlucoseRangeCategory? } struct GlucoseContext: GlucoseValue { let value: Double - let unit: HKUnit + let unit: LoopUnit let startDate: Date - var quantity: HKQuantity { - return HKQuantity(unit: unit, doubleValue: value) + var quantity: LoopQuantity { + return LoopQuantity(unit: unit, doubleValue: value) } } struct PredictedGlucoseContext { let values: [Double] - let unit: HKUnit + let unit: LoopUnit let startDate: Date let interval: TimeInterval @@ -162,7 +162,7 @@ extension GlucoseDisplayableContext: RawRepresentable { } if let trendRateValue = rawValue["trendRateValue"] as? Double { - trendRate = HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: trendRateValue) + trendRate = LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: trendRateValue) } else { trendRate = nil } @@ -182,7 +182,7 @@ extension GlucoseDisplayableContext: RawRepresentable { ] raw["trendType"] = trendType?.rawValue if let trendRate = trendRate { - raw["trendRateValue"] = trendRate.doubleValue(for: HKUnit.milligramsPerDeciliterPerMinute) + raw["trendRateValue"] = trendRate.doubleValue(for: LoopUnit.milligramsPerDeciliterPerMinute) } raw["glucoseRangeCategory"] = glucoseRangeCategory?.rawValue @@ -204,7 +204,7 @@ extension PredictedGlucoseContext: RawRepresentable { } self.values = values - self.unit = HKUnit(from: unitString) + self.unit = LoopUnit(from: unitString) self.startDate = startDate self.interval = interval } diff --git a/Common/Models/WatchContext.swift b/Common/Models/WatchContext.swift index 6d4e7a23a0..c35694390e 100644 --- a/Common/Models/WatchContext.swift +++ b/Common/Models/WatchContext.swift @@ -7,7 +7,6 @@ // import Foundation -import HealthKit import LoopKit import LoopAlgorithm @@ -19,19 +18,19 @@ final class WatchContext: RawRepresentable { var creationDate = Date() - var displayGlucoseUnit: HKUnit? + var displayGlucoseUnit: LoopUnit? - var glucose: HKQuantity? + var glucose: LoopQuantity? var glucoseCondition: GlucoseCondition? var glucoseTrend: GlucoseTrend? - var glucoseTrendRate: HKQuantity? + var glucoseTrendRate: LoopQuantity? var glucoseDate: Date? var glucoseIsDisplayOnly: Bool? var glucoseWasUserEntered: Bool? var glucoseSyncIdentifier: String? var predictedGlucose: WatchPredictedGlucose? - var eventualGlucose: HKQuantity? { + var eventualGlucose: LoopQuantity? { return predictedGlucose?.values.last?.quantity } @@ -63,11 +62,11 @@ final class WatchContext: RawRepresentable { isClosedLoop = rawValue["cl"] as? Bool if let unitString = rawValue["gu"] as? String { - displayGlucoseUnit = HKUnit(from: unitString) + displayGlucoseUnit = LoopUnit(from: unitString) } let unit = displayGlucoseUnit ?? .milligramsPerDeciliter if let glucoseValue = rawValue["gv"] as? Double { - glucose = HKQuantity(unit: unit, doubleValue: glucoseValue) + glucose = LoopQuantity(unit: unit, doubleValue: glucoseValue) } if let rawGlucoseCondition = rawValue["gc"] as? GlucoseCondition.RawValue { @@ -77,7 +76,7 @@ final class WatchContext: RawRepresentable { glucoseTrend = GlucoseTrend(rawValue: rawGlucoseTrend) } if let glucoseTrendRateValue = rawValue["gtrv"] as? Double { - glucoseTrendRate = HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: glucoseTrendRateValue) + glucoseTrendRate = LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: glucoseTrendRateValue) } glucoseDate = rawValue["gd"] as? Date glucoseIsDisplayOnly = rawValue["gdo"] as? Bool @@ -126,7 +125,7 @@ final class WatchContext: RawRepresentable { raw["gc"] = glucoseCondition?.rawValue raw["gt"] = glucoseTrend?.rawValue if let glucoseTrendRate = glucoseTrendRate { - let unitPerMinute = unit.unitDivided(by: .minute()) + let unitPerMinute = unit.glucose(per: .minutes) raw["gtru"] = unitPerMinute.unitString raw["gtrv"] = glucoseTrendRate.doubleValue(for: unitPerMinute) } diff --git a/Common/Models/WatchHistoricalGlucose.swift b/Common/Models/WatchHistoricalGlucose.swift index 3b166170a9..71209d428d 100644 --- a/Common/Models/WatchHistoricalGlucose.swift +++ b/Common/Models/WatchHistoricalGlucose.swift @@ -73,10 +73,10 @@ extension WatchHistoricalGlucose: RawRepresentable { syncIdentifier: syncIdentifiers[$0], syncVersion: syncVersions[$0], startDate: startDates[$0], - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: quantities[$0]), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: quantities[$0]), condition: conditions[$0], trend: trends[$0], - trendRate: trendRates[$0].flatMap { HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: $0) }, + trendRate: trendRates[$0].flatMap { LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: $0) }, isDisplayOnly: isDisplayOnlys[$0], wasUserEntered: wasUserEntereds[$0], device: devices[$0].flatMap { try? HKDevice(from: $0) }, diff --git a/Common/Models/WatchPredictedGlucose.swift b/Common/Models/WatchPredictedGlucose.swift index d5978eb6ed..d697418e43 100644 --- a/Common/Models/WatchPredictedGlucose.swift +++ b/Common/Models/WatchPredictedGlucose.swift @@ -8,7 +8,6 @@ import Foundation import LoopKit -import HealthKit import LoopAlgorithm @@ -47,7 +46,7 @@ extension WatchPredictedGlucose: RawRepresentable { self.values = values.enumerated().map { tuple in PredictedGlucoseValue(startDate: firstDate + Double(tuple.0) * interval, - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: Double(tuple.1))) + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: Double(tuple.1))) } } } diff --git a/Learn/Configuration/QuantityRangeEntry.swift b/Learn/Configuration/QuantityRangeEntry.swift index abb7ac344e..a549730af9 100644 --- a/Learn/Configuration/QuantityRangeEntry.swift +++ b/Learn/Configuration/QuantityRangeEntry.swift @@ -5,7 +5,6 @@ // Copyright © 2019 LoopKit Authors. All rights reserved. // -import HealthKit import LoopKit import UIKit diff --git a/Learn/Lessons/ModalDayLesson.swift b/Learn/Lessons/ModalDayLesson.swift index 612e55e41a..0b40c283d0 100644 --- a/Learn/Lessons/ModalDayLesson.swift +++ b/Learn/Lessons/ModalDayLesson.swift @@ -6,7 +6,6 @@ // import Foundation -import HealthKit import LoopCore import LoopKit import os.log diff --git a/Learn/Lessons/TimeInRangeLesson.swift b/Learn/Lessons/TimeInRangeLesson.swift index f01b3967cb..6f02fb8720 100644 --- a/Learn/Lessons/TimeInRangeLesson.swift +++ b/Learn/Lessons/TimeInRangeLesson.swift @@ -10,7 +10,6 @@ import LoopCore import LoopKit import LoopKitUI import LoopUI -import HealthKit import os.log diff --git a/Learn/Managers/DataManager.swift b/Learn/Managers/DataManager.swift index 3929c42bac..15e3220f1b 100644 --- a/Learn/Managers/DataManager.swift +++ b/Learn/Managers/DataManager.swift @@ -6,7 +6,6 @@ // import Foundation -import HealthKit import LoopKit import LoopCore diff --git a/Loop Widget Extension/Components/GlucoseView.swift b/Loop Widget Extension/Components/GlucoseView.swift index 332d4afee4..b865ce0e02 100644 --- a/Loop Widget Extension/Components/GlucoseView.swift +++ b/Loop Widget Extension/Components/GlucoseView.swift @@ -8,7 +8,6 @@ import SwiftUI import LoopKit -import HealthKit import LoopCore struct GlucoseView: View { diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift index 78cec95b08..4fe09ba12f 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift @@ -6,7 +6,6 @@ // Copyright © 2023 LoopKit Authors. All rights reserved. // -import HealthKit import LoopCore import LoopKit import WidgetKit @@ -25,8 +24,8 @@ struct StatusWidgetTimelimeEntry: TimelineEntry { let currentGlucose: GlucoseValue? let glucoseFetchedAt: Date? - let delta: HKQuantity? - let unit: HKUnit? + let delta: LoopQuantity? + let unit: LoopUnit? let sensor: GlucoseDisplayableContext? let pumpHighlight: DeviceStatusHighlightContext? diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift index b48bb1f7bf..04939d6761 100644 --- a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift @@ -112,14 +112,15 @@ class StatusWidgetTimelineProvider: TimelineProvider { let finalGlucose = glucose - guard let defaults = self.defaults, + guard let hkUnit = await healthStore.cachedPreferredUnits(for: .bloodGlucose), + let defaults = self.defaults, let context = defaults.statusExtensionContext, - let contextUpdatedAt = context.createdAt, - let unit = await healthStore.cachedPreferredUnits(for: .bloodGlucose) + let contextUpdatedAt = context.createdAt else { return } + let unit = LoopUnit(from: hkUnit) let lastCompleted = context.lastLoopCompleted let closeLoop = context.isClosedLoop ?? false @@ -137,7 +138,7 @@ class StatusWidgetTimelineProvider: TimelineProvider { previousGlucose = finalGlucose[finalGlucose.count - 2] } - var delta: HKQuantity? + var delta: LoopQuantity? // Making sure that previous glucose is within 6 mins of last glucose to avoid large deltas on sensor changes, missed readings, etc. if let prevGlucose = previousGlucose, @@ -145,7 +146,7 @@ class StatusWidgetTimelineProvider: TimelineProvider { currGlucose.startDate.timeIntervalSince(prevGlucose.startDate).minutes < 6 { let deltaMGDL = currGlucose.quantity.doubleValue(for: .milligramsPerDeciliter) - prevGlucose.quantity.doubleValue(for: .milligramsPerDeciliter) - delta = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: deltaMGDL) + delta = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: deltaMGDL) } let predictedGlucose = context.predictedGlucose?.samples diff --git a/Loop/Extensions/CarbStore+SimulatedCoreData.swift b/Loop/Extensions/CarbStore+SimulatedCoreData.swift index 3ddcc23c83..81530e1233 100644 --- a/Loop/Extensions/CarbStore+SimulatedCoreData.swift +++ b/Loop/Extensions/CarbStore+SimulatedCoreData.swift @@ -7,7 +7,7 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit // MARK: - Simulated Core Data @@ -63,7 +63,7 @@ extension CarbStore { fileprivate extension NewCarbEntry { static func simulated(startDate: Date, grams: Double, absorptionTime: TimeInterval) -> NewCarbEntry { return NewCarbEntry(date: startDate, - quantity: HKQuantity(unit: .gram(), doubleValue: grams), + quantity: LoopQuantity(unit: .gram, doubleValue: grams), startDate: startDate, foodType: "Simulated", absorptionTime: absorptionTime) diff --git a/Loop/Extensions/DoseStore+SimulatedCoreData.swift b/Loop/Extensions/DoseStore+SimulatedCoreData.swift index 066e1306a0..7f95ea82ba 100644 --- a/Loop/Extensions/DoseStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DoseStore+SimulatedCoreData.swift @@ -7,7 +7,7 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit // MARK: - Simulated Core Data @@ -144,7 +144,7 @@ fileprivate extension PersistedPumpEvent { value: rate, unit: .unitsPerHour, deliveredUnits: rate * duration / .hours(1), - scheduledBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: scheduledRate))) + scheduledBasalRate: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: scheduledRate))) } private static func simulated(date: Date, type: PumpEventType, alarmType: PumpAlarmType? = nil) -> PersistedPumpEvent { diff --git a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift index ae4b0c05bc..e4a007d4a6 100644 --- a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift @@ -102,10 +102,10 @@ fileprivate extension StoredDosingDecision { var historicalGlucose = [HistoricalGlucoseValue]() for minutes in stride(from: -120.0, to: 0.0, by: 5.0) { historicalGlucose.append(HistoricalGlucoseValue(startDate: date.addingTimeInterval(.minutes(minutes)), - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 125 + minutes / 5))) + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 125 + minutes / 5))) } let originalCarbEntry = StoredCarbEntry(startDate: date.addingTimeInterval(-.minutes(15)), - quantity: HKQuantity(unit: .gram(), doubleValue: 15), + quantity: LoopQuantity(unit: .gram, doubleValue: 15), uuid: UUID(uuidString: "C86DEB61-68E9-464E-9DD5-96A9CB445FD3")!, provenanceIdentifier: Bundle.main.bundleIdentifier!, syncIdentifier: "2B03D96C-6F5D-4140-99CD-80C3E64D6010", @@ -116,7 +116,7 @@ fileprivate extension StoredDosingDecision { userCreatedDate: date.addingTimeInterval(-.minutes(15)), userUpdatedDate: date.addingTimeInterval(-.minutes(1))) let carbEntry = StoredCarbEntry(startDate: date.addingTimeInterval(-.minutes(1)), - quantity: HKQuantity(unit: .gram(), doubleValue: 25), + quantity: LoopQuantity(unit: .gram, doubleValue: 25), uuid: UUID(uuidString: "71B699D7-0E8F-4B13-B7A1-E7751EB78E74")!, provenanceIdentifier: Bundle.main.bundleIdentifier!, syncIdentifier: "2B03D96C-6F5D-4140-99CD-80C3E64D6010", @@ -131,10 +131,10 @@ fileprivate extension StoredDosingDecision { syncIdentifier: "2A67A303-1234-4CB8-8263-79498265368E", syncVersion: 1, startDate: date.addingTimeInterval(-.minutes(1)), - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.45), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.45), condition: nil, trend: .up, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 3.4), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 3.4), isDisplayOnly: false, wasUserEntered: true, device: HKDevice(name: "Device Name", @@ -163,14 +163,14 @@ fileprivate extension StoredDosingDecision { var predictedGlucose = [PredictedGlucoseValue]() for minutes in stride(from: 5.0, to: 360.0, by: 5.0) { predictedGlucose.append(PredictedGlucoseValue(startDate: date.addingTimeInterval(.minutes(minutes)), - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 125 + minutes / 5))) + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 125 + minutes / 5))) } let automaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 0.75, duration: .minutes(30)), bolusUnits: 1.25) let manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 0.2, notice: .predictedGlucoseBelowTarget(minGlucose: SimpleGlucoseValue(startDate: date.addingTimeInterval(.minutes(30)), - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 95.0)))), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 95.0)))), date: date.addingTimeInterval(-.minutes(1))) let manualBolusRequested = 0.5 let warnings: [Issue] = [Issue(id: "one"), diff --git a/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift b/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift index e30a548a4a..4e27c520b0 100644 --- a/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift +++ b/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift @@ -7,7 +7,7 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit // MARK: - Simulated Core Data @@ -52,9 +52,9 @@ extension GlucoseStore { } }() simulated.append(NewGlucoseSample.simulated(date: startDate, - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: new), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: new), trend: trend, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: trendRateValue))) + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: trendRateValue))) if simulated.count >= simulatedLimit { if let error = addSimulatedHistoricalGlucoseObjects(samples: simulated) { @@ -88,7 +88,7 @@ extension GlucoseStore { } fileprivate extension NewGlucoseSample { - static func simulated(date: Date, quantity: HKQuantity, trend: GlucoseTrend?, trendRate: HKQuantity?) -> NewGlucoseSample { + static func simulated(date: Date, quantity: LoopQuantity, trend: GlucoseTrend?, trendRate: LoopQuantity?) -> NewGlucoseSample { return NewGlucoseSample(date: date, quantity: quantity, condition: nil, diff --git a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift index e633401d0d..06633e1ddd 100644 --- a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift +++ b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift @@ -103,7 +103,7 @@ fileprivate extension StoredSettings { RepeatingScheduleValue(startTime: .hours(18), value: 45.0), RepeatingScheduleValue(startTime: .hours(21), value: 50.0)], timeZone: scheduleTimeZone) - let carbRatioSchedule = CarbRatioSchedule(unit: .gram(), + let carbRatioSchedule = CarbRatioSchedule(unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: 10.0), RepeatingScheduleValue(startTime: .hours(8), value: 12.0), RepeatingScheduleValue(startTime: .hours(10), value: 9.0), diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 8528318d6c..16e4eab670 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -9,7 +9,7 @@ import Foundation import LoopKit import LoopCore -import HealthKit +import LoopAlgorithm final class AnalyticsServicesManager { @@ -202,7 +202,7 @@ final class AnalyticsServicesManager { logEvent("Alert Issued", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue]) } - func didEnactOverride(name: String, symbol: String, duration: TemporaryScheduleOverride.Duration, insulinSensitivityMultiplier: Double = 1.0, targetRange: ClosedRange? = nil) + func didEnactOverride(name: String, symbol: String, duration: TemporaryScheduleOverride.Duration, insulinSensitivityMultiplier: Double = 1.0, targetRange: ClosedRange? = nil) { let combinedName = "\(symbol) - \(name)" @@ -213,10 +213,10 @@ final class AnalyticsServicesManager { "nameWithEmoji": combinedName ] - if let targetUpperBound = targetRange?.upperBound.doubleValue(for: HKUnit.milligramsPerDeciliter) { + if let targetUpperBound = targetRange?.upperBound.doubleValue(for: LoopUnit.milligramsPerDeciliter) { properties["targetUpperBound"] = targetUpperBound } - if let targetLowerBound = targetRange?.lowerBound.doubleValue(for: HKUnit.milligramsPerDeciliter) { + if let targetLowerBound = targetRange?.lowerBound.doubleValue(for: LoopUnit.milligramsPerDeciliter) { properties["targetLowerBound"] = targetLowerBound } diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 7d29b8f742..9ac0d5fdc5 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -763,9 +763,9 @@ extension DeviceDataManager { guard FeatureFlags.cgmManagerCategorizeManualGlucoseRangeEnabled else { // Using Dexcom default glucose thresholds to categorize a glucose range - let urgentLowGlucoseThreshold = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 55) - let lowGlucoseThreshold = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) - let highGlucoseThreshold = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 200) + let urgentLowGlucoseThreshold = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 55) + let lowGlucoseThreshold = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) + let highGlucoseThreshold = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 200) let glucoseRangeCategory: GlucoseRangeCategory switch glucose.quantity { diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift index 37e1a21ed6..366cc5eb30 100644 --- a/Loop/Managers/ExtensionDataManager.swift +++ b/Loop/Managers/ExtensionDataManager.swift @@ -6,7 +6,7 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // -import HealthKit +import LoopAlgorithm import UIKit import LoopKit @@ -87,7 +87,7 @@ final class ExtensionDataManager { return info } - private func createStatusContext(glucoseUnit: HKUnit) async -> StatusExtensionContext? { + private func createStatusContext(glucoseUnit: LoopUnit) async -> StatusExtensionContext? { let basalDeliveryState = deviceManager.pumpManager?.status.basalDeliveryState @@ -112,7 +112,7 @@ final class ExtensionDataManager { ) context.predictedGlucose = PredictedGlucoseContext( values: (1...36).map { 89.123 + Double($0 * 5) }, // 3 hours of linear data - unit: HKUnit.milligramsPerDeciliter, + unit: LoopUnit.milligramsPerDeciliter, startDate: Date(), interval: TimeInterval(minutes: 5)) #endif diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 935726187a..c83414ef54 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -298,7 +298,8 @@ class LoopAppManager: NSObject { } Task { @MainActor in - if let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) { + if let hkUnit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) { + let unit = LoopUnit(from: hkUnit) self.displayGlucosePreference.unitDidChange(to: unit) self.notifyObserversOfDisplayGlucoseUnitChange(to: unit) } @@ -764,7 +765,7 @@ extension LoopAppManager: AlertPresenter { protocol DisplayGlucoseUnitBroadcaster: AnyObject { func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) - func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: LoopUnit) } extension LoopAppManager: DisplayGlucoseUnitBroadcaster { @@ -781,7 +782,7 @@ extension LoopAppManager: DisplayGlucoseUnitBroadcaster { displayGlucoseUnitObservers.cleanupDeallocatedElements() } - func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: LoopUnit) { self.displayGlucoseUnitObservers.forEach { $0.unitDidChange(to: displayGlucoseUnit) } @@ -852,7 +853,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { let mealTime = userInfo[LoopNotificationUserInfoKey.missedMealTime.rawValue] as? Date, let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double { - let missedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), + let missedEntry = NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: carbAmount), startDate: mealTime, foodType: nil, diff --git a/Loop/Managers/LoopDataManager+CarbAbsorption.swift b/Loop/Managers/LoopDataManager+CarbAbsorption.swift index 8f532a4e02..705ef75b35 100644 --- a/Loop/Managers/LoopDataManager+CarbAbsorption.swift +++ b/Loop/Managers/LoopDataManager+CarbAbsorption.swift @@ -8,7 +8,6 @@ import Foundation import LoopKit -import HealthKit import LoopAlgorithm struct CarbAbsorptionReview { diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index d79b8c2db9..bf333358df 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -8,7 +8,6 @@ import Foundation import Combine -import HealthKit import LoopKit import LoopKitUI import LoopCore @@ -902,7 +901,7 @@ extension LoopDataManager { ).filterDateRange(startSuspend, endSuspend) } - func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: LoopQuantity?, manualGlucose: LoopQuantity?) -> BolusDosingDecision? { var dosingDecision = BolusDosingDecision(for: .simpleBolus) @@ -938,13 +937,13 @@ extension LoopDataManager { let bolusAmount = SimpleBolusCalculator.recommendedInsulin( mealCarbs: mealCarbs, manualGlucose: manualGlucose, - activeInsulin: HKQuantity.init(unit: .internationalUnit(), doubleValue: iob), + activeInsulin: LoopQuantity.init(unit: .internationalUnit, doubleValue: iob), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule, at: date) - dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit()), notice: notice), + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit), notice: notice), date: Date()) return dosingDecision @@ -1135,7 +1134,7 @@ extension LoopDataManager: ServicesManagerDelegate { throw CarbActionError.invalidCarbs } - guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()) else { + guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) else { throw CarbActionError.exceedsMaxCarbs } @@ -1147,7 +1146,7 @@ extension LoopDataManager: ServicesManagerDelegate { } } - let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) + let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) let _ = try await carbStore.addCarbEntry(candidateCarbEntry) @@ -1199,7 +1198,7 @@ extension LoopDataManager: SimpleBolusViewModelDelegate { settingsProvider.settings.maximumBolus } - var suspendThreshold: HKQuantity? { + var suspendThreshold: LoopQuantity? { settingsProvider.settings.suspendThreshold?.quantity } diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift index e014a4332d..979009f92b 100644 --- a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -7,7 +7,6 @@ // import Foundation -import HealthKit import OSLog import LoopCore import LoopKit @@ -32,7 +31,7 @@ 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 + private let unit = LoopUnit.milligramsPerDeciliter /// The last missed meal notification that was sent /// Internal for unit testing @@ -325,7 +324,7 @@ class MealDetectionManager { return } - let currentCarbRatio = settingsProvider.carbRatioSchedule!.quantity(at: now).doubleValue(for: .gram()) + let currentCarbRatio = settingsProvider.carbRatioSchedule!.quantity(at: now).doubleValue(for: .gram) let maxAllowedCarbAutofill = settingsProvider.maximumBolus! * currentCarbRatio let clampedCarbAmount = min(carbAmount, maxAllowedCarbAutofill) @@ -406,7 +405,7 @@ extension GlucoseEffectVelocity { return GlucoseEffect( startDate: end, - quantity: HKQuantity( + quantity: LoopQuantity( unit: .milligramsPerDeciliter, doubleValue: velocityPerSecond * duration ) diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index b91ab70614..d3ab7d44e8 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -118,7 +118,7 @@ extension NotificationManager { static func sendRemoteBolusNotification(amount: Double) { let notification = UNMutableNotificationContent() - let quantityFormatter = QuantityFormatter(for: .internationalUnit()) + let quantityFormatter = QuantityFormatter(for: .internationalUnit) guard let amountDescription = quantityFormatter.numberFormatter.string(from: amount) else { return } @@ -140,7 +140,7 @@ extension NotificationManager { static func sendRemoteBolusFailureNotification(for error: Error, amountInUnits: Double) { let notification = UNMutableNotificationContent() - let quantityFormatter = QuantityFormatter(for: .internationalUnit()) + let quantityFormatter = QuantityFormatter(for: .internationalUnit) guard let amountDescription = quantityFormatter.numberFormatter.string(from: amountInUnits) else { return } diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index ff80a6170b..13c4e0ea91 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -10,7 +10,6 @@ import Foundation import LoopKit import UserNotifications import UIKit -import HealthKit import Combine import LoopCore import LoopKitUI @@ -278,11 +277,11 @@ class SettingsManager { try await settingsStore.getCarbRatioHistory(startDate: startDate, endDate: endDate) } - func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + 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>] { + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] { try await settingsStore.getTargetRangeHistory(startDate: startDate, endDate: endDate) } @@ -331,8 +330,8 @@ protocol SettingsProvider { 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 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) } diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift index a565cb703f..b6c7d16c5e 100644 --- a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -7,7 +7,6 @@ // import LoopKit -import HealthKit protocol CarbStoreProtocol: AnyObject { diff --git a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift index 0f15a19f56..915f0016f7 100644 --- a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift @@ -7,7 +7,6 @@ // import LoopKit -import HealthKit import LoopAlgorithm protocol GlucoseStoreProtocol: AnyObject { diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 2edbdecb12..31bb4af3a1 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -10,7 +10,6 @@ import Foundation import LoopKit import os.log import LoopCore -import HealthKit protocol PresetActivationObserver: AnyObject { func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index c73af7aeea..b7262086fe 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -9,6 +9,7 @@ import HealthKit import UIKit import WatchConnectivity +import LoopAlgorithm import LoopKit import LoopCore @@ -139,7 +140,7 @@ final class WatchDataManager: NSObject { private var lastComplicationContext: WatchContext? private let minTrendDrift: Double = 20 - private lazy var minTrendUnit = HKUnit.milligramsPerDeciliter + private lazy var minTrendUnit = LoopUnit.milligramsPerDeciliter private func sendSettingsIfNeeded() { let userInfo = LoopSettingsUserInfo( @@ -272,7 +273,7 @@ final class WatchDataManager: NSObject { 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()) + context.cob = carbsOnBoard?.quantity.doubleValue(for: .gram) if let glucoseDisplay = self.deviceManager.glucoseDisplay(for: glucose) { context.glucoseTrend = glucoseDisplay.trendType @@ -388,7 +389,7 @@ final class WatchDataManager: NSObject { if let carbEntry = bolus.carbEntry { let storedCarbEntry = try await loopDataManager.addCarbEntry(carbEntry) dosingDecision.carbEntry = storedCarbEntry - self.analyticsServicesManager?.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) + self.analyticsServicesManager?.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram)) } else { dosingDecision.carbEntry = nil } diff --git a/Loop/Models/ApplicationFactorStrategy.swift b/Loop/Models/ApplicationFactorStrategy.swift index d3244ec1c2..57038f5cb0 100644 --- a/Loop/Models/ApplicationFactorStrategy.swift +++ b/Loop/Models/ApplicationFactorStrategy.swift @@ -7,13 +7,13 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopCore protocol ApplicationFactorStrategy { func calculateDosingFactor( - for glucose: HKQuantity, - correctionRange: ClosedRange + for glucose: LoopQuantity, + correctionRange: ClosedRange ) -> Double } diff --git a/Loop/Models/ConstantApplicationFactorStrategy.swift b/Loop/Models/ConstantApplicationFactorStrategy.swift index 82ab6ebad6..41b4b478e2 100644 --- a/Loop/Models/ConstantApplicationFactorStrategy.swift +++ b/Loop/Models/ConstantApplicationFactorStrategy.swift @@ -7,15 +7,14 @@ // import Foundation -import HealthKit import LoopKit import LoopCore import LoopAlgorithm struct ConstantApplicationFactorStrategy: ApplicationFactorStrategy { func calculateDosingFactor( - for glucose: HKQuantity, - correctionRange: ClosedRange + for glucose: LoopQuantity, + correctionRange: ClosedRange ) -> Double { // The original strategy uses a constant dosing factor. return LoopAlgorithm.defaultBolusPartialApplicationFactor diff --git a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift index 7f03337011..e339d4a881 100644 --- a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift +++ b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift @@ -7,7 +7,7 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopCore @@ -20,8 +20,8 @@ struct GlucoseBasedApplicationFactorStrategy: ApplicationFactorStrategy { static let maxGlucoseSlidingScale = 200.0 // mg/dL func calculateDosingFactor( - for glucose: HKQuantity, - correctionRange: ClosedRange + for glucose: LoopQuantity, + correctionRange: ClosedRange ) -> Double { // Calculate current glucose and lower bound target let currentGlucose = glucose.doubleValue(for: .milligramsPerDeciliter) diff --git a/Loop/Models/GlucoseDisplay.swift b/Loop/Models/GlucoseDisplay.swift index 119e06d096..fe81a05b34 100644 --- a/Loop/Models/GlucoseDisplay.swift +++ b/Loop/Models/GlucoseDisplay.swift @@ -7,19 +7,19 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit struct GlucoseDisplay: GlucoseDisplayable { let isStateValid: Bool let trendType: GlucoseTrend? - let trendRate: HKQuantity? + let trendRate: LoopQuantity? let isLocal: Bool var glucoseRangeCategory: GlucoseRangeCategory? init(isStateValid: Bool, trendType: GlucoseTrend?, - trendRate: HKQuantity?, + trendRate: LoopQuantity?, isLocal: Bool, glucoseRangeCategory: GlucoseRangeCategory?) { @@ -45,7 +45,7 @@ struct GlucoseDisplay: GlucoseDisplayable { struct ManualGlucoseDisplay: GlucoseDisplayable { let isStateValid: Bool let trendType: GlucoseTrend? - let trendRate: HKQuantity? + let trendRate: LoopQuantity? let isLocal: Bool let glucoseRangeCategory: GlucoseRangeCategory? diff --git a/Loop/Models/GlucoseEffectVelocity.swift b/Loop/Models/GlucoseEffectVelocity.swift index 6680073769..4a9ab40796 100644 --- a/Loop/Models/GlucoseEffectVelocity.swift +++ b/Loop/Models/GlucoseEffectVelocity.swift @@ -5,7 +5,6 @@ // Copyright © 2017 LoopKit Authors. All rights reserved. // -import HealthKit import LoopKit import LoopAlgorithm @@ -13,7 +12,7 @@ import LoopAlgorithm extension GlucoseEffectVelocity: RawRepresentable { public typealias RawValue = [String: Any] - static let unit = HKUnit.milligramsPerDeciliter.unitDivided(by: .minute()) + static let unit = LoopUnit.milligramsPerDeciliterPerMinute public init?(rawValue: RawValue) { guard let startDate = rawValue["startDate"] as? Date, @@ -25,7 +24,7 @@ extension GlucoseEffectVelocity: RawRepresentable { self.init( startDate: startDate, endDate: rawValue["endDate"] as? Date ?? startDate, - quantity: HKQuantity(unit: type(of: self).unit, doubleValue: doubleValue) + quantity: LoopQuantity(unit: type(of: self).unit, doubleValue: doubleValue) ) } diff --git a/Loop/Models/LoopConstants.swift b/Loop/Models/LoopConstants.swift index bd1296c12f..0d7d881279 100644 --- a/Loop/Models/LoopConstants.swift +++ b/Loop/Models/LoopConstants.swift @@ -7,7 +7,7 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import UIKit @@ -15,11 +15,11 @@ enum LoopConstants { // Input field bounds - static let maxCarbEntryQuantity = HKQuantity(unit: .gram(), doubleValue: 250) // cannot exceed this value + static let maxCarbEntryQuantity = LoopQuantity(unit: .gram, doubleValue: 250) // cannot exceed this value - static let warningCarbEntryQuantity = HKQuantity(unit: .gram(), doubleValue: 99) // user is warned above this value + static let warningCarbEntryQuantity = LoopQuantity(unit: .gram, doubleValue: 99) // user is warned above this value - static let validManualGlucoseEntryRange = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 10)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 600) + static let validManualGlucoseEntryRange = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 10)...LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 600) static let minCarbAbsorptionTime = TimeInterval(minutes: 30) static let maxCarbAbsorptionTime = TimeInterval(hours: 8) @@ -36,13 +36,13 @@ enum LoopConstants { static let statusChartMinimumHistoryDisplay: TimeInterval = .hours(1) static let glucoseChartDefaultDisplayBound = - HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 175) + LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 100)...LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 175) static let glucoseChartDefaultDisplayRangeWide = - HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 60)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 200) + LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 60)...LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 200) static let glucoseChartDefaultDisplayBoundClamped = - HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 240) + LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 80)...LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 240) // Compile time configuration @@ -66,7 +66,7 @@ enum LoopConstants { static let missedMealWarningVelocitySampleMinDuration = TimeInterval(minutes: 12) // Bolus calculator warning thresholds - static let simpleBolusCalculatorMinGlucoseBolusRecommendation = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 70) - static let simpleBolusCalculatorMinGlucoseMealBolusRecommendation = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 55) - static let simpleBolusCalculatorGlucoseWarningLimit = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 70) + static let simpleBolusCalculatorMinGlucoseBolusRecommendation = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 70) + static let simpleBolusCalculatorMinGlucoseMealBolusRecommendation = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 55) + static let simpleBolusCalculatorGlucoseWarningLimit = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 70) } diff --git a/Loop/Models/ManualBolusRecommendation.swift b/Loop/Models/ManualBolusRecommendation.swift index 1753813e2c..4594bae7f8 100644 --- a/Loop/Models/ManualBolusRecommendation.swift +++ b/Loop/Models/ManualBolusRecommendation.swift @@ -8,12 +8,11 @@ import Foundation import LoopKit -import HealthKit import LoopAlgorithm extension BolusRecommendationNotice { - public func description(using unit: HKUnit) -> String { + public func description(using unit: LoopUnit) -> String { switch self { case .glucoseBelowSuspendThreshold(minGlucose: let minGlucose): let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) diff --git a/Loop/Models/PredictionInputEffect.swift b/Loop/Models/PredictionInputEffect.swift index 175afd3c1b..342fe23db5 100644 --- a/Loop/Models/PredictionInputEffect.swift +++ b/Loop/Models/PredictionInputEffect.swift @@ -7,7 +7,6 @@ // import Foundation -import HealthKit import LoopKit import LoopAlgorithm @@ -39,7 +38,7 @@ struct PredictionInputEffect: OptionSet { } } - func localizedDescription(forGlucoseUnit unit: HKUnit) -> String? { + func localizedDescription(forGlucoseUnit unit: LoopUnit) -> String? { switch self { case [.carbs]: return String(format: NSLocalizedString("Carbs Absorbed (g) ÷ Carb Ratio (g/U) × Insulin Sensitivity (%1$@/U)", comment: "Description of the prediction input effect for carbohydrates. (1: The glucose unit string)"), unit.localizedShortUnitString) diff --git a/Loop/Models/SimpleBolusCalculator.swift b/Loop/Models/SimpleBolusCalculator.swift index a26af98466..515aef3f5f 100644 --- a/Loop/Models/SimpleBolusCalculator.swift +++ b/Loop/Models/SimpleBolusCalculator.swift @@ -8,17 +8,17 @@ import Foundation import LoopCore -import HealthKit +import LoopAlgorithm import LoopKit struct SimpleBolusCalculator { - public static func recommendedInsulin(mealCarbs: HKQuantity?, manualGlucose: HKQuantity?, activeInsulin: HKQuantity, carbRatioSchedule: CarbRatioSchedule, correctionRangeSchedule: GlucoseRangeSchedule, sensitivitySchedule: InsulinSensitivitySchedule, at date: Date = Date()) -> HKQuantity { + public static func recommendedInsulin(mealCarbs: LoopQuantity?, manualGlucose: LoopQuantity?, activeInsulin: LoopQuantity, carbRatioSchedule: CarbRatioSchedule, correctionRangeSchedule: GlucoseRangeSchedule, sensitivitySchedule: InsulinSensitivitySchedule, at date: Date = Date()) -> LoopQuantity { var recommendedBolus: Double = 0 if let mealCarbs = mealCarbs { let carbRatio = carbRatioSchedule.quantity(at: date) - recommendedBolus += mealCarbs.doubleValue(for: .gram()) / carbRatio.doubleValue(for: .gram()) + recommendedBolus += mealCarbs.doubleValue(for: .gram) / carbRatio.doubleValue(for: .gram) } if let manualGlucose = manualGlucose { @@ -28,7 +28,7 @@ struct SimpleBolusCalculator { let correctionTarget = correctionRange.averageValue(for: .milligramsPerDeciliter) let correctionBolus = (manualGlucose.doubleValue(for: .milligramsPerDeciliter) - correctionTarget) / sensitivity if correctionBolus >= 0 { - let activeInsulin = max(0, activeInsulin.doubleValue(for: .internationalUnit())) + let activeInsulin = max(0, activeInsulin.doubleValue(for: .internationalUnit)) let correctionBolusMinusActiveInsulin = correctionBolus - activeInsulin recommendedBolus += max(0, correctionBolusMinusActiveInsulin) } else { @@ -46,6 +46,6 @@ struct SimpleBolusCalculator { // No negative recommendation recommendedBolus = max(0, recommendedBolus) - return HKQuantity(unit: .internationalUnit(), doubleValue: recommendedBolus) + return LoopQuantity(unit: .internationalUnit, doubleValue: recommendedBolus) } } diff --git a/Loop/Models/StoredDataAlgorithmInput.swift b/Loop/Models/StoredDataAlgorithmInput.swift index ae33304c3c..b5273bf2c6 100644 --- a/Loop/Models/StoredDataAlgorithmInput.swift +++ b/Loop/Models/StoredDataAlgorithmInput.swift @@ -8,7 +8,6 @@ import Foundation import LoopKit -import HealthKit import LoopAlgorithm struct StoredDataAlgorithmInput: AlgorithmInput { @@ -28,13 +27,13 @@ struct StoredDataAlgorithmInput: AlgorithmInput { var basal: [AbsoluteScheduleValue] - var sensitivity: [AbsoluteScheduleValue] + var sensitivity: [AbsoluteScheduleValue] var carbRatio: [AbsoluteScheduleValue] var target: GlucoseRangeTimeline - var suspendThreshold: HKQuantity? + var suspendThreshold: LoopQuantity? var maxBolus: Double diff --git a/Loop/Models/WatchContext+LoopKit.swift b/Loop/Models/WatchContext+LoopKit.swift index 2235ec7b92..e1374e4ed1 100644 --- a/Loop/Models/WatchContext+LoopKit.swift +++ b/Loop/Models/WatchContext+LoopKit.swift @@ -7,12 +7,11 @@ // import Foundation -import HealthKit import LoopKit import LoopAlgorithm extension WatchContext { - convenience init(glucose: GlucoseSampleValue?, glucoseUnit: HKUnit?) { + convenience init(glucose: GlucoseSampleValue?, glucoseUnit: LoopUnit?) { self.init() self.glucose = glucose?.quantity diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index be8327bba8..8e7fc59406 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -6,7 +6,6 @@ // import SwiftUI -import HealthKit import Intents import LoopCore import LoopKit @@ -235,7 +234,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif static let count = 1 } - private lazy var carbFormatter: QuantityFormatter = QuantityFormatter(for: .gram()) + private lazy var carbFormatter: QuantityFormatter = QuantityFormatter(for: .gram) private lazy var absorptionFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -292,7 +291,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif return cell case .entries: - let unit = HKUnit.gram() + let unit = LoopUnit.gram let cell = tableView.dequeueReusableCell(withIdentifier: CarbEntryTableViewCell.className, for: indexPath) as! CarbEntryTableViewCell // Entry value @@ -323,7 +322,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif if let absorption = status.absorption { // Absorbed value - let observedProgress = Float(absorption.observedProgress.doubleValue(for: .percent())) + let observedProgress = Float(absorption.observedProgress.doubleValue(for: .percent)) let observedCarbs = absorption.observed if let observedCarbsText = carbFormatter.string(from: observedCarbs) { @@ -342,7 +341,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif } cell.observedProgress = observedProgress - cell.clampedProgress = Float(absorption.observedProgress.doubleValue(for: .percent())) + cell.clampedProgress = Float(absorption.observedProgress.doubleValue(for: .percent)) cell.observedDateText = absorptionFormatter.string(from: absorption.estimatedDate.duration) // Absorbed time @@ -366,7 +365,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif } private func updateCell(_ cell: HeaderValuesTableViewCell) { - let unit = HKUnit.gram() + let unit = LoopUnit.gram if let carbsOnBoard = carbsOnBoard, carbsOnBoard.quantity.doubleValue(for: unit) > 0 { cell.COBDateLabel.text = String( @@ -389,7 +388,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif cell.COBDateLabel.textColor = textColor } else { cell.COBDateLabel.text = nil - cell.COBValueLabel.text = carbFormatter.string(from: HKQuantity(unit: .gram(), doubleValue: 0), includeUnit: false) + cell.COBValueLabel.text = carbFormatter.string(from: LoopQuantity(unit: .gram, doubleValue: 0), includeUnit: false) } if let carbTotal = carbTotal { @@ -400,7 +399,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif cell.totalValueLabel.text = carbFormatter.string(from: carbTotal.quantity, includeUnit: false) } else { cell.totalDateLabel.text = nil - cell.totalValueLabel.text = carbFormatter.string(from: HKQuantity(unit: .gram(), doubleValue: 0), includeUnit: false) + cell.totalValueLabel.text = carbFormatter.string(from: LoopQuantity(unit: .gram, doubleValue: 0), includeUnit: false) } } diff --git a/Loop/View Controllers/GlucoseThresholdTableViewController.swift b/Loop/View Controllers/GlucoseThresholdTableViewController.swift index 1657be2779..cb6bbb7011 100644 --- a/Loop/View Controllers/GlucoseThresholdTableViewController.swift +++ b/Loop/View Controllers/GlucoseThresholdTableViewController.swift @@ -7,16 +7,16 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI import UIKit final class GlucoseThresholdTableViewController: TextFieldTableViewController { - public let glucoseUnit: HKUnit + public let glucoseUnit: LoopUnit - init(threshold: Double?, glucoseUnit: HKUnit) { + init(threshold: Double?, glucoseUnit: LoopUnit) { self.glucoseUnit = glucoseUnit super.init(style: .grouped) diff --git a/Loop/View Controllers/LoopChartsTableViewController.swift b/Loop/View Controllers/LoopChartsTableViewController.swift index 8b1e56447b..699459f0b7 100644 --- a/Loop/View Controllers/LoopChartsTableViewController.swift +++ b/Loop/View Controllers/LoopChartsTableViewController.swift @@ -8,7 +8,6 @@ import UIKit import LoopUI import LoopKitUI -import HealthKit import os.log diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index 79ab21a35f..ffaffe0070 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -6,7 +6,6 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // -import HealthKit import LoopCore import LoopKit import LoopKitUI @@ -76,7 +75,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable private var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? - private var totalRetrospectiveCorrection: HKQuantity? + private var totalRetrospectiveCorrection: LoopQuantity? private var refreshContext = RefreshContext.all @@ -115,7 +114,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable chartStartDate = calendar.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date var glucoseSamples: [StoredGlucoseSample]? - var totalRetrospectiveCorrection: HKQuantity? + var totalRetrospectiveCorrection: LoopQuantity? // For now, do this every time _ = self.refreshContext.remove(.status) @@ -259,7 +258,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable let currentGlucose = loopDataManager.latestGlucose { let formatter = QuantityFormatter(for: glucoseChart.glucoseUnit) - let predicted = HKQuantity(unit: glucoseChart.glucoseUnit, doubleValue: currentGlucose.quantity.doubleValue(for: glucoseChart.glucoseUnit) - lastDiscrepancy.quantity.doubleValue(for: glucoseChart.glucoseUnit)) + let predicted = LoopQuantity(unit: glucoseChart.glucoseUnit, doubleValue: currentGlucose.quantity.doubleValue(for: glucoseChart.glucoseUnit) - lastDiscrepancy.quantity.doubleValue(for: glucoseChart.glucoseUnit)) var values = [predicted, currentGlucose.quantity].map { formatter.string(from: $0) ?? "?" } formatter.numberFormatter.positivePrefix = formatter.numberFormatter.plusSign values.append(formatter.string(from: lastDiscrepancy.quantity) ?? "?") @@ -275,7 +274,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable var totalEffectDisplay = "?" if let totalEffect = self.totalRetrospectiveCorrection { let integralEffectValue = totalEffect.doubleValue(for: glucoseChart.glucoseUnit) - lastDiscrepancy.quantity.doubleValue(for: glucoseChart.glucoseUnit) - let integralEffect = HKQuantity(unit: glucoseChart.glucoseUnit, doubleValue: integralEffectValue) + let integralEffect = LoopQuantity(unit: glucoseChart.glucoseUnit, doubleValue: integralEffectValue) integralEffectDisplay = formatter.string(from: integralEffect) ?? "?" totalEffectDisplay = formatter.string(from: totalEffect) ?? "?" } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 1b075628d2..1448a6fb36 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -7,7 +7,6 @@ // import UIKit -import HealthKit import SwiftUI import Intents import LoopCore @@ -30,10 +29,10 @@ final class StatusTableViewController: LoopChartsTableViewController { private let log = OSLog(category: "StatusTableViewController") - lazy var carbFormatter: QuantityFormatter = QuantityFormatter(for: .gram()) + lazy var carbFormatter: QuantityFormatter = QuantityFormatter(for: .gram) lazy var insulinFormatter: QuantityFormatter = { - let formatter = QuantityFormatter(for: .internationalUnit()) + let formatter = QuantityFormatter(for: .internationalUnit) formatter.numberFormatter.maximumFractionDigits = 2 return formatter }() @@ -480,7 +479,7 @@ final class StatusTableViewController: LoopChartsTableViewController { var doseEntries: [BasalRelativeDose]? var totalDelivery: Double? var cobValues: [CarbValue]? - var carbsOnBoard: HKQuantity? + var carbsOnBoard: LoopQuantity? let startDate = charts.startDate let basalDeliveryState = self.basalDeliveryState let automaticDosingEnabled = automaticDosingStatus.automaticDosingEnabled diff --git a/Loop/View Controllers/TextFieldTableViewController.swift b/Loop/View Controllers/TextFieldTableViewController.swift index 07c686e0f8..5dc954e726 100644 --- a/Loop/View Controllers/TextFieldTableViewController.swift +++ b/Loop/View Controllers/TextFieldTableViewController.swift @@ -7,7 +7,6 @@ // import LoopKitUI -import HealthKit /// Convenience static constructors used to contain common configuration diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 4b88387fd5..49f3a1e5d2 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -7,7 +7,6 @@ // import Combine -import HealthKit import LocalAuthentication import Intents import os.log @@ -66,7 +65,7 @@ final class BolusEntryViewModel: ObservableObject { enum Notice: Equatable { case predictedGlucoseInRange - case predictedGlucoseBelowSuspendThreshold(suspendThreshold: HKQuantity) + case predictedGlucoseBelowSuspendThreshold(suspendThreshold: LoopQuantity) case glucoseBelowTarget case staleGlucoseData case futureGlucoseData @@ -93,26 +92,26 @@ final class BolusEntryViewModel: ObservableObject { @Published var predictedGlucoseValues: [GlucoseValue] = [] @Published var chartDateInterval: DateInterval - @Published var activeCarbs: HKQuantity? - @Published var activeInsulin: HKQuantity? + @Published var activeCarbs: LoopQuantity? + @Published var activeInsulin: LoopQuantity? @Published var targetGlucoseSchedule: GlucoseRangeSchedule? @Published var preMealOverride: TemporaryScheduleOverride? private var savedPreMealOverride: TemporaryScheduleOverride? @Published var scheduleOverride: TemporaryScheduleOverride? - var maximumBolus: HKQuantity? + var maximumBolus: LoopQuantity? let originalCarbEntry: StoredCarbEntry? let potentialCarbEntry: NewCarbEntry? let selectedCarbAbsorptionTimeEmoji: String? - @Published var recommendedBolus: HKQuantity? + @Published var recommendedBolus: LoopQuantity? var recommendedBolusAmount: Double? { - recommendedBolus?.doubleValue(for: .internationalUnit()) + recommendedBolus?.doubleValue(for: .internationalUnit) } - @Published var enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) + @Published var enteredBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0) var enteredBolusAmount: Double { - enteredBolus.doubleValue(for: .internationalUnit()) + enteredBolus.doubleValue(for: .internationalUnit) } private var userChangedBolusAmount = false @Published var isInitiatingSaveOrBolus = false @@ -138,7 +137,7 @@ final class BolusEntryViewModel: ObservableObject { }() @Published var isManualGlucoseEntryEnabled = false - @Published var manualGlucoseQuantity: HKQuantity? + @Published var manualGlucoseQuantity: LoopQuantity? var manualGlucoseSample: NewGlucoseSample? @@ -240,7 +239,7 @@ final class BolusEntryViewModel: ObservableObject { guard let self = self else { return } // Clear out any entered bolus whenever the glucose entry changes - self.enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) + self.enteredBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0) Task { await self.updatePredictedGlucoseValues() @@ -324,7 +323,7 @@ final class BolusEntryViewModel: ObservableObject { return false } - guard enteredBolusAmount <= maximumBolus.doubleValue(for: .internationalUnit()) else { + guard enteredBolusAmount <= maximumBolus.doubleValue(for: .internationalUnit) else { presentAlert(.maxBolusExceeded) return false } @@ -368,7 +367,7 @@ final class BolusEntryViewModel: ObservableObject { self.dosingDecision.manualGlucoseSample = nil } - let activationType = BolusActivationType.activationTypeFor(recommendedAmount: recommendedBolus?.doubleValue(for: .internationalUnit()), bolusAmount: amountToDeliver) + let activationType = BolusActivationType.activationTypeFor(recommendedAmount: recommendedBolus?.doubleValue(for: .internationalUnit), bolusAmount: amountToDeliver) if let carbEntry = potentialCarbEntry { if originalCarbEntry == nil { @@ -382,7 +381,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()), isFavoriteFood: storedCarbEntry.favoriteFoodID != nil) + self.analyticsServicesManager?.didAddCarbs(source: "Phone", amount: storedCarbEntry.quantity.doubleValue(for: .gram), isFavoriteFood: storedCarbEntry.favoriteFoodID != nil) } else { self.presentAlert(.carbEntryPersistenceFailure) return false @@ -416,7 +415,7 @@ final class BolusEntryViewModel: ObservableObject { } private lazy var bolusAmountFormatter: NumberFormatter = { - let formatter = QuantityFormatter(for: .internationalUnit()) + let formatter = QuantityFormatter(for: .internationalUnit) formatter.numberFormatter.roundingMode = .down return formatter.numberFormatter }() @@ -436,7 +435,7 @@ final class BolusEntryViewModel: ObservableObject { } var maximumBolusAmountString: String? { - guard let maxBolusAmount = maximumBolus?.doubleValue(for: .internationalUnit()) else { + guard let maxBolusAmount = maximumBolus?.doubleValue(for: .internationalUnit) else { return nil } return formatBolusAmount(maxBolusAmount) @@ -445,7 +444,7 @@ final class BolusEntryViewModel: ObservableObject { var carbEntryAmountAndEmojiString: String? { guard let potentialCarbEntry = potentialCarbEntry, - let carbAmountString = QuantityFormatter(for: .gram()).string(from: potentialCarbEntry.quantity) + let carbAmountString = QuantityFormatter(for: .gram).string(from: potentialCarbEntry.quantity) else { return nil } @@ -501,7 +500,7 @@ final class BolusEntryViewModel: ObservableObject { var chartGlucoseValues = storedGlucoseValues if let manualGlucoseSample = manualGlucoseSample { - chartGlucoseValues.append(manualGlucoseSample.quantitySample) + chartGlucoseValues.append(LoopQuantitySample(with: manualGlucoseSample.quantitySample)) } self.glucoseValues = chartGlucoseValues @@ -523,7 +522,7 @@ final class BolusEntryViewModel: ObservableObject { deliveryType: .bolus, startDate: startDate, endDate: startDate, - volume: enteredBolus.doubleValue(for: .internationalUnit()), + volume: enteredBolus.doubleValue(for: .internationalUnit), insulinModel: insulinModel ) @@ -554,13 +553,13 @@ final class BolusEntryViewModel: ObservableObject { } var recommendation: ManualBolusRecommendation? - let recommendedBolus: HKQuantity? + let recommendedBolus: LoopQuantity? let notice: Notice? do { recommendation = try await computeBolusRecommendation() if let recommendation, deliveryDelegate != nil { - recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) + recommendedBolus = LoopQuantity(unit: .internationalUnit, doubleValue: recommendation.amount) switch recommendation.notice { case .glucoseBelowSuspendThreshold: @@ -577,7 +576,7 @@ final class BolusEntryViewModel: ObservableObject { notice = nil } } else { - recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) + recommendedBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0) notice = nil } } catch { @@ -640,7 +639,7 @@ final class BolusEntryViewModel: ObservableObject { } maximumBolus = delegate.settings.maximumBolus.map { maxBolusAmount in - HKQuantity(unit: .internationalUnit(), doubleValue: maxBolusAmount) + LoopQuantity(unit: .internationalUnit, doubleValue: maxBolusAmount) } dosingDecision.scheduleOverride = scheduleOverride @@ -690,7 +689,7 @@ final class BolusEntryViewModel: ObservableObject { } func updateEnteredBolus(_ enteredBolusAmount: Double?) { - enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: enteredBolusAmount ?? 0) + enteredBolus = LoopQuantity(unit: .internationalUnit, doubleValue: enteredBolusAmount ?? 0) } } @@ -726,7 +725,7 @@ extension BolusEntryViewModel { } private var hasBolusEntryReadyToDeliver: Bool { - enteredBolus.doubleValue(for: .internationalUnit()) != 0 + enteredBolus.doubleValue(for: .internationalUnit) != 0 } private var hasDataToSave: Bool { diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index b71771b6ad..2228a289b0 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -8,7 +8,6 @@ import SwiftUI import LoopKit -import HealthKit import Combine import LoopCore import LoopAlgorithm @@ -59,7 +58,7 @@ final class CarbEntryViewModel: ObservableObject { let shouldBeginEditingQuantity: Bool @Published var carbsQuantity: Double? = nil - var preferredCarbUnit = HKUnit.gram() + var preferredCarbUnit = LoopUnit.gram var maxCarbEntryQuantity = LoopConstants.maxCarbEntryQuantity var warningCarbEntryQuantity = LoopConstants.warningCarbEntryQuantity @@ -160,7 +159,7 @@ final class CarbEntryViewModel: ObservableObject { return NewCarbEntry( date: date, - quantity: HKQuantity(unit: preferredCarbUnit, doubleValue: quantity), + quantity: LoopQuantity(unit: preferredCarbUnit, doubleValue: quantity), startDate: time, foodType: usesCustomFoodType ? foodType : selectedDefaultAbsorptionTimeEmoji, absorptionTime: absorptionTime, @@ -200,7 +199,7 @@ final class CarbEntryViewModel: ObservableObject { } guard let carbsQuantity, carbsQuantity > 0 else { return } - let quantity = HKQuantity(unit: preferredCarbUnit, doubleValue: carbsQuantity) + let quantity = LoopQuantity(unit: preferredCarbUnit, doubleValue: carbsQuantity) if quantity.compare(maxCarbEntryQuantity) == .orderedDescending { self.alert = .maxQuantityExceded return diff --git a/Loop/View Models/FavoriteFoodAddEditViewModel.swift b/Loop/View Models/FavoriteFoodAddEditViewModel.swift index 225766db4c..b3f70fe3cf 100644 --- a/Loop/View Models/FavoriteFoodAddEditViewModel.swift +++ b/Loop/View Models/FavoriteFoodAddEditViewModel.swift @@ -8,7 +8,7 @@ import SwiftUI import LoopKit -import HealthKit +import LoopAlgorithm final class FavoriteFoodAddEditViewModel: ObservableObject { enum Alert: Identifiable { @@ -23,7 +23,7 @@ final class FavoriteFoodAddEditViewModel: ObservableObject { @Published var name = "" @Published var carbsQuantity: Double? = nil - var preferredCarbUnit = HKUnit.gram() + var preferredCarbUnit = LoopUnit.gram var maxCarbEntryQuantity = LoopConstants.maxCarbEntryQuantity var warningCarbEntryQuantity = LoopConstants.warningCarbEntryQuantity @@ -76,7 +76,7 @@ final class FavoriteFoodAddEditViewModel: ObservableObject { return NewFavoriteFood( name: name, - carbsQuantity: HKQuantity(unit: preferredCarbUnit, doubleValue: quantity), + carbsQuantity: LoopQuantity(unit: preferredCarbUnit, doubleValue: quantity), foodType: foodType, absorptionTime: absorptionTime ) @@ -90,7 +90,7 @@ final class FavoriteFoodAddEditViewModel: ObservableObject { guard let updatedFavoriteFood, absorptionTime <= maxAbsorptionTime else { return } guard let carbsQuantity, carbsQuantity > 0 else { return } - let quantity = HKQuantity(unit: preferredCarbUnit, doubleValue: carbsQuantity) + let quantity = LoopQuantity(unit: preferredCarbUnit, doubleValue: carbsQuantity) if quantity.compare(maxCarbEntryQuantity) == .orderedDescending { self.alert = .maxQuantityExceded return diff --git a/Loop/View Models/FavoriteFoodInsightsViewModel.swift b/Loop/View Models/FavoriteFoodInsightsViewModel.swift index e7403c2c48..d649fe844c 100644 --- a/Loop/View Models/FavoriteFoodInsightsViewModel.swift +++ b/Loop/View Models/FavoriteFoodInsightsViewModel.swift @@ -11,7 +11,6 @@ import LoopKitUI import LoopAlgorithm import os.log import Combine -import HealthKit protocol FavoriteFoodInsightsViewModelDelegate: AnyObject { func selectedFavoriteFoodLastEaten(_ favoriteFood: StoredFavoriteFood) async throws -> Date? @@ -56,7 +55,7 @@ class FavoriteFoodInsightsViewModel: ObservableObject { } var now = Date() - var preferredCarbUnit = HKUnit.gram() + var preferredCarbUnit = LoopUnit.gram lazy var carbFormatter = QuantityFormatter(for: preferredCarbUnit) lazy var absorptionTimeFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() diff --git a/Loop/View Models/FavoriteFoodsViewModel.swift b/Loop/View Models/FavoriteFoodsViewModel.swift index f9055c46f2..4b425ccee7 100644 --- a/Loop/View Models/FavoriteFoodsViewModel.swift +++ b/Loop/View Models/FavoriteFoodsViewModel.swift @@ -7,7 +7,7 @@ // import SwiftUI -import HealthKit +import LoopAlgorithm import LoopKit import Combine import os.log @@ -20,7 +20,7 @@ final class FavoriteFoodsViewModel: ObservableObject { @Published var isEditViewActive = false @Published var isAddViewActive = false - var preferredCarbUnit = HKUnit.gram() + var preferredCarbUnit = LoopUnit.gram lazy var carbFormatter = QuantityFormatter(for: preferredCarbUnit) lazy var absorptionTimeFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() diff --git a/Loop/View Models/ManualEntryDoseViewModel.swift b/Loop/View Models/ManualEntryDoseViewModel.swift index f6d1235df6..5365595d0c 100644 --- a/Loop/View Models/ManualEntryDoseViewModel.swift +++ b/Loop/View Models/ManualEntryDoseViewModel.swift @@ -7,7 +7,6 @@ // import Combine -import HealthKit import LocalAuthentication import Intents import os.log @@ -38,18 +37,18 @@ final class ManualEntryDoseViewModel: ObservableObject { @Published var glucoseValues: [GlucoseValue] = [] // stored glucose values @Published var predictedGlucoseValues: [GlucoseValue] = [] - @Published var glucoseUnit: HKUnit = .milligramsPerDeciliter + @Published var glucoseUnit: LoopUnit = .milligramsPerDeciliter @Published var chartDateInterval: DateInterval - @Published var activeCarbs: HKQuantity? - @Published var activeInsulin: HKQuantity? + @Published var activeCarbs: LoopQuantity? + @Published var activeInsulin: LoopQuantity? @Published var targetGlucoseSchedule: GlucoseRangeSchedule? @Published var preMealOverride: TemporaryScheduleOverride? private var savedPreMealOverride: TemporaryScheduleOverride? @Published var scheduleOverride: TemporaryScheduleOverride? - @Published var enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) + @Published var enteredBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0) private var isInitiatingSaveOrBolus = false private let log = OSLog(category: "ManualEntryDoseViewModel") @@ -171,7 +170,7 @@ final class ManualEntryDoseViewModel: ObservableObject { // MARK: - View API func saveManualDose() async throws { - guard enteredBolus.doubleValue(for: .internationalUnit()) > 0 else { + guard enteredBolus.doubleValue(for: .internationalUnit) > 0 else { return } @@ -185,7 +184,7 @@ final class ManualEntryDoseViewModel: ObservableObject { } private func continueSaving() async { - let doseVolume = enteredBolus.doubleValue(for: .internationalUnit()) + let doseVolume = enteredBolus.doubleValue(for: .internationalUnit) guard doseVolume > 0 else { return } @@ -193,7 +192,7 @@ final class ManualEntryDoseViewModel: ObservableObject { await delegate?.addManuallyEnteredDose(startDate: selectedDoseDate, units: doseVolume, insulinType: selectedInsulinType) } - private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit()) + private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit) private lazy var absorptionTimeFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -205,7 +204,7 @@ final class ManualEntryDoseViewModel: ObservableObject { }() var enteredBolusAmountString: String { - let bolusVolume = enteredBolus.doubleValue(for: .internationalUnit()) + let bolusVolume = enteredBolus.doubleValue(for: .internationalUnit) return bolusVolumeFormatter.numberFormatter.string(from: bolusVolume) ?? String(bolusVolume) } @@ -235,7 +234,7 @@ final class ManualEntryDoseViewModel: ObservableObject { deliveryType: .bolus, startDate: selectedDoseDate, endDate: selectedDoseDate, - volume: enteredBolus.doubleValue(for: .internationalUnit()), + volume: enteredBolus.doubleValue(for: .internationalUnit), insulinModel: insulinModel ) diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index ea93e53d7d..f907427989 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -11,7 +11,6 @@ import LoopCore import LoopKit import LoopKitUI import SwiftUI -import HealthKit public class DeviceViewModel: ObservableObject { public typealias DeleteTestingDataFunc = () -> Void diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift index 3d90042d3e..45593378d3 100644 --- a/Loop/View Models/SimpleBolusViewModel.swift +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -7,7 +7,6 @@ // import Foundation -import HealthKit import LoopKit import LoopKitUI import os.log @@ -29,11 +28,11 @@ protocol SimpleBolusViewModelDelegate: AnyObject { func insulinOnBoard(at date: Date) async -> InsulinValue? - func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: LoopQuantity?, manualGlucose: LoopQuantity?) -> BolusDosingDecision? var maximumBolus: Double? { get } - var suspendThreshold: HKQuantity? { get } + var suspendThreshold: LoopQuantity? { get } } @MainActor @@ -75,7 +74,7 @@ class SimpleBolusViewModel: ObservableObject { @Published var enteredCarbString: String = "" { didSet { if let enteredCarbs = Self.carbAmountFormatter.number(from: enteredCarbString)?.doubleValue, enteredCarbs > 0 { - carbQuantity = HKQuantity(unit: .gram(), doubleValue: enteredCarbs) + carbQuantity = LoopQuantity(unit: .gram, doubleValue: enteredCarbs) } else { carbQuantity = nil } @@ -87,7 +86,7 @@ class SimpleBolusViewModel: ObservableObject { // needed to detect change in display glucose unit when returning to the app - private var cachedDisplayGlucoseUnit: HKUnit + private var cachedDisplayGlucoseUnit: LoopUnit var manualGlucoseString: String { get { @@ -121,14 +120,14 @@ class SimpleBolusViewModel: ObservableObject { } if let bolus = bolus { - guard bolus.doubleValue(for: .internationalUnit()) <= maxBolus else { + guard bolus.doubleValue(for: .internationalUnit) <= maxBolus else { activeNotice = .maxBolusExceeded return } } let isAddingCarbs: Bool - if let carbQuantity = carbQuantity, carbQuantity.doubleValue(for: .gram()) > 0 { + if let carbQuantity = carbQuantity, carbQuantity.doubleValue(for: .gram) > 0 { isAddingCarbs = true } else { isAddingCarbs = false @@ -170,7 +169,7 @@ class SimpleBolusViewModel: ObservableObject { if manualGlucoseQuantity == nil || _manualGlucoseString != displayGlucosePreference.format(manualGlucoseQuantity!, includeUnit: false) { - manualGlucoseQuantity = HKQuantity(unit: cachedDisplayGlucoseUnit, doubleValue: manualGlucoseValue) + manualGlucoseQuantity = LoopQuantity(unit: cachedDisplayGlucoseUnit, doubleValue: manualGlucoseValue) updateNotice() } } @@ -179,7 +178,7 @@ class SimpleBolusViewModel: ObservableObject { @Published var enteredBolusString: String { didSet { if let enteredBolusAmount = Self.doseAmountFormatter.number(from: enteredBolusString)?.doubleValue, enteredBolusAmount > 0 { - bolus = HKQuantity(unit: .internationalUnit(), doubleValue: enteredBolusAmount) + bolus = LoopQuantity(unit: .internationalUnit, doubleValue: enteredBolusAmount) } else { bolus = nil } @@ -187,18 +186,18 @@ class SimpleBolusViewModel: ObservableObject { } } - private var carbQuantity: HKQuantity? = nil + private var carbQuantity: LoopQuantity? = nil - private var manualGlucoseQuantity: HKQuantity? = nil { + private var manualGlucoseQuantity: LoopQuantity? = nil { didSet { updateRecommendation() } } - private var bolus: HKQuantity? = nil + private var bolus: LoopQuantity? = nil var bolusRecommended: Bool { - if let bolus = bolus, bolus.doubleValue(for: .internationalUnit()) > 0 { + if let bolus = bolus, bolus.doubleValue(for: .internationalUnit) > 0 { return true } return false @@ -206,9 +205,9 @@ class SimpleBolusViewModel: ObservableObject { let displayGlucosePreference: DisplayGlucosePreference - var displayGlucoseUnit: HKUnit { return displayGlucosePreference.unit } + var displayGlucoseUnit: LoopUnit { return displayGlucosePreference.unit } - var suspendThreshold: HKQuantity? { return delegate.suspendThreshold } + var suspendThreshold: LoopQuantity? { return delegate.suspendThreshold } private var recommendation: Double? = nil { didSet { @@ -234,7 +233,7 @@ class SimpleBolusViewModel: ObservableObject { }() private static let carbAmountFormatter: NumberFormatter = { - let quantityFormatter = QuantityFormatter(for: .gram()) + let quantityFormatter = QuantityFormatter(for: .gram) return quantityFormatter.numberFormatter }() @@ -278,13 +277,13 @@ class SimpleBolusViewModel: ObservableObject { private let delegate: SimpleBolusViewModelDelegate private let log = OSLog(category: "SimpleBolusViewModel") - private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit()) + private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit) var maximumBolusAmountString: String { guard let maxBolus = delegate.maximumBolus else { return "" } - let maxBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus) + let maxBolusQuantity = LoopQuantity(unit: .internationalUnit, doubleValue: maxBolus) return bolusVolumeFormatter.string(from: maxBolusQuantity)! } @@ -342,7 +341,7 @@ class SimpleBolusViewModel: ObservableObject { let saveDate = Date() // Authenticate if needed - if let bolus = bolus, bolus.doubleValue(for: .internationalUnit()) > 0 { + 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) { @@ -396,7 +395,7 @@ class SimpleBolusViewModel: ObservableObject { } } - if let bolusVolume = bolus?.doubleValue(for: .internationalUnit()), bolusVolume > 0 { + 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 diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 7e01c87a8e..7aa7bc0f9f 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -7,7 +7,7 @@ // import Combine -import HealthKit +import LoopAlgorithm import SwiftUI import LoopKit import LoopKitUI @@ -58,7 +58,7 @@ struct BolusEntryView: View { .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) .onReceive(self.viewModel.$recommendedBolus) { recommendation in // If the recommendation changes, and the user has not edited the bolus amount, update the bolus amount - let amount = recommendation?.doubleValue(for: .internationalUnit()) ?? 0 + let amount = recommendation?.doubleValue(for: .internationalUnit) ?? 0 if !editedBolusAmount { var newEnteredBolusString: String if amount == 0 { @@ -136,7 +136,7 @@ struct BolusEntryView: View { LabeledQuantity( label: Text("Active Carbs", comment: "Title describing quantity of still-absorbing carbohydrates"), quantity: viewModel.activeCarbs, - unit: .gram() + unit: .gram ) } @@ -145,7 +145,7 @@ struct BolusEntryView: View { LabeledQuantity( label: Text("Active Insulin", comment: "Title describing quantity of still-absorbing insulin"), quantity: viewModel.activeInsulin, - unit: .internationalUnit(), + unit: .internationalUnit, maxFractionDigits: 2 ) } @@ -264,7 +264,7 @@ struct BolusEntryView: View { } private var bolusUnitsLabel: some View { - Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) + Text(QuantityFormatter(for: .internationalUnit).localizedUnitStringWithPlurality()) .foregroundColor(Color(.secondaryLabel)) } @@ -431,8 +431,8 @@ struct BolusEntryView: View { struct LabeledQuantity: View { var label: Text - var quantity: HKQuantity? - var unit: HKUnit + var quantity: LoopQuantity? + var unit: LoopUnit var maxFractionDigits: Int? var body: some View { diff --git a/Loop/Views/BolusProgressTableViewCell.swift b/Loop/Views/BolusProgressTableViewCell.swift index 3ac0af962b..3fa7149648 100644 --- a/Loop/Views/BolusProgressTableViewCell.swift +++ b/Loop/Views/BolusProgressTableViewCell.swift @@ -9,7 +9,7 @@ import Foundation import LoopKit import LoopUI -import HealthKit +import LoopAlgorithm import MKRingProgressView @@ -46,7 +46,7 @@ public class BolusProgressTableViewCell: UITableViewCell { } lazy var insulinFormatter: QuantityFormatter = { - let formatter = QuantityFormatter(for: .internationalUnit()) + let formatter = QuantityFormatter(for: .internationalUnit) formatter.numberFormatter.minimumFractionDigits = 2 return formatter }() @@ -99,11 +99,11 @@ public class BolusProgressTableViewCell: UITableViewCell { activityIndicator.isHidden = true tapToStopLabel.isHidden = false - let totalUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: totalVolume) + let totalUnitsQuantity = LoopQuantity(unit: .internationalUnit, doubleValue: totalVolume) let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" if let delivered { - let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: delivered) + let deliveredUnitsQuantity = LoopQuantity(unit: .internationalUnit, doubleValue: delivered) let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" progressLabel.text = String(format: NSLocalizedString("Bolused %1$@ of %2$@", comment: "The format string for bolus progress. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) @@ -127,10 +127,10 @@ public class BolusProgressTableViewCell: UITableViewCell { activityIndicator.isHidden = true tapToStopLabel.isHidden = true - let totalUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: totalVolume) + let totalUnitsQuantity = LoopQuantity(unit: .internationalUnit, doubleValue: totalVolume) let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" - let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: delivered) + let deliveredUnitsQuantity = LoopQuantity(unit: .internationalUnit, doubleValue: delivered) let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" progressLabel.text = String(format: NSLocalizedString("Bolus Canceled: Delivered %1$@ of %2$@", comment: "The title of the cell indicating a bolus has been canceled. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index c5faffa1b4..649d645599 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -9,7 +9,6 @@ import SwiftUI import LoopKit import LoopKitUI -import HealthKit struct CarbEntryView: View, HorizontalSizeClassOverride { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference diff --git a/Loop/Views/Charts/CarbEffectChartView.swift b/Loop/Views/Charts/CarbEffectChartView.swift index 8a6f4eda1f..9a98113535 100644 --- a/Loop/Views/Charts/CarbEffectChartView.swift +++ b/Loop/Views/Charts/CarbEffectChartView.swift @@ -7,14 +7,13 @@ // import SwiftUI -import HealthKit import LoopKit import LoopKitUI import LoopAlgorithm struct CarbEffectChartView: View { let chartManager: ChartsManager - var glucoseUnit: HKUnit + var glucoseUnit: LoopUnit var carbAbsorptionReview: CarbAbsorptionReview? var dateInterval: DateInterval diff --git a/Loop/Views/Charts/GlucoseCarbChartView.swift b/Loop/Views/Charts/GlucoseCarbChartView.swift index 7b6ed91b37..70f1f5c4ad 100644 --- a/Loop/Views/Charts/GlucoseCarbChartView.swift +++ b/Loop/Views/Charts/GlucoseCarbChartView.swift @@ -7,14 +7,13 @@ // import SwiftUI -import HealthKit import LoopKit import LoopKitUI import LoopAlgorithm struct GlucoseCarbChartView: View { let chartManager: ChartsManager - var glucoseUnit: HKUnit + var glucoseUnit: LoopUnit var glucoseValues: [GlucoseValue] var carbEntries: [StoredCarbEntry] var dateInterval: DateInterval diff --git a/Loop/Views/Charts/PredictedGlucoseChartView.swift b/Loop/Views/Charts/PredictedGlucoseChartView.swift index 2d6725cbed..e223a2c3cc 100644 --- a/Loop/Views/Charts/PredictedGlucoseChartView.swift +++ b/Loop/Views/Charts/PredictedGlucoseChartView.swift @@ -6,7 +6,6 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // -import HealthKit import SwiftUI import LoopKit import LoopKitUI @@ -14,7 +13,7 @@ import LoopAlgorithm struct PredictedGlucoseChartView: View { let chartManager: ChartsManager - var glucoseUnit: HKUnit + var glucoseUnit: LoopUnit var glucoseValues: [GlucoseValue] var predictedGlucoseValues: [GlucoseValue] = [] var targetGlucoseSchedule: GlucoseRangeSchedule? = nil diff --git a/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift b/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift index 10f7625d68..160adb3514 100644 --- a/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift +++ b/Loop/Views/Favorite Foods/FavoriteFoodDetailView.swift @@ -9,7 +9,6 @@ import SwiftUI import LoopKit import LoopKitUI -import HealthKit public struct FavoriteFoodDetailView: View { @ObservedObject var viewModel: FavoriteFoodsViewModel diff --git a/Loop/Views/Favorite Foods/FavoriteFoodInsightsChartsView.swift b/Loop/Views/Favorite Foods/FavoriteFoodInsightsChartsView.swift index 71205485f7..8a67b7c519 100644 --- a/Loop/Views/Favorite Foods/FavoriteFoodInsightsChartsView.swift +++ b/Loop/Views/Favorite Foods/FavoriteFoodInsightsChartsView.swift @@ -10,7 +10,6 @@ import SwiftUI import LoopKit import LoopKitUI import LoopAlgorithm -import HealthKit import Combine struct FavoriteFoodsInsightsChartsView: View { diff --git a/Loop/Views/ManualEntryDoseView.swift b/Loop/Views/ManualEntryDoseView.swift index ea70d235dc..7882827a7b 100644 --- a/Loop/Views/ManualEntryDoseView.swift +++ b/Loop/Views/ManualEntryDoseView.swift @@ -7,7 +7,7 @@ // import Combine -import HealthKit +import LoopAlgorithm import SwiftUI import LoopKit import LoopKitUI @@ -103,7 +103,7 @@ struct ManualEntryDoseView: View { LabeledQuantity( label: Text("Active Carbs", comment: "Title describing quantity of still-absorbing carbohydrates"), quantity: viewModel.activeCarbs, - unit: .gram() + unit: .gram ) } @@ -112,7 +112,7 @@ struct ManualEntryDoseView: View { LabeledQuantity( label: Text("Active Insulin", comment: "Title describing quantity of still-absorbing insulin"), quantity: viewModel.activeInsulin, - unit: .internationalUnit(), + unit: .internationalUnit, maxFractionDigits: 2 ) } @@ -157,7 +157,7 @@ struct ManualEntryDoseView: View { } private static let doseAmountFormatter: NumberFormatter = { - let quantityFormatter = QuantityFormatter(for: .internationalUnit()) + let quantityFormatter = QuantityFormatter(for: .internationalUnit) return quantityFormatter.numberFormatter }() @@ -207,7 +207,7 @@ struct ManualEntryDoseView: View { } private var bolusUnitsLabel: some View { - Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) + Text(QuantityFormatter(for: .internationalUnit).localizedUnitStringWithPlurality()) .foregroundColor(Color(.secondaryLabel)) } @@ -215,7 +215,7 @@ struct ManualEntryDoseView: View { Binding( get: { self.enteredBolusString }, set: { newValue in - self.viewModel.enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: Self.doseAmountFormatter.number(from: newValue)?.doubleValue ?? 0) + self.viewModel.enteredBolus = LoopQuantity(unit: .internationalUnit, doubleValue: Self.doseAmountFormatter.number(from: newValue)?.doubleValue ?? 0) self.enteredBolusString = newValue } ) diff --git a/Loop/Views/ManualGlucoseEntryRow.swift b/Loop/Views/ManualGlucoseEntryRow.swift index 33bcecfb1b..f719bd5c1a 100644 --- a/Loop/Views/ManualGlucoseEntryRow.swift +++ b/Loop/Views/ManualGlucoseEntryRow.swift @@ -8,17 +8,17 @@ import Foundation import SwiftUI +import LoopAlgorithm import LoopKit import LoopKitUI import Combine -import HealthKit struct ManualGlucoseEntryRow: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @State private var valueText = "" - @Binding var quantity: HKQuantity? + @Binding var quantity: LoopQuantity? @State private var isManualGlucoseEntryRowVisible = false @@ -42,7 +42,7 @@ struct ManualGlucoseEntryRow: View { ) .onChange(of: valueText, perform: { value in if let manualGlucoseValue = displayGlucosePreference.formatter.numberFormatter.number(from: valueText)?.doubleValue { - quantity = HKQuantity(unit: displayGlucosePreference.unit, doubleValue: manualGlucoseValue) + quantity = LoopQuantity(unit: displayGlucosePreference.unit, doubleValue: manualGlucoseValue) } else { quantity = nil } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index a8fb09a3f8..c47b5f62d6 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -10,7 +10,6 @@ import LoopKit import LoopKitUI import MockKit import SwiftUI -import HealthKit import LoopUI diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index 903caf4c8c..de4cec2a07 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -9,7 +9,6 @@ import SwiftUI import LoopKit import LoopKitUI -import HealthKit import LoopCore import LoopAlgorithm @@ -68,7 +67,7 @@ struct SimpleBolusView: View { .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) } - private func formatGlucose(_ quantity: HKQuantity) -> String { + private func formatGlucose(_ quantity: LoopQuantity) -> String { return displayGlucosePreference.format(quantity) } @@ -211,7 +210,7 @@ struct SimpleBolusView: View { } private var carbUnitsLabel: some View { - Text(QuantityFormatter(for: .gram()).localizedUnitStringWithPlurality()) + Text(QuantityFormatter(for: .gram).localizedUnitStringWithPlurality()) .foregroundColor(Color(.secondaryLabel)) } @@ -222,7 +221,7 @@ struct SimpleBolusView: View { } private var bolusUnitsLabel: Text { - Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) + Text(QuantityFormatter(for: .internationalUnit).localizedUnitStringWithPlurality()) .foregroundColor(Color(.secondaryLabel)) } @@ -334,7 +333,7 @@ struct SimpleBolusView: View { title: Text("Recommended Bolus Exceeds Maximum Bolus", comment: "Title for bolus screen warning when recommended bolus exceeds max bolus"), caption: Text(String(format: NSLocalizedString("Your recommended bolus exceeds your maximum bolus amount of %1$@.", comment: "Warning for simple bolus when recommended bolus exceeds max bolus. (1: maximum bolus)"), viewModel.maximumBolusAmountString ))) case .carbohydrateEntryTooLarge: - let maximumCarbohydrateString = QuantityFormatter(for: .gram()).string(from: LoopConstants.maxCarbEntryQuantity)! + let maximumCarbohydrateString = QuantityFormatter(for: .gram).string(from: LoopConstants.maxCarbEntryQuantity)! return WarningView( title: Text("Carbohydrate Entry Too Large", comment: "Title for bolus screen warning when carbohydrate entry is too large"), caption: Text(String(format: NSLocalizedString("The maximum amount allowed is %1$@.", comment: "Warning for simple bolus when carbohydrate entry is too large. (1: maximum carbohydrate entry)"), maximumCarbohydrateString))) @@ -383,7 +382,7 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { completion(.success(InsulinValue(startDate: date, value: 2.0))) } - func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: LoopQuantity?, manualGlucose: LoopQuantity?) -> BolusDosingDecision? { var decision = BolusDosingDecision(for: .simpleBolus) decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 3), date: Date()) @@ -401,8 +400,8 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { return 6 } - var suspendThreshold: HKQuantity? { - return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 75) + var suspendThreshold: LoopQuantity? { + return LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 75) } } diff --git a/LoopCore/HKUnit.swift b/LoopCore/HKUnit.swift index 7f9a5e3009..da6d179e84 100644 --- a/LoopCore/HKUnit.swift +++ b/LoopCore/HKUnit.swift @@ -17,16 +17,4 @@ extension HKUnit { public static let millimolesPerLiter: HKUnit = { return HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: .liter()) }() - - public static let milligramsPerDeciliterPerMinute: HKUnit = { - return HKUnit.milligramsPerDeciliter.unitDivided(by: .minute()) - }() - - public static let millimolesPerLiterPerMinute: HKUnit = { - return HKUnit.millimolesPerLiter.unitDivided(by: .minute()) - }() - - public static let internationalUnitsPerHour: HKUnit = { - return HKUnit.internationalUnit().unitDivided(by: .hour()) - }() } diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index b93aecf837..ea68ce4ad5 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -5,7 +5,6 @@ // Copyright © 2017 LoopKit Authors. All rights reserved. // -import HealthKit import LoopKit import LoopAlgorithm @@ -31,9 +30,9 @@ public struct LoopSettings: Equatable { public var carbRatioSchedule: CarbRatioSchedule? - public var preMealTargetRange: ClosedRange? + public var preMealTargetRange: ClosedRange? - public var legacyWorkoutTargetRange: ClosedRange? + public var legacyWorkoutTargetRange: ClosedRange? public var overridePresets: [TemporaryScheduleOverridePreset] = [] @@ -47,7 +46,7 @@ public struct LoopSettings: Equatable { public var defaultRapidActingModel: ExponentialInsulinModelPreset? - public var glucoseUnit: HKUnit? { + public var glucoseUnit: LoopUnit? { return glucoseTargetRangeSchedule?.unit } @@ -57,8 +56,8 @@ public struct LoopSettings: Equatable { insulinSensitivitySchedule: InsulinSensitivitySchedule? = nil, basalRateSchedule: BasalRateSchedule? = nil, carbRatioSchedule: CarbRatioSchedule? = nil, - preMealTargetRange: ClosedRange? = nil, - legacyWorkoutTargetRange: ClosedRange? = nil, + preMealTargetRange: ClosedRange? = nil, + legacyWorkoutTargetRange: ClosedRange? = nil, overridePresets: [TemporaryScheduleOverridePreset]? = nil, maximumBasalRatePerHour: Double? = nil, maximumBolus: Double? = nil, @@ -85,7 +84,7 @@ public struct LoopSettings: Equatable { extension LoopSettings: RawRepresentable { public typealias RawValue = [String: Any] private static let version = 1 - fileprivate static let codingGlucoseUnit = HKUnit.milligramsPerDeciliter + fileprivate static let codingGlucoseUnit = LoopUnit.milligramsPerDeciliter public init?(rawValue: RawValue) { guard diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index c4a2cbe172..beb9446061 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -8,7 +8,6 @@ import Foundation import LoopKit -import HealthKit import LoopAlgorithm extension UserDefaults { diff --git a/LoopTests/Managers/CGMStalenessMonitorTests.swift b/LoopTests/Managers/CGMStalenessMonitorTests.swift index 9da44f7f00..5971d75daa 100644 --- a/LoopTests/Managers/CGMStalenessMonitorTests.swift +++ b/LoopTests/Managers/CGMStalenessMonitorTests.swift @@ -9,7 +9,7 @@ import XCTest import Foundation import LoopKit -import HealthKit +import LoopAlgorithm @testable import Loop class CGMStalenessMonitorTests: XCTestCase { @@ -18,11 +18,11 @@ class CGMStalenessMonitorTests: XCTestCase { private var fetchExpectation: XCTestExpectation? private var storedGlucoseSample: StoredGlucoseSample { - return StoredGlucoseSample(uuid: UUID(), provenanceIdentifier: UUID().uuidString, syncIdentifier: "syncIdentifier", syncVersion: 1, startDate: Date().addingTimeInterval(-.minutes(5)), quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), condition: nil, trend: .flat, trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 0.0), isDisplayOnly: false, wasUserEntered: false, device: nil, healthKitEligibleDate: nil) + return StoredGlucoseSample(uuid: UUID(), provenanceIdentifier: UUID().uuidString, syncIdentifier: "syncIdentifier", syncVersion: 1, startDate: Date().addingTimeInterval(-.minutes(5)), quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), condition: nil, trend: .flat, trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 0.0), isDisplayOnly: false, wasUserEntered: false, device: nil, healthKitEligibleDate: nil) } private var newGlucoseSample: NewGlucoseSample { - return NewGlucoseSample(date: Date().addingTimeInterval(-.minutes(1)), quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), condition: nil, trend: .flat, trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 0.0), isDisplayOnly: false, wasUserEntered: false, syncIdentifier: "syncIdentifier") + return NewGlucoseSample(date: Date().addingTimeInterval(-.minutes(1)), quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), condition: nil, trend: .flat, trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 0.0), isDisplayOnly: false, wasUserEntered: false, syncIdentifier: "syncIdentifier") } func testInitialValue() { diff --git a/LoopTests/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift index c72a955cab..6f4976e592 100644 --- a/LoopTests/Managers/DeviceDataManagerTests.swift +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -8,6 +8,7 @@ import XCTest import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI import LoopCore @@ -120,7 +121,7 @@ final class DeviceDataManagerTests: XCTestCase { pumpManager.status.basalDeliveryState = .tempBasal(dose) let newLimits = DeliveryLimits( - maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: 5), + maximumBasalRate: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 5), maximumBolus: nil ) let limits = try await deviceDataManager.syncDeliveryLimits(deliveryLimits: newLimits) @@ -141,7 +142,7 @@ final class DeviceDataManagerTests: XCTestCase { pumpManager.status.basalDeliveryState = .tempBasal(dose) let newLimits = DeliveryLimits( - maximumBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: 3), + maximumBasalRate: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 3), maximumBolus: nil ) let limits = try await deviceDataManager.syncDeliveryLimits(deliveryLimits: newLimits) @@ -205,6 +206,6 @@ extension DeviceDataManagerTests: DisplayGlucoseUnitBroadcaster { func removeDisplayGlucoseUnitObserver(_ observer: LoopKitUI.DisplayGlucoseUnitObserver) { } - func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: LoopUnit) { } } diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index 08e5f4d9b5..445cd9aa7c 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -9,7 +9,6 @@ import XCTest import Foundation import LoopKit -import HealthKit import LoopAlgorithm @testable import Loop diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 45d7612b7a..f833a80934 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -7,9 +7,7 @@ // import XCTest -import HealthKit import LoopKit -import HealthKit import LoopAlgorithm @testable import LoopCore @@ -50,13 +48,13 @@ class LoopDataManagerTests: XCTestCase { let defaultAccuracy = 1.0 / 40.0 var suspendThreshold: GlucoseThreshold { - return GlucoseThreshold(unit: HKUnit.milligramsPerDeciliter, value: 75) + return GlucoseThreshold(unit: .milligramsPerDeciliter, value: 75) } var adultExponentialInsulinModel: InsulinModel = ExponentialInsulinModel(actionDuration: 21600.0, peakActivityTime: 4500.0) var glucoseTargetRangeSchedule: GlucoseRangeSchedule { - return GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ + return GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [ RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 100, maxValue: 110)), RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) @@ -90,7 +88,7 @@ class LoopDataManagerTests: XCTestCase { timeZone: .utcTimeZone )! let carbRatioSchedule = CarbRatioSchedule( - unit: .gram(), + unit: .gram, dailyItems: [ RepeatingScheduleValue(startTime: 0.0, value: 10.0), ], @@ -151,7 +149,7 @@ class LoopDataManagerTests: XCTestCase { 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)) + return GlucoseEffect(startDate: localDateFormatter.date(from: $0["date"] as! String)!, quantity: LoopQuantity(unit: LoopUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) } } @@ -184,7 +182,7 @@ class LoopDataManagerTests: XCTestCase { timeZone: .utcTimeZone )! let carbRatioSchedule = CarbRatioSchedule( - unit: .gram(), + unit: .gram, dailyItems: [ RepeatingScheduleValue(startTime: 0.0, value: predictionInput.carbRatio.first!.value) ], @@ -475,13 +473,13 @@ extension LoopDataManagerTests { } } -extension HKQuantity { - static func glucose(value: Double) -> HKQuantity { +extension LoopQuantity { + static func glucose(value: Double) -> LoopQuantity { return .init(unit: .milligramsPerDeciliter, doubleValue: value) } - static func carbs(value: Double) -> HKQuantity { - return .init(unit: .gram(), doubleValue: value) + static func carbs(value: Double) -> LoopQuantity { + return .init(unit: .gram, doubleValue: value) } } diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index 7acfe1b660..202459cd2f 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -7,7 +7,6 @@ // import XCTest -import HealthKit import LoopCore import LoopKit import LoopAlgorithm @@ -20,10 +19,10 @@ fileprivate class MockGlucoseSample: GlucoseSampleValue { let isDisplayOnly: Bool let wasUserEntered: Bool let condition: GlucoseCondition? = nil - let trendRate: HKQuantity? = nil + let trendRate: LoopQuantity? = nil var trend: LoopKit.GlucoseTrend? var syncIdentifier: String? - let quantity: HKQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100) + let quantity: LoopQuantity = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 100) let startDate: Date init(startDate: Date, isDisplayOnly: Bool = false, wasUserEntered: Bool = false) { @@ -112,33 +111,33 @@ extension MissedMealTestType { switch self { case .missedMealWithCOB: return [ - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 30), startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, foodType: nil, absorptionTime: nil), - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 10), + NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 10), startDate: Self.dateFormatter.date(from: "2022-10-19T17:36:58")!, foodType: nil, absorptionTime: nil) ] case .noMealWithCOB: return [ - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 30), startDate: Self.dateFormatter.date(from: "2022-10-17T22:40:00")!, foodType: nil, absorptionTime: nil) ] case .manyMeals: return [ - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 30), startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, foodType: nil, absorptionTime: nil), - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 10), + NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 10), startDate: Self.dateFormatter.date(from: "2022-10-19T17:36:58")!, foodType: nil, absorptionTime: nil), - NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 40), + NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: 40), startDate: Self.dateFormatter.date(from: "2022-10-19T19:11:43")!, foodType: nil, absorptionTime: nil) @@ -150,7 +149,7 @@ extension MissedMealTestType { var carbSchedule: CarbRatioSchedule { CarbRatioSchedule( - unit: .gram(), + unit: .gram, dailyItems: [ RepeatingScheduleValue(startTime: 0.0, value: 15.0), ], @@ -163,16 +162,16 @@ extension MissedMealTestType { switch self { case .mmolUser: return InsulinSensitivitySchedule( - unit: HKUnit.millimolesPerLiter, + unit: LoopUnit.millimolesPerLiter, dailyItems: [ RepeatingScheduleValue(startTime: 0.0, - value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: value).doubleValue(for: .millimolesPerLiter)) + value: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: value).doubleValue(for: .millimolesPerLiter)) ], timeZone: .utcTimeZone )! default: return InsulinSensitivitySchedule( - unit: HKUnit.milligramsPerDeciliter, + unit: .milligramsPerDeciliter, dailyItems: [ RepeatingScheduleValue(startTime: 0.0, value: value) ], @@ -227,7 +226,7 @@ class MealDetectionManagerTests: XCTestCase { 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), + suspendThreshold: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 65), maxBolus: maximumBolus!, maxBasalRate: maximumBasalRatePerHour, useIntegralRetrospectiveCorrection: false, @@ -287,7 +286,7 @@ class MealDetectionManagerTests: XCTestCase { return fixture.map { GlucoseEffectVelocity(startDate: dateFormatter.date(from: $0["startDate"] as! String)!, endDate: dateFormatter.date(from: $0["endDate"] as! String)!, - quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), + quantity: LoopQuantity(unit: LoopUnit(from: $0["unit"] as! String), doubleValue:$0["value"] as! Double)) } } diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index df0f7d8b21..cce5401699 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -6,7 +6,6 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // -import HealthKit import LoopKit import LoopCore @testable import Loop diff --git a/LoopTests/Mock Stores/MockGlucoseStore.swift b/LoopTests/Mock Stores/MockGlucoseStore.swift index ea6c3f118d..eb4c488841 100644 --- a/LoopTests/Mock Stores/MockGlucoseStore.swift +++ b/LoopTests/Mock Stores/MockGlucoseStore.swift @@ -6,7 +6,6 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // -import HealthKit import LoopKit import LoopAlgorithm @testable import Loop diff --git a/LoopTests/Mocks/MockSettingsProvider.swift b/LoopTests/Mocks/MockSettingsProvider.swift index 823f0901f8..c73041511c 100644 --- a/LoopTests/Mocks/MockSettingsProvider.swift +++ b/LoopTests/Mocks/MockSettingsProvider.swift @@ -8,7 +8,6 @@ import Foundation import LoopKit -import HealthKit import LoopAlgorithm @testable import Loop @@ -23,13 +22,13 @@ class MockSettingsProvider: SettingsProvider { return carbRatioHistory ?? settings.carbRatioSchedule?.between(start: startDate, end: endDate) ?? [] } - var insulinSensitivityHistory: [AbsoluteScheduleValue]? - func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + 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>] { + var targetRangeHistory: [AbsoluteScheduleValue>]? + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] { return targetRangeHistory ?? settings.glucoseTargetRangeSchedule?.quantityBetween(start: startDate, end: endDate) ?? [] } diff --git a/LoopTests/Models/SetBolusUserInfoTests.swift b/LoopTests/Models/SetBolusUserInfoTests.swift index a486abff15..2dda80dc9d 100644 --- a/LoopTests/Models/SetBolusUserInfoTests.swift +++ b/LoopTests/Models/SetBolusUserInfoTests.swift @@ -7,7 +7,7 @@ // import XCTest -import HealthKit +import LoopAlgorithm import LoopKit @testable import Loop @@ -16,7 +16,7 @@ class SetBolusUserInfoTests: XCTestCase { private var startDate = dateFormatter.date(from: "2020-05-14T22:45:00Z")! private var contextDate = dateFormatter.date(from: "2020-05-14T22:38:14Z")! private var carbEntry = NewCarbEntry(date: dateFormatter.date(from: "2020-05-14T22:39:34Z")!, - quantity: HKQuantity(unit: .gram(), doubleValue: 17), + quantity: LoopQuantity(unit: .gram, doubleValue: 17), startDate: dateFormatter.date(from: "2020-05-14T22:00:00Z")!, foodType: "Pizza", absorptionTime: .hours(5)) @@ -99,7 +99,7 @@ class SetBolusUserInfoTests: XCTestCase { XCTAssertEqual(rawValue["at"] as? BolusActivationType.RawValue, activationType.rawValue) let carbEntryRawValue = rawValue["ce"] as? NewCarbEntry.RawValue XCTAssertEqual(carbEntryRawValue?["date"] as? Date, carbEntry.date) - XCTAssertEqual(carbEntryRawValue?["grams"] as? Double, carbEntry.quantity.doubleValue(for: .gram())) + XCTAssertEqual(carbEntryRawValue?["grams"] as? Double, carbEntry.quantity.doubleValue(for: .gram)) XCTAssertEqual(carbEntryRawValue?["startDate"] as? Date, carbEntry.startDate) XCTAssertEqual(carbEntryRawValue?["foodType"] as? String, carbEntry.foodType) XCTAssertEqual(carbEntryRawValue?["absorptionTime"] as? TimeInterval, carbEntry.absorptionTime) diff --git a/LoopTests/Models/SimpleBolusCalculatorTests.swift b/LoopTests/Models/SimpleBolusCalculatorTests.swift index 069cb54368..e143bfea83 100644 --- a/LoopTests/Models/SimpleBolusCalculatorTests.swift +++ b/LoopTests/Models/SimpleBolusCalculatorTests.swift @@ -7,9 +7,8 @@ // import Foundation - import XCTest -import HealthKit +import LoopAlgorithm import LoopKit @testable import Loop @@ -22,128 +21,128 @@ class SimpleBolusCalculatorTests: XCTestCase { RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100.0, maxValue: 110.0)) ])! - let carbRatioSchedule = CarbRatioSchedule(unit: .gram(), dailyItems: [RepeatingScheduleValue(startTime: 0, value: 10)])! + let carbRatioSchedule = CarbRatioSchedule(unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 10)])! let sensitivitySchedule = InsulinSensitivitySchedule(unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: 80)])! func testMealRecommendation() { let recommendation = SimpleBolusCalculator.recommendedInsulin( - mealCarbs: HKQuantity(unit: .gram(), doubleValue: 40), + mealCarbs: LoopQuantity(unit: .gram, doubleValue: 40), manualGlucose: nil, - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 0), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(4.0, recommendation.doubleValue(for: .internationalUnit())) + XCTAssertEqual(4.0, recommendation.doubleValue(for: .internationalUnit)) } func testCorrectionRecommendation() { let recommendation = SimpleBolusCalculator.recommendedInsulin( mealCarbs: nil, - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 0), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(0.94, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(0.94, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testCorrectionRecommendationWithIOB() { let recommendation = SimpleBolusCalculator.recommendedInsulin( mealCarbs: nil, - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 10), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 10), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(0.0, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(0.0, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testCorrectionRecommendationWithNegativeIOB() { let recommendation = SimpleBolusCalculator.recommendedInsulin( mealCarbs: nil, - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: -1), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: -1), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(0.94, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(0.94, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testCorrectionRecommendationWhenInRange() { let recommendation = SimpleBolusCalculator.recommendedInsulin( mealCarbs: nil, - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 110), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 110), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 0), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(0.0, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(0.0, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testCorrectionAndCarbsRecommendationWhenBelowRange() { let recommendation = SimpleBolusCalculator.recommendedInsulin( - mealCarbs: HKQuantity(unit: .gram(), doubleValue: 40), - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 70), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + mealCarbs: LoopQuantity(unit: .gram, doubleValue: 40), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 70), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 0), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(3.56, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(3.56, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testCarbsEntryWithActiveInsulinAndNoGlucose() { let recommendation = SimpleBolusCalculator.recommendedInsulin( - mealCarbs: HKQuantity(unit: .gram(), doubleValue: 20), + mealCarbs: LoopQuantity(unit: .gram, doubleValue: 20), manualGlucose: nil, - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 4), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 4), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(2, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(2, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testCarbsEntryWithActiveInsulinAndCarbsAndNoCorrection() { let recommendation = SimpleBolusCalculator.recommendedInsulin( - mealCarbs: HKQuantity(unit: .gram(), doubleValue: 20), - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 4), + mealCarbs: LoopQuantity(unit: .gram, doubleValue: 20), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 100), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 4), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(2, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(2, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testPredictionShouldBeZeroWhenGlucoseBelowMealBolusRecommendationLimit() { let recommendation = SimpleBolusCalculator.recommendedInsulin( - mealCarbs: HKQuantity(unit: .gram(), doubleValue: 20), - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 54), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 4), + mealCarbs: LoopQuantity(unit: .gram, doubleValue: 20), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 54), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 4), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(0, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(0, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } func testPredictionShouldBeZeroWhenGlucoseBelowBolusRecommendationLimit() { let recommendation = SimpleBolusCalculator.recommendedInsulin( mealCarbs: nil, - manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 69), - activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 4), + manualGlucose: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 69), + activeInsulin: LoopQuantity(unit: .internationalUnit, doubleValue: 4), carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule) - XCTAssertEqual(0, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + XCTAssertEqual(0, recommendation.doubleValue(for: .internationalUnit), accuracy: 0.01) } } diff --git a/LoopTests/Models/WatchHistoricalGlucoseTest.swift b/LoopTests/Models/WatchHistoricalGlucoseTest.swift index 4478af76f7..6d0f85be19 100644 --- a/LoopTests/Models/WatchHistoricalGlucoseTest.swift +++ b/LoopTests/Models/WatchHistoricalGlucoseTest.swift @@ -8,6 +8,7 @@ import XCTest import HealthKit +import LoopAlgorithm import LoopKit @testable import Loop @@ -20,7 +21,7 @@ class WatchHistoricalGlucoseTests: XCTestCase { syncIdentifier: UUID().uuidString, syncVersion: 4, startDate: Date(timeIntervalSinceReferenceDate: .hours(100)), - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.45), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.45), condition: nil, trend: nil, trendRate: nil, @@ -33,10 +34,10 @@ class WatchHistoricalGlucoseTests: XCTestCase { syncIdentifier: UUID().uuidString, syncVersion: 2, startDate: Date(timeIntervalSinceReferenceDate: .hours(99)), - quantity: HKQuantity(unit: .millimolesPerLiter, doubleValue: 7.2), + quantity: LoopQuantity(unit: .millimolesPerLiter, doubleValue: 7.2), condition: nil, trend: .up, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 1.0), isDisplayOnly: true, wasUserEntered: false, device: device, @@ -46,10 +47,10 @@ class WatchHistoricalGlucoseTests: XCTestCase { syncIdentifier: nil, syncVersion: nil, startDate: Date(timeIntervalSinceReferenceDate: .hours(98)), - quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 187.65), + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 187.65), condition: .aboveRange, trend: .downDownDown, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -4.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -4.0), isDisplayOnly: false, wasUserEntered: false, device: nil, diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 275b4c3743..d3a0dbb7d0 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -24,25 +24,25 @@ class BolusEntryViewModelTests: XCTestCase { static let exampleStartDate = now - .hours(2) static let exampleEndDate = now - .hours(1) static fileprivate let exampleGlucoseValue = SimpleGlucoseValue(startDate: exampleStartDate, quantity: exampleManualGlucoseQuantity) - static let exampleManualGlucoseQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.4) + static let exampleManualGlucoseQuantity = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.4) static let exampleManualGlucoseSample = HKQuantitySample(type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, - quantity: exampleManualGlucoseQuantity, + quantity: exampleManualGlucoseQuantity.hkQuantity, start: exampleStartDate, end: exampleEndDate) static let exampleManualStoredGlucoseSample = StoredGlucoseSample(sample: exampleManualGlucoseSample) - static let exampleCGMGlucoseQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100.4) + static let exampleCGMGlucoseQuantity = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 100.4) static let exampleCGMGlucoseSample = - HKQuantitySample(type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, - quantity: exampleCGMGlucoseQuantity, + HKQuantitySample(type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, + quantity: exampleCGMGlucoseQuantity.hkQuantity, start: exampleStartDate, end: exampleEndDate) - static let exampleCarbQuantity = HKQuantity(unit: .gram(), doubleValue: 234.5) + static let exampleCarbQuantity = LoopQuantity(unit: .gram, doubleValue: 234.5) - static let exampleBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: 1.0) - static let noBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) + static let exampleBolusQuantity = LoopQuantity(unit: .internationalUnit, doubleValue: 1.0) + static let noBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0.0) static let exampleGlucoseRangeSchedule = GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [ RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 100, maxValue: 110)), @@ -115,7 +115,7 @@ class BolusEntryViewModelTests: XCTestCase { selectedCarbAbsorptionTimeEmoji: selectedCarbAbsorptionTimeEmoji) bolusEntryViewModel.authenticationHandler = { _ in return true } - bolusEntryViewModel.maximumBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 10) + bolusEntryViewModel.maximumBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 10) bolusEntryViewModel.deliveryDelegate = mockDeliveryDelegate @@ -134,8 +134,8 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertFalse(bolusEntryViewModel.isManualGlucoseEntryEnabled) XCTAssertNil(bolusEntryViewModel.manualGlucoseQuantity) - XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 0), bolusEntryViewModel.recommendedBolus) - XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 0), bolusEntryViewModel.enteredBolus) + XCTAssertEqual(LoopQuantity(unit: .internationalUnit, doubleValue: 0), bolusEntryViewModel.recommendedBolus) + XCTAssertEqual(LoopQuantity(unit: .internationalUnit, doubleValue: 0), bolusEntryViewModel.enteredBolus) XCTAssertNil(bolusEntryViewModel.activeAlert) XCTAssertNil(bolusEntryViewModel.activeNotice) @@ -184,7 +184,7 @@ class BolusEntryViewModelTests: XCTestCase { func testManualEntryClearsEnteredBolus() throws { bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity - XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 0), bolusEntryViewModel.enteredBolus) + XCTAssertEqual(LoopQuantity(unit: .internationalUnit, doubleValue: 0), bolusEntryViewModel.enteredBolus) } func testUpdatePredictedGlucoseValues() async throws { @@ -275,11 +275,11 @@ class BolusEntryViewModelTests: XCTestCase { delegate.activeInsulin = InsulinValue(startDate: Self.exampleStartDate, value: 1.5) XCTAssertNil(bolusEntryViewModel.activeInsulin) await bolusEntryViewModel.update() - XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 1.5), bolusEntryViewModel.activeInsulin) + XCTAssertEqual(LoopQuantity(unit: .internationalUnit, doubleValue: 1.5), bolusEntryViewModel.activeInsulin) } func testUpdateCarbsOnBoard() async throws { - delegate.activeCarbs = 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) @@ -308,7 +308,7 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus XCTAssertNotNil(recommendedBolus) - XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit)) XCTAssertEqual(delegate.originalCarbEntryForBolusRecommendation?.quantity, originalCarbEntry.quantity) XCTAssertEqual(delegate.potentialCarbEntryForBolusRecommendation?.quantity, editedCarbEntry.quantity) @@ -329,7 +329,7 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus XCTAssertNotNil(recommendedBolus) - XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit)) XCTAssertEqual(BolusEntryViewModel.Notice.predictedGlucoseBelowSuspendThreshold(suspendThreshold: Self.exampleCGMGlucoseQuantity), bolusEntryViewModel.activeNotice) } @@ -342,7 +342,7 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus XCTAssertNotNil(recommendedBolus) - XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit)) XCTAssertNil(bolusEntryViewModel.activeNotice) } @@ -354,7 +354,7 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus XCTAssertNotNil(recommendedBolus) - XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit)) XCTAssertNil(bolusEntryViewModel.activeNotice) } @@ -416,7 +416,7 @@ class BolusEntryViewModelTests: XCTestCase { await setUpViewModel(originalCarbEntry: originalCarbEntry, potentialCarbEntry: editedCarbEntry) - let manualGlucoseQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123) + let manualGlucoseQuantity = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 123) bolusEntryViewModel.manualGlucoseQuantity = manualGlucoseQuantity XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) @@ -428,7 +428,7 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) let recommendedBolus = bolusEntryViewModel.recommendedBolus XCTAssertNotNil(recommendedBolus) - XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit)) XCTAssertEqual(delegate.potentialCarbEntryForBolusRecommendation, editedCarbEntry) XCTAssertEqual(delegate.originalCarbEntryForBolusRecommendation, originalCarbEntry) @@ -456,7 +456,7 @@ class BolusEntryViewModelTests: XCTestCase { } func testBolusTooSmall() async throws { - bolusEntryViewModel.enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0.01) + bolusEntryViewModel.enteredBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0.01) let success = await bolusEntryViewModel.saveAndDeliver() XCTAssertEqual(.bolusTooSmall, bolusEntryViewModel.activeAlert) XCTAssertNil(delegate.enactedBolusUnits) @@ -516,7 +516,7 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertTrue(delegate.bolusDosingDecisionsAdded.isEmpty) } - private func saveAndDeliver(_ bolus: HKQuantity, file: StaticString = #file, line: UInt = #line) async throws { + private func saveAndDeliver(_ bolus: LoopQuantity, file: StaticString = #file, line: UInt = #line) async throws { bolusEntryViewModel.enteredBolus = bolus self.saveAndDeliverSuccess = await bolusEntryViewModel.saveAndDeliver() @@ -830,7 +830,7 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { glucoseTargetRangeSchedule: BolusEntryViewModelTests.exampleGlucoseRangeSchedule, maximumBasalRatePerHour: 3.0, maximumBolus: 10.0, - suspendThreshold: GlucoseThreshold(unit: .internationalUnit(), value: 75)) + suspendThreshold: GlucoseThreshold(unit: .internationalUnit, value: 75)) { didSet { NotificationCenter.default.post(name: .LoopDataUpdated, object: nil, userInfo: [ @@ -970,7 +970,7 @@ fileprivate struct MockInsulinModel: InsulinModel { } fileprivate struct MockGlucoseValue: GlucoseValue { - var quantity: HKQuantity + var quantity: LoopQuantity var startDate: Date } @@ -1025,7 +1025,7 @@ extension LoopAlgorithmEffects { extension NewCarbEntry { static func mock(_ grams: Double, at date: Date) -> NewCarbEntry { NewCarbEntry( - quantity: .init(unit: .gram(), doubleValue: grams), + quantity: .init(unit: .gram, doubleValue: grams), startDate: date, foodType: nil, absorptionTime: nil @@ -1035,7 +1035,7 @@ extension NewCarbEntry { extension StoredCarbEntry { static func mock(_ grams: Double, at date: Date) -> StoredCarbEntry { - StoredCarbEntry(startDate: date, quantity: .init(unit: .gram(), doubleValue: grams)) + StoredCarbEntry(startDate: date, quantity: .init(unit: .gram, doubleValue: grams)) } } diff --git a/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift b/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift index 9728578c0c..8f9bd9d3ac 100644 --- a/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift +++ b/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift @@ -7,7 +7,7 @@ // import XCTest -import HealthKit +import LoopAlgorithm import LoopKit @testable import LoopUI @@ -38,7 +38,7 @@ class CGMStatusHUDViewModelTests: XCTestCase { func testSetGlucoseQuantityCGM() { let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() @@ -56,13 +56,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { XCTAssertEqual(viewModel.trend, .down) XCTAssertEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) XCTAssertEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) - XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) + XCTAssertEqual(viewModel.unitsString, LoopUnit.milligramsPerDeciliter.localizedShortUnitString) } func testSetGlucoseQuantityCGMStale() { let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() @@ -82,13 +82,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { XCTAssertEqual(viewModel.glucoseTrendTintColor, .glucoseTintColor) XCTAssertNotEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) XCTAssertEqual(viewModel.glucoseValueTintColor, .label) - XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) + XCTAssertEqual(viewModel.unitsString, LoopUnit.milligramsPerDeciliter.localizedShortUnitString) } func testSetGlucoseQuantityManualGlucose() { let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() @@ -107,13 +107,13 @@ class CGMStatusHUDViewModelTests: XCTestCase { XCTAssertNotEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) XCTAssertEqual(viewModel.glucoseTrendTintColor, .glucoseTintColor) XCTAssertEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) - XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) + XCTAssertEqual(viewModel.unitsString, LoopUnit.milligramsPerDeciliter.localizedShortUnitString) } func testSetGlucoseQuantityCalibrationDoesNotShow() { let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() @@ -130,7 +130,7 @@ class CGMStatusHUDViewModelTests: XCTestCase { XCTAssertEqual(viewModel.trend, .down) XCTAssertEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) XCTAssertEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) - XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) + XCTAssertEqual(viewModel.unitsString, LoopUnit.milligramsPerDeciliter.localizedShortUnitString) } func testSetManualGlucoseIconOverride() { @@ -151,7 +151,7 @@ class CGMStatusHUDViewModelTests: XCTestCase { // when there is a manual glucose override icon, the status highlight isn't returned to be presented let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), isLocal: true, glucoseRangeCategory: .urgentLow) let glucoseStartDate = Date() @@ -182,7 +182,7 @@ class CGMStatusHUDViewModelTests: XCTestCase { // add manual glucose let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, trendType: .down, - trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), isLocal: true, glucoseRangeCategory: .urgentLow) viewModel.setGlucoseQuantity(90, @@ -294,7 +294,7 @@ extension CGMStatusHUDViewModelTests { var trendType: GlucoseTrend? - var trendRate: HKQuantity? + var trendRate: LoopQuantity? var isLocal: Bool diff --git a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift index d21c3f9e43..4eaa0fbb3b 100644 --- a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift +++ b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift @@ -6,7 +6,6 @@ // Copyright © 2021 LoopKit Authors. All rights reserved. // -import HealthKit import LoopCore import LoopKit import XCTest @@ -23,9 +22,9 @@ class ManualEntryDoseViewModelTests: XCTestCase { var manualEntryDoseViewModel: ManualEntryDoseViewModel! - static let exampleBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: 1.0) + static let exampleBolusQuantity = LoopQuantity(unit: .internationalUnit, doubleValue: 1.0) - static let noBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) + static let noBolus = LoopQuantity(unit: .internationalUnit, doubleValue: 0.0) fileprivate var delegate: MockManualEntryDoseViewModelDelegate! @@ -53,7 +52,7 @@ class ManualEntryDoseViewModelTests: XCTestCase { try await manualEntryDoseViewModel.saveManualDose() - XCTAssertEqual(delegate.manualEntryBolusUnits, Self.exampleBolusQuantity.doubleValue(for: .internationalUnit())) + XCTAssertEqual(delegate.manualEntryBolusUnits, Self.exampleBolusQuantity.doubleValue(for: .internationalUnit)) XCTAssertEqual(delegate.manuallyEnteredDoseInsulinType, .novolog) } diff --git a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift index d2425abd0b..c4e17bd1ed 100644 --- a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift +++ b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift @@ -85,7 +85,7 @@ class SimpleBolusViewModelTests: XCTestCase { let _ = await viewModel.saveAndDeliver() - XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram())) + XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram)) XCTAssertEqual(180, addedGlucose.first?.quantity.doubleValue(for: .milligramsPerDeciliter)) XCTAssertEqual(2.5, enactedBolus?.units) @@ -112,7 +112,7 @@ class SimpleBolusViewModelTests: XCTestCase { let _ = await viewModel.saveAndDeliver() - XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram())) + XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram)) XCTAssertEqual(0.1, enactedBolus?.units) @@ -299,7 +299,7 @@ extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { } - func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: LoopQuantity?, manualGlucose: LoopQuantity?) -> BolusDosingDecision? { var decision = BolusDosingDecision(for: .simpleBolus) decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: currentRecommendation, notice: .none), date: date) @@ -312,7 +312,7 @@ extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { return 3.0 } - var suspendThreshold: HKQuantity? { - return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) + var suspendThreshold: LoopQuantity? { + return LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) } } diff --git a/LoopUI/ViewModel/CGMStatusHUDViewModel.swift b/LoopUI/ViewModel/CGMStatusHUDViewModel.swift index 06dc1d456d..2d6f04bec7 100644 --- a/LoopUI/ViewModel/CGMStatusHUDViewModel.swift +++ b/LoopUI/ViewModel/CGMStatusHUDViewModel.swift @@ -6,7 +6,7 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // -import HealthKit +import LoopAlgorithm import LoopKit public class CGMStatusHUDViewModel { @@ -73,7 +73,7 @@ public class CGMStatusHUDViewModel { func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, - unit: HKUnit, + unit: LoopUnit, glucoseDisplay: GlucoseDisplayable?, wasUserEntered: Bool, isDisplayOnly: Bool, diff --git a/LoopUI/Views/CGMStatusHUDView.swift b/LoopUI/Views/CGMStatusHUDView.swift index 5a40459c3c..2f53c0f89d 100644 --- a/LoopUI/Views/CGMStatusHUDView.swift +++ b/LoopUI/Views/CGMStatusHUDView.swift @@ -7,7 +7,7 @@ // import UIKit -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI @@ -116,7 +116,7 @@ public final class CGMStatusHUDView: DeviceStatusHUDView, NibLoadable { public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, - unit: HKUnit, + unit: LoopUnit, glucoseDisplay: GlucoseDisplayable?, wasUserEntered: Bool, isDisplayOnly: Bool, diff --git a/LoopUI/Views/DeviceStatusHUDView.swift b/LoopUI/Views/DeviceStatusHUDView.swift index 3951aca3ec..32d7aea767 100644 --- a/LoopUI/Views/DeviceStatusHUDView.swift +++ b/LoopUI/Views/DeviceStatusHUDView.swift @@ -7,7 +7,6 @@ // import UIKit -import HealthKit import LoopKit import LoopKitUI diff --git a/LoopUI/Views/GlucoseHUDView.swift b/LoopUI/Views/GlucoseHUDView.swift index 8201e82a51..e54bdf2db1 100644 --- a/LoopUI/Views/GlucoseHUDView.swift +++ b/LoopUI/Views/GlucoseHUDView.swift @@ -7,7 +7,7 @@ // import UIKit -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI @@ -127,7 +127,7 @@ public final class GlucoseHUDView: BaseHUDView { } } - public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: HKUnit, staleGlucoseAge: TimeInterval, sensor: GlucoseDisplayable?) { + public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: LoopUnit, staleGlucoseAge: TimeInterval, sensor: GlucoseDisplayable?) { var accessibilityStrings = [String]() let time = timeFormatter.string(from: glucoseStartDate) diff --git a/LoopUI/Views/GlucoseTrendHUDView.swift b/LoopUI/Views/GlucoseTrendHUDView.swift index c31dd812da..332e3b545f 100644 --- a/LoopUI/Views/GlucoseTrendHUDView.swift +++ b/LoopUI/Views/GlucoseTrendHUDView.swift @@ -7,7 +7,6 @@ // import UIKit -import HealthKit import LoopKit import LoopKitUI diff --git a/LoopUI/Views/GlucoseValueHUDView.swift b/LoopUI/Views/GlucoseValueHUDView.swift index 4a0858e746..1fe64e0b60 100644 --- a/LoopUI/Views/GlucoseValueHUDView.swift +++ b/LoopUI/Views/GlucoseValueHUDView.swift @@ -7,7 +7,6 @@ // import UIKit -import HealthKit import LoopKit import LoopKitUI diff --git a/LoopUI/Views/PumpStatusHUDView.swift b/LoopUI/Views/PumpStatusHUDView.swift index 754aa6e7fe..2b019e3e5e 100644 --- a/LoopUI/Views/PumpStatusHUDView.swift +++ b/LoopUI/Views/PumpStatusHUDView.swift @@ -7,7 +7,6 @@ // import UIKit -import HealthKit import LoopKit import LoopKitUI diff --git a/WatchApp Extension/Controllers/ActionHUDController.swift b/WatchApp Extension/Controllers/ActionHUDController.swift index bb2df24563..8719415d27 100644 --- a/WatchApp Extension/Controllers/ActionHUDController.swift +++ b/WatchApp Extension/Controllers/ActionHUDController.swift @@ -8,7 +8,7 @@ import WatchKit import WatchConnectivity -import HealthKit +import LoopAlgorithm import LoopKit import LoopCore import SwiftUI @@ -224,7 +224,7 @@ final class ActionHUDController: HUDInterfaceController { } } - private func formattedGlucoseRangeString(from range: ClosedRange) -> String { + private func formattedGlucoseRangeString(from range: ClosedRange) -> String { let unit = loopManager.displayGlucoseUnit glucoseFormatter.updateUnit(to: unit) let rangeDouble = range.doubleRange(for: unit) diff --git a/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift b/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift index 102bc98de8..aeea4066a7 100644 --- a/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift +++ b/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift @@ -8,7 +8,6 @@ import WatchKit import SwiftUI -import HealthKit import LoopCore import LoopKit diff --git a/WatchApp Extension/Controllers/CarbEntryListController.swift b/WatchApp Extension/Controllers/CarbEntryListController.swift index 8a2b74a420..ab02242643 100644 --- a/WatchApp Extension/Controllers/CarbEntryListController.swift +++ b/WatchApp Extension/Controllers/CarbEntryListController.swift @@ -5,7 +5,6 @@ // Copyright © 2019 LoopKit Authors. All rights reserved. // -import HealthKit import LoopCore import LoopKit import os.log @@ -26,7 +25,7 @@ class CarbEntryListController: WKInterfaceController, IdentifiableClass { private lazy var loopManager = ExtensionDelegate.shared().loopManager private lazy var carbFormatter: QuantityFormatter = { - let formatter = QuantityFormatter(for: .gram()) + let formatter = QuantityFormatter(for: .gram) formatter.numberFormatter.numberStyle = .none return formatter }() @@ -105,7 +104,7 @@ extension CarbEntryListController { timeFormatter.dateStyle = .none timeFormatter.timeStyle = .short - let unit = loopManager.carbStore.preferredUnit ?? .gram() + let unit = loopManager.carbStore.preferredUnit ?? .gram for (index, entry) in entries.reversed().enumerated() { guard let row = table.rowController(at: index) as? TextRowController else { @@ -118,6 +117,6 @@ extension CarbEntryListController { row.detailTextLabel.setText(carbFormatter.string(from: entry.quantity)) } - totalLabel.setText(carbFormatter.string(from: HKQuantity(unit: unit, doubleValue: total))) + totalLabel.setText(carbFormatter.string(from: LoopQuantity(unit: unit, doubleValue: total))) } } diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift index d093bca3c9..b9e9292b95 100644 --- a/WatchApp Extension/Controllers/ChartHUDController.swift +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -9,7 +9,6 @@ import WatchKit import WatchConnectivity import LoopKit -import HealthKit import SpriteKit import os.log import LoopCore diff --git a/WatchApp Extension/Controllers/HUDRowController.swift b/WatchApp Extension/Controllers/HUDRowController.swift index d1c8ee5cca..c45dd12fcb 100644 --- a/WatchApp Extension/Controllers/HUDRowController.swift +++ b/WatchApp Extension/Controllers/HUDRowController.swift @@ -5,7 +5,7 @@ // Copyright © 2019 LoopKit Authors. All rights reserved. // -import HealthKit +import LoopAlgorithm import LoopCore import LoopKit import WatchKit @@ -36,14 +36,14 @@ extension HUDRowController { } extension HUDRowController { - func setActiveInsulin(_ activeInsulin: HKQuantity?) { + func setActiveInsulin(_ activeInsulin: LoopQuantity?) { guard let activeInsulin = activeInsulin else { setDetail(nil) return } let insulinFormatter: QuantityFormatter = { - let insulinFormatter = QuantityFormatter(for: .internationalUnit()) + let insulinFormatter = QuantityFormatter(for: .internationalUnit) insulinFormatter.numberFormatter.minimumFractionDigits = 1 insulinFormatter.numberFormatter.maximumFractionDigits = 1 @@ -53,13 +53,13 @@ extension HUDRowController { setDetail(insulinFormatter.string(from: activeInsulin)) } - func setActiveCarbohydrates(_ activeCarbohydrates: HKQuantity?) { + func setActiveCarbohydrates(_ activeCarbohydrates: LoopQuantity?) { guard let activeCarbohydrates = activeCarbohydrates else { setDetail(nil) return } - let carbFormatter = QuantityFormatter(for: .gram()) + let carbFormatter = QuantityFormatter(for: .gram) carbFormatter.numberFormatter.maximumFractionDigits = 0 setDetail(carbFormatter.string(from: activeCarbohydrates)) @@ -85,14 +85,14 @@ extension HUDRowController { setDetail(basalFormatter.string(from: tempBasal, unit: unit)) } - func setReservoirVolume(_ reservoirVolume: HKQuantity?) { + func setReservoirVolume(_ reservoirVolume: LoopQuantity?) { guard let reservoirVolume = reservoirVolume else { setDetail(nil) return } let insulinFormatter: QuantityFormatter = { - let insulinFormatter = QuantityFormatter(for: .internationalUnit()) + let insulinFormatter = QuantityFormatter(for: .internationalUnit) insulinFormatter.unitStyle = .long insulinFormatter.numberFormatter.minimumFractionDigits = 0 insulinFormatter.numberFormatter.maximumFractionDigits = 0 diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 946669adf4..3a150f1e9e 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -9,6 +9,7 @@ import WatchConnectivity import WatchKit import HealthKit +import LoopAlgorithm import Intents import os import os.log @@ -165,11 +166,18 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { if context.displayGlucoseUnit == nil { let type = HKQuantityType.quantityType(forIdentifier: .bloodGlucose)! loopManager.healthStore.preferredUnits(for: [type]) { (units, error) in - context.displayGlucoseUnit = units[type] - - DispatchQueue.main.async { - self.loopManager.updateContext(context) + defer { + DispatchQueue.main.async { + self.loopManager.updateContext(context) + } + } + + guard let unit = units[type] else { + context.displayGlucoseUnit = nil + return } + + context.displayGlucoseUnit = LoopUnit(from: unit) } } else { DispatchQueue.main.async { @@ -262,7 +270,7 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { let mealTime = userInfo[LoopNotificationUserInfoKey.missedMealTime.rawValue] as? Date, let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double { - let missedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), + let missedEntry = NewCarbEntry(quantity: LoopQuantity(unit: .gram, doubleValue: carbAmount), startDate: mealTime, foodType: nil, diff --git a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift index 2518644375..dee7c6c6d8 100644 --- a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift +++ b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift @@ -7,7 +7,6 @@ // import ClockKit -import HealthKit import LoopKit import Foundation import LoopCore @@ -43,12 +42,12 @@ extension CLKComplicationTemplate { static func templateForFamily( _ family: CLKComplicationFamily, - glucose: HKQuantity, - unit: HKUnit, + glucose: LoopQuantity, + unit: LoopUnit, glucoseDate: Date?, trend: GlucoseTrend?, glucoseCondition: GlucoseCondition?, - eventualGlucose: HKQuantity?, + eventualGlucose: LoopQuantity?, at date: Date, loopLastRunDate: Date?, recencyInterval: TimeInterval, diff --git a/WatchApp Extension/Extensions/WatchContext+WatchApp.swift b/WatchApp Extension/Extensions/WatchContext+WatchApp.swift index 6c77afd7c2..41253d309a 100644 --- a/WatchApp Extension/Extensions/WatchContext+WatchApp.swift +++ b/WatchApp Extension/Extensions/WatchContext+WatchApp.swift @@ -7,31 +7,31 @@ // import Foundation -import HealthKit +import LoopAlgorithm import LoopKit extension WatchContext { - var activeInsulin: HKQuantity? { + var activeInsulin: LoopQuantity? { guard let value = iob else { return nil } - return HKQuantity(unit: .internationalUnit(), doubleValue: value) + return LoopQuantity(unit: .internationalUnit, doubleValue: value) } - var activeCarbohydrates: HKQuantity? { + var activeCarbohydrates: LoopQuantity? { guard let value = cob else { return nil } - return HKQuantity(unit: .gram(), doubleValue: value) + return LoopQuantity(unit: .gram, doubleValue: value) } - var reservoirVolume: HKQuantity? { + var reservoirVolume: LoopQuantity? { guard let value = reservoir else { return nil } - return HKQuantity(unit: .internationalUnit(), doubleValue: value) + return LoopQuantity(unit: .internationalUnit, doubleValue: value) } } diff --git a/WatchApp Extension/Managers/ComplicationChartManager.swift b/WatchApp Extension/Managers/ComplicationChartManager.swift index 1d71f66446..17b87c4c36 100644 --- a/WatchApp Extension/Managers/ComplicationChartManager.swift +++ b/WatchApp Extension/Managers/ComplicationChartManager.swift @@ -8,7 +8,6 @@ import Foundation import UIKit -import HealthKit import WatchKit import LoopKit import LoopAlgorithm @@ -46,7 +45,7 @@ final class ComplicationChartManager { private var renderedChartImage: UIImage? private var visibleInterval: TimeInterval = .hours(4) - private var unit: HKUnit { + private var unit: LoopUnit { return data?.unit ?? .milligramsPerDeciliter } diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index b8b2d4a50f..37ccd2a4d4 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -193,7 +193,7 @@ extension LoopDataManager { } extension LoopDataManager { - var displayGlucoseUnit: HKUnit { + var displayGlucoseUnit: LoopUnit { activeContext?.displayGlucoseUnit ?? .milligramsPerDeciliter } } diff --git a/WatchApp Extension/Models/GlucoseChartData.swift b/WatchApp Extension/Models/GlucoseChartData.swift index 4bf9a2b2c8..868b53121a 100644 --- a/WatchApp Extension/Models/GlucoseChartData.swift +++ b/WatchApp Extension/Models/GlucoseChartData.swift @@ -7,13 +7,12 @@ // import Foundation -import HealthKit import LoopKit import LoopAlgorithm struct GlucoseChartData { - var unit: HKUnit? + var unit: LoopUnit? var correctionRange: GlucoseRangeSchedule? @@ -27,7 +26,7 @@ struct GlucoseChartData { } } - private(set) var historicalGlucoseRange: ClosedRange? + private(set) var historicalGlucoseRange: ClosedRange? var predictedGlucose: [SampleValue]? { didSet { @@ -35,9 +34,9 @@ struct GlucoseChartData { } } - private(set) var predictedGlucoseRange: ClosedRange? + private(set) var predictedGlucoseRange: ClosedRange? - init(unit: HKUnit?, correctionRange: GlucoseRangeSchedule?, preMealOverride: TemporaryScheduleOverride?, scheduleOverride: TemporaryScheduleOverride?, historicalGlucose: [SampleValue]?, predictedGlucose: [SampleValue]?) { + init(unit: LoopUnit?, correctionRange: GlucoseRangeSchedule?, preMealOverride: TemporaryScheduleOverride?, scheduleOverride: TemporaryScheduleOverride?, historicalGlucose: [SampleValue]?, predictedGlucose: [SampleValue]?) { self.unit = unit self.correctionRange = correctionRange self.preMealOverride = preMealOverride @@ -48,7 +47,7 @@ struct GlucoseChartData { self.predictedGlucoseRange = predictedGlucose?.quantityRange } - func chartableGlucoseRange(from interval: DateInterval) -> ClosedRange { + func chartableGlucoseRange(from interval: DateInterval) -> ClosedRange { let unit = self.unit ?? .milligramsPerDeciliter // Defaults @@ -85,8 +84,8 @@ struct GlucoseChartData { min = Swift.max(0, min.floored(to: unit.axisIncrement)) max = max.ceiled(to: unit.axisIncrement) - let lowerBound = HKQuantity(unit: unit, doubleValue: min) - let upperBound = HKQuantity(unit: unit, doubleValue: max) + let lowerBound = LoopQuantity(unit: unit, doubleValue: min) + let upperBound = LoopQuantity(unit: unit, doubleValue: max) return lowerBound...upperBound } @@ -106,7 +105,7 @@ struct GlucoseChartData { } } -private extension HKUnit { +private extension LoopUnit { var axisIncrement: Double { return chartableIncrement * 25 } diff --git a/WatchApp Extension/Models/GlucoseChartScaler.swift b/WatchApp Extension/Models/GlucoseChartScaler.swift index 953f5bf1ea..979eeb5e74 100644 --- a/WatchApp Extension/Models/GlucoseChartScaler.swift +++ b/WatchApp Extension/Models/GlucoseChartScaler.swift @@ -8,7 +8,6 @@ import Foundation import CoreGraphics -import HealthKit import LoopKit import WatchKit import LoopAlgorithm @@ -49,14 +48,14 @@ struct GlucoseChartScaler { return CGPoint(x: xCoordinate(for: date), y: yCoordinate(for: glucose)) } - func point(for glucose: SampleValue, unit: HKUnit) -> CGPoint { + func point(for glucose: SampleValue, unit: LoopUnit) -> CGPoint { return point(glucose.startDate, glucose.quantity.doubleValue(for: unit)) } // By default enforce a minimum height so that the range is visible func rect( for range: GlucoseChartValueHashable, - unit: HKUnit, + unit: LoopUnit, minHeight: CGFloat = 2, alignedToScreenScale screenScale: CGFloat = WKInterfaceDevice.current().screenScale ) -> CGRect { @@ -80,7 +79,7 @@ struct GlucoseChartScaler { } extension GlucoseChartScaler { - init(size: CGSize, dateInterval: DateInterval, glucoseRange: ClosedRange, unit: HKUnit, coordinateSystem: CoordinateSystem = .standard) { + init(size: CGSize, dateInterval: DateInterval, glucoseRange: ClosedRange, unit: LoopUnit, coordinateSystem: CoordinateSystem = .standard) { self.dates = dateInterval self.glucoseMin = glucoseRange.lowerBound.doubleValue(for: unit) self.glucoseMax = glucoseRange.upperBound.doubleValue(for: unit) @@ -90,8 +89,8 @@ extension GlucoseChartScaler { } } -extension ClosedRange where Bound == HKQuantity { - fileprivate func span(with unit: HKUnit) -> Double { +extension ClosedRange where Bound == LoopQuantity { + fileprivate func span(with unit: LoopUnit) -> Double { return upperBound.doubleValue(for: unit) - lowerBound.doubleValue(for: unit) } } diff --git a/WatchApp Extension/Scenes/GlucoseChartScene.swift b/WatchApp Extension/Scenes/GlucoseChartScene.swift index 8f69ff5e1d..010022908c 100644 --- a/WatchApp Extension/Scenes/GlucoseChartScene.swift +++ b/WatchApp Extension/Scenes/GlucoseChartScene.swift @@ -8,7 +8,6 @@ import Foundation import SpriteKit -import HealthKit import LoopKit import WatchKit import os.log diff --git a/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift index 5060e5d372..1809d72ca0 100644 --- a/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift +++ b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift @@ -6,15 +6,14 @@ // import LoopKit -import HealthKit import LoopAlgorithm protocol GlucoseChartValueHashable { var start: Date { get } var end: Date { get } - var min: HKQuantity { get } - var max: HKQuantity { get } + var min: LoopQuantity { get } + var max: LoopQuantity { get } var chartHashValue: Int { get } } @@ -59,7 +58,7 @@ extension SampleValue { } -extension AbsoluteScheduleValue: GlucoseChartValueHashable where T == ClosedRange { +extension AbsoluteScheduleValue: GlucoseChartValueHashable where T == ClosedRange { var start: Date { return startDate } @@ -68,11 +67,11 @@ extension AbsoluteScheduleValue: GlucoseChartValueHashable where T == ClosedRang return endDate } - var min: HKQuantity { + var min: LoopQuantity { return value.lowerBound } - var max: HKQuantity { + var max: LoopQuantity { return value.upperBound } } @@ -95,11 +94,11 @@ struct TemporaryScheduleOverrideHashable: GlucoseChartValueHashable { return override.activeInterval.end } - var min: HKQuantity { + var min: LoopQuantity { return override.settings.targetRange!.lowerBound } - var max: HKQuantity { + var max: LoopQuantity { return override.settings.targetRange!.upperBound } } diff --git a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift index 8241fab62a..d264f04992 100644 --- a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift +++ b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift @@ -8,7 +8,7 @@ import Foundation import Combine -import HealthKit +import LoopAlgorithm import WatchKit import WatchConnectivity import LoopKit @@ -115,13 +115,13 @@ final class CarbAndBolusFlowViewModel: ObservableObject { func recommendBolus(forGrams grams: Int, eatenAt carbEntryDate: Date, absorptionTime carbAbsorptionTime: CarbAbsorptionTime, lastEntryDate: Date) { let entry = NewCarbEntry( date: lastEntryDate, - quantity: HKQuantity(unit: .gram(), doubleValue: Double(grams)), + quantity: LoopQuantity(unit: .gram, doubleValue: Double(grams)), startDate: carbEntryDate, foodType: nil, absorptionTime: absorptionTime(for: carbAbsorptionTime) ) - guard entry.quantity.doubleValue(for: .gram()) > 0 else { + guard entry.quantity.doubleValue(for: .gram) > 0 else { return } diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/BolusInput.swift b/WatchApp Extension/Views/Carb Entry & Bolus/BolusInput.swift index 4c01e5c4be..c87048f0cf 100644 --- a/WatchApp Extension/Views/Carb Entry & Bolus/BolusInput.swift +++ b/WatchApp Extension/Views/Carb Entry & Bolus/BolusInput.swift @@ -26,12 +26,12 @@ struct BolusInput: View { } private static let amountFormatter: NumberFormatter = { - let formatter = QuantityFormatter(for: .internationalUnit()) + let formatter = QuantityFormatter(for: .internationalUnit) return formatter.numberFormatter }() private static let recommendedAmountFormatter: NumberFormatter = { - let formatter = QuantityFormatter(for: .internationalUnit()) + let formatter = QuantityFormatter(for: .internationalUnit) return formatter.numberFormatter }() diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift index 0c27958fe9..23761a82bf 100644 --- a/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift +++ b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift @@ -7,7 +7,6 @@ // import SwiftUI -import HealthKit import LoopKit @@ -62,7 +61,7 @@ struct CarbAndBolusFlow: View { if let entry = entry { _carbEntryDate = State(initialValue: entry.startDate) - let initialCarbAmount = entry.quantity.doubleValue(for: .gram()) + let initialCarbAmount = entry.quantity.doubleValue(for: .gram) _carbAmount = State(initialValue: Int(initialCarbAmount)) } case .manualBolus: From d67f3acac143f77b129126b6691b999178a02e88 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 6 Dec 2024 10:49:04 -0800 Subject: [PATCH 187/421] Loop 2.0 - Presets (#720) --- Loop.xcodeproj/project.pbxproj | 106 +++++++ .../Presets Icon.imageset/Contents.json | 23 ++ .../Presets Icon.imageset/Presets Icon.png | Bin 0 -> 723 bytes .../Presets Icon.imageset/Presets Icon@2x.png | Bin 0 -> 1300 bytes .../Presets Icon.imageset/Presets Icon@3x.png | Bin 0 -> 1807 bytes .../presets.colorset/Contents.json | 38 +++ Loop/Extensions/Image+Exists.swift | 21 ++ .../StatusTableViewController.swift | 1 + .../PresetsTrainingViewModel.swift | 84 ++++++ Loop/View Models/PresetsViewModel.swift | 244 ++++++++++++++++ Loop/View Models/SettingsViewModel.swift | 31 +- .../Favorite Foods/FavoriteFoodsView.swift | 2 +- .../Components/AdjustedGlucoseRangeView.swift | 46 +++ .../Views/Presets/Components/ImpactView.swift | 32 ++ .../Components/PercentPickerView.swift | 114 ++++++++ .../Views/Presets/Components/PresetCard.swift | 273 ++++++++++++++++++ .../Components/PresetsTrainingCard.swift | 36 +++ .../PresetsTrainingContentContainerView.swift | 105 +++++++ .../TherapySettingsExampleView.swift | 62 ++++ Loop/Views/Presets/PresetsTrainingView.swift | 38 +++ Loop/Views/Presets/PresetsView.swift | 243 ++++++++++++++++ .../CreatingYourOwnPresetsContentView.swift | 64 ++++ .../HowTheyWorkContentView.swift | 118 ++++++++ .../PresetsAndExerciseContentView.swift | 153 ++++++++++ .../PresetsAndIllnessContentView.swift | 139 +++++++++ Loop/Views/SettingsView.swift | 72 +++-- LoopUI/Extensions/Color.swift | 12 +- LoopUI/Extensions/UIColor.swift | 2 + 28 files changed, 2031 insertions(+), 28 deletions(-) create mode 100644 Loop/DefaultAssets.xcassets/Presets Icon.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon.png create mode 100644 Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon@2x.png create mode 100644 Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon@3x.png create mode 100644 Loop/DerivedAssetsBase.xcassets/presets.colorset/Contents.json create mode 100644 Loop/Extensions/Image+Exists.swift create mode 100644 Loop/View Models/PresetsTrainingViewModel.swift create mode 100644 Loop/View Models/PresetsViewModel.swift create mode 100644 Loop/Views/Presets/Components/AdjustedGlucoseRangeView.swift create mode 100644 Loop/Views/Presets/Components/ImpactView.swift create mode 100644 Loop/Views/Presets/Components/PercentPickerView.swift create mode 100644 Loop/Views/Presets/Components/PresetCard.swift create mode 100644 Loop/Views/Presets/Components/PresetsTrainingCard.swift create mode 100644 Loop/Views/Presets/Components/PresetsTrainingContentContainerView.swift create mode 100644 Loop/Views/Presets/Components/TherapySettingsExampleView.swift create mode 100644 Loop/Views/Presets/PresetsTrainingView.swift create mode 100644 Loop/Views/Presets/PresetsView.swift create mode 100644 Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift create mode 100644 Loop/Views/Presets/Training Content/HowTheyWorkContentView.swift create mode 100644 Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift create mode 100644 Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 495f9140b2..1b88d07dfe 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -251,7 +251,23 @@ 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */; }; 84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */; }; 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E62A4A4DEF000B658B /* PumpView.swift */; }; + 84C170ED2CCA362A0098E52F /* ImpactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EC2CCA361F0098E52F /* ImpactView.swift */; }; + 84C170EF2CCA37680098E52F /* PresetCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EE2CCA37680098E52F /* PresetCard.swift */; }; 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; + 84E8BBAE2CC9791E0078E6CF /* PresetsTrainingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */; }; + 84E8BBB12CC979820078E6CF /* PresetsTrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */; }; + 84E8BBB32CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB22CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift */; }; + 84E8BBB52CC98C3F0078E6CF /* PresetsTrainingContentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB42CC98C3F0078E6CF /* PresetsTrainingContentContainerView.swift */; }; + 84E8BBB82CC9924B0078E6CF /* HowTheyWorkContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB72CC9924B0078E6CF /* HowTheyWorkContentView.swift */; }; + 84E8BBBA2CC9925C0078E6CF /* PresetsAndExerciseContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB92CC9925C0078E6CF /* PresetsAndExerciseContentView.swift */; }; + 84E8BBBC2CC992660078E6CF /* PresetsAndIllnessContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBBB2CC992660078E6CF /* PresetsAndIllnessContentView.swift */; }; + 84E8BBC42CC9B9890078E6CF /* AdjustedGlucoseRangeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */; }; + 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */; }; + 84E8BBC82CC9D34B0078E6CF /* TherapySettingsExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */; }; + 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC92CCA16290078E6CF /* PresetsView.swift */; }; + 84E8BBCC2CCA16B30078E6CF /* PresetsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCB2CCA16B30078E6CF /* PresetsViewModel.swift */; }; + 84E8BBCE2CCA1E070078E6CF /* PresetsTrainingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */; }; + 84E8BBD02CCA279B0078E6CF /* Image+Exists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCF2CCA27960078E6CF /* Image+Exists.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 */; }; @@ -1111,7 +1127,23 @@ 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelineProvider.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 = ""; }; + 84C170EC2CCA361F0098E52F /* ImpactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImpactView.swift; sourceTree = ""; }; + 84C170EE2CCA37680098E52F /* PresetCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetCard.swift; sourceTree = ""; }; 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.swift; sourceTree = ""; }; + 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingViewModel.swift; sourceTree = ""; }; + 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingView.swift; sourceTree = ""; }; + 84E8BBB22CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatingYourOwnPresetsContentView.swift; sourceTree = ""; }; + 84E8BBB42CC98C3F0078E6CF /* PresetsTrainingContentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingContentContainerView.swift; sourceTree = ""; }; + 84E8BBB72CC9924B0078E6CF /* HowTheyWorkContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowTheyWorkContentView.swift; sourceTree = ""; }; + 84E8BBB92CC9925C0078E6CF /* PresetsAndExerciseContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsAndExerciseContentView.swift; sourceTree = ""; }; + 84E8BBBB2CC992660078E6CF /* PresetsAndIllnessContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsAndIllnessContentView.swift; sourceTree = ""; }; + 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustedGlucoseRangeView.swift; sourceTree = ""; }; + 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PercentPickerView.swift; sourceTree = ""; }; + 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySettingsExampleView.swift; sourceTree = ""; }; + 84E8BBC92CCA16290078E6CF /* PresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsView.swift; sourceTree = ""; }; + 84E8BBCB2CCA16B30078E6CF /* PresetsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsViewModel.swift; sourceTree = ""; }; + 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingCard.swift; sourceTree = ""; }; + 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Exists.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; }; @@ -2082,6 +2114,7 @@ 43E344A01B9E144300C85C07 /* Extensions */ = { isa = PBXGroup; children = ( + 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */, A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */, C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */, C1F2CAAB2B7A980600D7F581 /* BasalRelativeDose.swift */, @@ -2169,6 +2202,7 @@ 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */, 438D42FA1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift */, 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, + 84E8BBAF2CC979300078E6CF /* Presets */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */, C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */, @@ -2446,6 +2480,42 @@ path = Widgets; sourceTree = ""; }; + 84E8BBAF2CC979300078E6CF /* Presets */ = { + isa = PBXGroup; + children = ( + 84E8BBC92CCA16290078E6CF /* PresetsView.swift */, + 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */, + 84E8BBC22CC9B9780078E6CF /* Components */, + 84E8BBB62CC990480078E6CF /* Training Content */, + ); + path = Presets; + sourceTree = ""; + }; + 84E8BBB62CC990480078E6CF /* Training Content */ = { + isa = PBXGroup; + children = ( + 84E8BBB22CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift */, + 84E8BBB72CC9924B0078E6CF /* HowTheyWorkContentView.swift */, + 84E8BBB92CC9925C0078E6CF /* PresetsAndExerciseContentView.swift */, + 84E8BBBB2CC992660078E6CF /* PresetsAndIllnessContentView.swift */, + ); + path = "Training Content"; + sourceTree = ""; + }; + 84E8BBC22CC9B9780078E6CF /* Components */ = { + isa = PBXGroup; + children = ( + 84C170EC2CCA361F0098E52F /* ImpactView.swift */, + 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */, + 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */, + 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */, + 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */, + 84E8BBB42CC98C3F0078E6CF /* PresetsTrainingContentContainerView.swift */, + 84C170EE2CCA37680098E52F /* PresetCard.swift */, + ); + path = Components; + sourceTree = ""; + }; 891B508324342BCA005DA578 /* View Models */ = { isa = PBXGroup; children = ( @@ -2519,6 +2589,8 @@ 1D49795724E7289700948F05 /* ServicesViewModel.swift */, C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */, 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */, + 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */, + 84E8BBCB2CCA16B30078E6CF /* PresetsViewModel.swift */, ); path = "View Models"; sourceTree = ""; @@ -3411,6 +3483,7 @@ 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */, 14C9706E2C5A83AF00E8A01B /* IOBChartView.swift in Sources */, E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */, + 84E8BBCE2CCA1E070078E6CF /* PresetsTrainingCard.swift in Sources */, B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */, E98A55ED24EDD6380008715D /* LatestStoredSettingsProvider.swift in Sources */, C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */, @@ -3428,6 +3501,7 @@ 1452F4AD2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift in Sources */, C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */, 14C970822C5C2EC100E8A01B /* FavoriteFoodInsightsView.swift in Sources */, + 84E8BBB12CC979820078E6CF /* PresetsTrainingView.swift in Sources */, 1D080CBD2473214A00356610 /* AlertStore.xcdatamodeld in Sources */, C11BD0552523CFED00236B08 /* SimpleBolusViewModel.swift in Sources */, C1F2CAAA2B76B3EE00D7F581 /* TempBasalRecommendation.swift in Sources */, @@ -3438,6 +3512,7 @@ 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */, + 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, @@ -3450,8 +3525,10 @@ 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */, A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, + 84E8BBBA2CC9925C0078E6CF /* PresetsAndExerciseContentView.swift in Sources */, C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */, 1452F4AB2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift in Sources */, + 84E8BBAE2CC9791E0078E6CF /* PresetsTrainingViewModel.swift in Sources */, C1742332259BEADC00399C9D /* ManualEntryDoseView.swift in Sources */, 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */, 43FCEEA9221A615B0013DD30 /* StatusChartsManager.swift in Sources */, @@ -3460,6 +3537,7 @@ 43E3449F1B9D68E900C85C07 /* StatusTableViewController.swift in Sources */, B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */, A96DAC242838325900D94E38 /* DiagnosticLog.swift in Sources */, + 84C170ED2CCA362A0098E52F /* ImpactView.swift in Sources */, A9CBE45A248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift in Sources */, A9C62D8A2331703100535612 /* ServicesManager.swift in Sources */, 43DBF0531C93EC8200B3C386 /* DeviceDataManager.swift in Sources */, @@ -3468,6 +3546,7 @@ C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */, 4372E487213C86240068E043 /* SampleValue.swift in Sources */, 14C970682C5991CD00E8A01B /* LoopChartView.swift in Sources */, + 84E8BBB52CC98C3F0078E6CF /* PresetsTrainingContentContainerView.swift in Sources */, 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */, C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */, 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */, @@ -3479,6 +3558,7 @@ C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, A977A2F424ACFECF0059C207 /* CriticalEventLogExportManager.swift in Sources */, 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, + 84E8BBCC2CCA16B30078E6CF /* PresetsViewModel.swift in Sources */, 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */, 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */, B455C7332BD14E25002B847E /* Comparable.swift in Sources */, @@ -3495,6 +3575,7 @@ E98A55EF24EDD6E60008715D /* DosingDecisionStoreProtocol.swift in Sources */, B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */, E9C00EF524C623EF00628F35 /* LoopSettings+Loop.swift in Sources */, + 84E8BBD02CCA279B0078E6CF /* Image+Exists.swift in Sources */, 4389916B1E91B689000EEF90 /* ChartSettings+Loop.swift in Sources */, C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, C1F2CAAC2B7A980600D7F581 /* BasalRelativeDose.swift in Sources */, @@ -3503,6 +3584,7 @@ DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, A9347F3124E7521800C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */, A9CBE458248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift in Sources */, + 84E8BBBC2CC992660078E6CF /* PresetsAndIllnessContentView.swift in Sources */, 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */, 4374B5EF209D84BF00D17AA8 /* OSLog.swift in Sources */, 1DB619AC270BAD3D006C9D07 /* VersionUpdateViewModel.swift in Sources */, @@ -3535,6 +3617,7 @@ C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */, B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */, 14C970842C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift in Sources */, + 84E8BBB32CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift in Sources */, 84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */, 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */, @@ -3571,6 +3654,7 @@ 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */, 14ED83F62C6421F9008B4A5C /* FavoriteFoodInsightsCardView.swift in Sources */, C174233C259BEB0F00399C9D /* ManualEntryDoseViewModel.swift in Sources */, + 84E8BBC82CC9D34B0078E6CF /* TherapySettingsExampleView.swift in Sources */, 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */, C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */, @@ -3585,6 +3669,7 @@ 892A5D59222F0A27008961AB /* Debug.swift in Sources */, 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */, 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, + 84E8BBB82CC9924B0078E6CF /* HowTheyWorkContentView.swift in Sources */, 14C9706A2C5A833100E8A01B /* CarbEffectChartView.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */, @@ -3593,10 +3678,13 @@ 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */, C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */, A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */, + 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */, 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, 8968B1122408B3520074BB48 /* UIFont.swift in Sources */, + 84E8BBC42CC9B9890078E6CF /* AdjustedGlucoseRangeView.swift in Sources */, + 84C170EF2CCA37680098E52F /* PresetCard.swift in Sources */, 1452F4A92A851C9400F8B9E4 /* FavoriteFoodAddEditViewModel.swift in Sources */, 438D42FB1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift in Sources */, 89A1B66E24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */, @@ -4483,6 +4571,7 @@ INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4531,6 +4620,7 @@ INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4788,6 +4878,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4816,6 +4907,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4991,6 +5083,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5012,6 +5105,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5030,6 +5124,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5048,6 +5143,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5069,6 +5165,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5089,6 +5186,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5224,6 +5322,7 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5317,6 +5416,7 @@ INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5348,6 +5448,7 @@ ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5374,6 +5475,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5419,6 +5521,7 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5437,6 +5540,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5459,6 +5563,7 @@ ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5486,6 +5591,7 @@ ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Contents.json new file mode 100644 index 0000000000..3df3c9d7e4 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Presets Icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Presets Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Presets Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon.png b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..01239355f9e3c78b35377912c6012258a1908652 GIT binary patch literal 723 zcmV;^0xbQBP)p#m?z{qjjE7llZ!KfW7*MQ|SD#zC};3i^EM z&bWw$!p?PIN0_mowS(w0O~pLzNJ0B1Xq`b#go)9s@X{+1qalHZU;%7?z1RXp-9jru zk3T#p^#*591CfdVn}drhqlYa7jzvsNXIWzq4cXYw_XvY=(CR@=jIu0y9~ypN2={Pw%&jd>E&_2~w|2o)`^LcP)%Ipa7iqI8^&UyLaaq7;!8; zE>}9%2ymZ^YecX#=;B(lWv~is$%2|s-%U}p_l78eA(Z*ynkRrpk8eYwPkgIc)(Fpg zI2Dxx#;KaLlb{{vSy;`o+hBd4d+WWbVlpfE2e{#>?+B-dh_7`4rj&jv?sZtq(lmqr zFLQ617Y^OKl{+7aJVK0bcIwY!l?CNZuG~%rwwg3+lPk}59UG#I1l?Ehs}i&1g_3XL znwf;9|9pWEQt(_{3sLkVi=ncniS+Dm@^*s*TlY0k0SO{W_#KmZmVJp%>90Qdp_m0v zt}>O|d6O%z%&`qIno5%^IQW5o=<p002ovPDHLk FV1lGRLvsKC literal 0 HcmV?d00001 diff --git a/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon@2x.png b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..5ef0902b53d997ad692b2a250d9d99487c473f45 GIT binary patch literal 1300 zcmV+v1?&2WP)^Xb)P_r+@Qz!5$Ici zQ~_H%mx{a787fYj#!@75DX-=I9Sq1IVd?YEa%R3A0eBXIum-~Z%i4A2x?zYKim)cX zGe}7g1fy5qe|-TCQ>s9jFu|J+5w-Yb?e?oM1~k3N)4c&WNNEDqfB5wPG#$ZQVLo^( z@skM!r%De$>;D#lLs(xy_0#cV-e7))++bWa)15eK zCg1?}SCCPCCnX)C*ziUKqv?=>vq^5t`MH(k%gGV{oj@J00~1V0>Qow zfie(B{4<6-!91^OeI{X!BF85lo?Fp++RL6|_5Qep3flx!a4*UVQA_*T-K}gyj8mOPNz}Zsnn`aTpLcmr z>wpEcNx$GX7{eW7HBn4WD#dB>5`n|s1#twc0wq#E8F~VRz*s}t1xK)6i%p`vGc?eH zT(CH1+DsfNRE1L79e+f-T{0BG3gL!;7jMOh!68cRDAxMJFi=64|IERH%MxC3$08~? z$Xa^RnNSckIF)D+;^#p0C$F$r@v32AM&R*yJRVOT*76jy$)`WQXlSkX5!9FRnm<5d zz+9H)(%u_^*lXJ-;zBspsJ^l4R5WKQo|dkN^ zaRj2)wW$x$Tm0L4C2MgPt74!OTx5{0Th&C)qNU9R;MUG}$+h z+BDD|W$m!RMna)tdvye!Ik2+|I&Xu0(_sVQ##x>9jw^?pqJILeFgqD*2QEEUx?Iy` z3h9x!E6yg0+b8EkT3DDa32z~jr@KwaiMyqSrO;PeMNj}|1(Nb0zX3Ue`2JT>u^y50 z?T-5Kc<>Ql?RE@3W0$uD1#!OJF=_9$R$fVLMA{HXa9$umTvs^`!$I?G(RGs3N%cO7 zcCDSWzIQ!oPDyI)((a2(E3b{AB;dMf3i`~YLk}L0$KzQ8Yk7*j?}pC$PjaG+&^)iE z)lnSnIQ#S|RiNeEP{^w7n;tMYMri`cSSHs(-R1A#=9Izjiq=lHDMg^w#d$}fjB2_h zl68R|ou0Qj-9LidZ1Bvg4CkT2f^`suHY{UX*d0YsY0HXB!tGY4W>z^YIvf`ApZAl8 z8ULO$!U7xW#v`p0MP|jbHxCF>bm*PcMX(L#oz(@9re9R5( literal 0 HcmV?d00001 diff --git a/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon@3x.png b/Loop/DefaultAssets.xcassets/Presets Icon.imageset/Presets Icon@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..45d6fff2d24120171a809c8573f596e64d3eb527 GIT binary patch literal 1807 zcmV+q2k`ibP) z6IT$&yWdEuEO14Ipejpo0_=XD4iX}S2ELm7& zh{*=eyw2;9WrT}RV9k4P=1u>qEMym8Y5wZ>)!qLA5Cp*<2qmR6c-#6nI$P189s*DY z$b2z~v3X1g=tv2lm(P9QK8c!VRXXt5Xqm_Lgee?fqF6O8Aqi($|XW-h#Gq89?gn_!<*$`LU0 zob@fxMDOjv>v;$WFM_pqj=N@_adIJbqz4cXUIatHRd9tF8gC6wmLL$mSS8;6!wer; zX8n_|315Iq5cU+%K2!{ikTlcgaQZ+X7i{)Tt6&pSu<`!M;wC@GTr8CxWQWOAUe8Mcv%P)7X_X#00pmG+z7R?>)63y+md~5uK-`j-%@fu+aM}Zl-7K+&iqWQnlg;2#}N>!@e9vpW;sb|-SHnX{e?G)0j zB*0q4X-e4)VT4;TvA<$o-Xr+N2AdZxI-`9J*>31UO(<>m%7oV7^?U<#3#X#)93pdS z@hPs0NP-{;f*=TjAP9m#O22V-1-ln$qIm>-2KvfFT93&EV)Oqik3W|UnnI(Ur#)JRv6@=v0>Mgst@r!56@!HTrM6KwE#+n_E?x^_XtDlv3n&0(awwR3Vn;v`TF6g^f%ZdOl10g!sc zt3(+JD}=J$@*@;TrjHb5(u`gWFy?+|HR|VUd?Px;Kl-WDiq)&XMJ8|61!(?Xjs#v;jX}LXg``oO_nU~qQ@>lrOVMsvxKV{9mcQxA7mP#-d>Yv6(ZfjkKAF09cXv5%| z(g@amZZg6;Z-%>eu?*iQ@I@EoYxM9Y7|&T0fDMCd{6&~n1F($)8wS^S5p3+B=H_<96wOw*Q8xTU z7%6ubkzgsLD7#!Gj(wPD{VS&mf=>_*n^|LRvf>@vKI|O70KzT^B?h~r-u;)D&nJYz zt)>DbnbYYVW%fai?)ECoNb6(N>?#7WeW(gXtGm2Plv_Gg1)>jZ`yhSIRYB8GrJ3r3 zPysOJzG;>8?+v+$8f0iJE!-tDX%@WPICDdQf@lRJbr-BcEk5G|hwvMwst)~`-k(@L zFkbRu9&8vMPrvCZb7{i>T06bfhHbnEmN&zV7dlqq26S7FG`{eb zz_f3}fN`3o);|=k@m_?}-ag)i!fnbi=4G%88wS^Sw|yA(9(1A5NDEgirW8IMY Bool { + UIImage(named: name, in: bundle, with: configuration) != nil + } +} diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 1448a6fb36..3ea9ba1c59 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1650,6 +1650,7 @@ final class StatusTableViewController: LoopChartsTableViewController { availableSupports: supportManager.availableSupports, isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceManager, + presetHistory: temporaryPresetsManager.overrideHistory, delegate: self ) viewModel.favoriteFoodInsightsDelegate = loopManager diff --git a/Loop/View Models/PresetsTrainingViewModel.swift b/Loop/View Models/PresetsTrainingViewModel.swift new file mode 100644 index 0000000000..3b12681a01 --- /dev/null +++ b/Loop/View Models/PresetsTrainingViewModel.swift @@ -0,0 +1,84 @@ +// +// PresetsTrainingViewModel.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI + +class PresetsTrainingViewModel: ObservableObject { + + @Published var navigationPath: [Step] + + init(step: Step = .creatingYourOwnPresets) { + self.navigationPath = step.fullPath() + } + + func nextPage() { + if navigationPath.isEmpty, let firstPage = Step.allCases.dropFirst().first { + navigationPath.append(firstPage) + } else if let next = navigationPath.last?.next() { + navigationPath.append(next) + } + } +} + +extension PresetsTrainingViewModel { + enum Step: Int, Hashable, CaseIterable { + case creatingYourOwnPresets + case howTheyWork1 + case howTheyWork2 + case presetsAndExercise1 + case presetsAndExercise2 + case presetsAndExercise3 + case presetsAndExercise4 + case presetsAndIllness1 + case presetsAndIllness2 + case presetsAndIllness3 + case presetsAndIllness4 + + var localizedTitle: String { + switch self { + case .creatingYourOwnPresets: + return NSLocalizedString("Creating Your Own Presets", comment: "Preset training, Creating your own presets, title") + case .howTheyWork1, .howTheyWork2: + return NSLocalizedString("How They Work", comment: "Preset training, How they work, title") + case .presetsAndExercise1, .presetsAndExercise2, .presetsAndExercise3, .presetsAndExercise4: + return NSLocalizedString("Presets and Exercise", comment: "Preset training, Presets and exercise, title") + case .presetsAndIllness1, .presetsAndIllness2, .presetsAndIllness3, .presetsAndIllness4: + return NSLocalizedString("Presets and Illness", comment: "Preset training, Presets and illness, title") + } + } + + var isFinalStep: Bool { + self.rawValue == Step.allCases.last?.rawValue + } + + fileprivate func next() -> Self? { + switch self { + case .creatingYourOwnPresets: .howTheyWork1 + case .howTheyWork1: .howTheyWork2 + case .howTheyWork2: .presetsAndExercise1 + case .presetsAndExercise1: .presetsAndExercise2 + case .presetsAndExercise2: .presetsAndExercise3 + case .presetsAndExercise3: .presetsAndExercise4 + case .presetsAndExercise4: .presetsAndIllness1 + case .presetsAndIllness1: .presetsAndIllness2 + case .presetsAndIllness2: .presetsAndIllness3 + case .presetsAndIllness3: .presetsAndIllness4 + case .presetsAndIllness4: nil + } + } + + fileprivate func fullPath() -> [Self] { + guard let currentIndex = Step.allCases.firstIndex(of: self), currentIndex != 0 else { + return [] + } + + return Array((1...currentIndex).map({ Step.allCases[$0] })) + } + } +} diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift new file mode 100644 index 0000000000..1ab80df384 --- /dev/null +++ b/Loop/View Models/PresetsViewModel.swift @@ -0,0 +1,244 @@ +// +// PresetsViewModel.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import HealthKit + +enum PresetDurationType { + case untilCarbsEntered + case duration(TimeInterval) + case indefinite +} + +enum PresetExpectedEndTime { + case untilCarbsEntered + case scheduled(Date) + case indefinite +} + +extension TemporaryScheduleOverride { + var expectedEndTime: PresetExpectedEndTime? { + switch context { + case .preMeal: return .untilCarbsEntered + case .legacyWorkout: return .indefinite + case .custom, .preset: + switch duration { + case .indefinite: return .indefinite + case .finite: return .scheduled(scheduledEndDate) + } + } + } + + var presetId: String { + switch context { + case .preMeal: return "premeal" + case .legacyWorkout: return "legacyworkout" + case .custom: return self.syncIdentifier.uuidString + case .preset(let preset): return preset.id.uuidString + } + } +} + +enum PresetIcon { + case emoji(String) + case image(String, Color) +} + +typealias RangeSafetyClassification = (lower: SafetyClassification, upper: SafetyClassification) + +enum SelectablePreset: Hashable, Identifiable { + + func hash(into hasher: inout Hasher) { + switch self { + case .custom(let preset): + hasher.combine(preset) + case .legacyWorkout(let range, _): + hasher.combine("legacyworkout") + hasher.combine(range) + case .preMeal(let range, _): + hasher.combine("premeal") + hasher.combine(range) + } + } + + static func == (lhs: SelectablePreset, rhs: SelectablePreset) -> Bool { + switch (lhs, rhs) { + case (.custom(let lhsPreset), .custom(let rhsPreset)): + return lhsPreset == rhsPreset + case (.legacyWorkout(let lhsRange, _), .legacyWorkout(let rhsRange, _)): + return lhsRange == rhsRange + case (.preMeal(let lhsRange, _), .legacyWorkout(let rhsRange, _)): + return lhsRange == rhsRange + default: + return false + } + } + + var id: String { + switch self { + case .custom(let preset): return preset.id.uuidString + case .legacyWorkout: return "legacyWorkout" + case .preMeal: return "preMeal" + } + } + + case custom(TemporaryScheduleOverridePreset) + case preMeal(range: ClosedRange, guardrail: Guardrail?) + case legacyWorkout(range: ClosedRange, guardrail: Guardrail?) + + var icon: PresetIcon { + switch self { + case .custom(let preset): return .emoji(preset.symbol) + case .preMeal: return .image("Pre-Meal", .carbTintColor) + case .legacyWorkout: return .image("workout", .insulinTintColor) + } + } + + var duration: PresetDurationType { + switch self { + case .custom(let preset): + switch preset.duration { + case .indefinite: + return .indefinite + case .finite(let duration): + return .duration(duration) + } + case .preMeal: return .untilCarbsEntered + case .legacyWorkout: return .indefinite + } + } + + var name: String { + switch self { + case .custom(let preset): return preset.name + case .preMeal: return "Pre-Meal" + case .legacyWorkout: return "Workout" + } + } + + var correctionRange: ClosedRange? { + switch self { + case .custom(let preset): return preset.settings.targetRange + case .preMeal(let range, _): return range + case .legacyWorkout(let range, _): return range + } + } + + var insulinSensitivityMultiplier: Double? { + if case .custom(let preset) = self { + return preset.settings.insulinSensitivityMultiplier + } else { + return nil + } + } + + var guardrail: Guardrail? { + switch self { + case .custom: + return nil + case .preMeal(_, let guardrail): + return guardrail + case .legacyWorkout(_, let guardrail): + return guardrail + } + } + + var dateCreated: Date { + switch self { + case .custom: + return .distantPast // TODO + case .preMeal: + return .distantPast.addingTimeInterval(1) + case .legacyWorkout: + return .distantPast + } + } +} + +class PresetsViewModel: ObservableObject { + + // MARK: Training + @AppStorage("hasCompletedPresetsTraining") var hasCompletedTraining: Bool = false + @AppStorage("presetsSortOrder") var selectedSortOption: PresetSortOption = .name + @AppStorage("presetsSortDirectionReversed") var presetsSortAscending: Bool = true + + var correctionRangeOverrides: CorrectionRangeOverrides? + + @Published var customPresets: [TemporaryScheduleOverridePreset] + @Published var activeOverride: TemporaryScheduleOverride? + + let preMealGuardrail: Guardrail? + let legacyWorkoutGuardrail: Guardrail? + + private var presetHistory: TemporaryScheduleOverrideHistory + + var activePreset: SelectablePreset? { + return allPresets.first(where: { $0.id == activeOverride?.presetId }) + } + + var allPresets: [SelectablePreset] { + var presets: [SelectablePreset] = [] + + if let preMealTargetRange = correctionRangeOverrides?.preMeal { + presets.append(.preMeal( + range: preMealTargetRange, + guardrail: preMealGuardrail + )) + } + + if let legacyWorkoutTargetRange = correctionRangeOverrides?.workout { + presets.append(.legacyWorkout( + range: legacyWorkoutTargetRange, + guardrail: legacyWorkoutGuardrail + )) + } + + presets.append(contentsOf: customPresets.map { .custom($0)} ) + + return presets + } + + var lastUsed: [String: Date]? + + func lastUsed(id: String) -> Date? { + if lastUsed == nil { + let enacts = presetHistory.getOverrideHistory(startDate: .distantPast, endDate: Date()) + lastUsed = [:] + for enact in enacts { + var id: String + switch enact.context { + case .preMeal: id = "preMeal" + case .legacyWorkout: id = "legacyWorkout" + case .preset(let preset): id = preset.id.uuidString + case .custom: continue + } + lastUsed![id] = max(lastUsed![id] ?? .distantPast, enact.startDate) + } + } + return lastUsed![id] + } + + init( + customPresets: [TemporaryScheduleOverridePreset], + correctionRangeOverrides: CorrectionRangeOverrides?, + presetsHistory: TemporaryScheduleOverrideHistory, + preMealGuardrail: Guardrail?, + legacyWorkoutGuardrail: Guardrail? + ) { + self.customPresets = customPresets + self.correctionRangeOverrides = correctionRangeOverrides + self.presetHistory = presetsHistory + self.preMealGuardrail = preMealGuardrail + self.legacyWorkoutGuardrail = legacyWorkoutGuardrail + + // TODO: If active preset changes, data store should update us. + activeOverride = presetsHistory.activeOverride(at: Date()) + } + +} diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index f907427989..b8fdfcee12 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -78,7 +78,8 @@ public class SettingsViewModel: ObservableObject { let sensitivityOverridesEnabled: Bool let isOnboardingComplete: Bool let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? - + let presetHistory: TemporaryScheduleOverrideHistory + @Published private(set) var automaticDosingStatus: AutomaticDosingStatus @Published private(set) var lastLoopCompletion: Date? @@ -101,7 +102,30 @@ public class SettingsViewModel: ObservableObject { delegate?.dosingEnabledChanged(closedLoopPreference) } } - + + var preMealGuardrail: Guardrail? { + guard let scheduleRange = therapySettings().glucoseTargetRangeSchedule?.scheduleRange() else { + return nil + } + return Guardrail.correctionRangeOverride( + for: .preMeal, + correctionRangeScheduleRange: scheduleRange, + suspendThreshold: therapySettings().suspendThreshold + ) + } + + var legacyWorkoutPresetGuardrail: Guardrail? { + guard let scheduleRange = therapySettings().glucoseTargetRangeSchedule?.scheduleRange() else { + return nil + } + return Guardrail.correctionRangeOverride( + for: .workout, + correctionRangeScheduleRange: scheduleRange, + suspendThreshold: therapySettings().suspendThreshold + ) + } + + weak var favoriteFoodInsightsDelegate: FavoriteFoodInsightsViewModelDelegate? var showDeleteTestData: Bool { @@ -143,6 +167,7 @@ public class SettingsViewModel: ObservableObject { availableSupports: [SupportUI], isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, + presetHistory: TemporaryScheduleOverrideHistory, delegate: SettingsViewModelDelegate? ) { self.alertPermissionsChecker = alertPermissionsChecker @@ -163,6 +188,7 @@ public class SettingsViewModel: ObservableObject { self.availableSupports = availableSupports self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate + self.presetHistory = presetHistory self.delegate = delegate // This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject) @@ -224,6 +250,7 @@ extension SettingsViewModel { availableSupports: [], isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, + presetHistory: TemporaryScheduleOverrideHistory(), delegate: nil ) } diff --git a/Loop/Views/Favorite Foods/FavoriteFoodsView.swift b/Loop/Views/Favorite Foods/FavoriteFoodsView.swift index 8ded4d57db..9b98a91360 100644 --- a/Loop/Views/Favorite Foods/FavoriteFoodsView.swift +++ b/Loop/Views/Favorite Foods/FavoriteFoodsView.swift @@ -50,7 +50,7 @@ struct FavoriteFoodsView: View { } } .insetGroupedListStyle() - + let editViewIsActive = Binding(get: { viewModel.isEditViewActive && !viewModel.isDetailViewActive }, set: { viewModel.isEditViewActive = $0 }) NavigationLink(destination: FavoriteFoodAddEditView(originalFavoriteFood: viewModel.selectedFood, onSave: viewModel.onFoodSave(_:)), isActive: editViewIsActive) { EmptyView() diff --git a/Loop/Views/Presets/Components/AdjustedGlucoseRangeView.swift b/Loop/Views/Presets/Components/AdjustedGlucoseRangeView.swift new file mode 100644 index 0000000000..2088b4b241 --- /dev/null +++ b/Loop/Views/Presets/Components/AdjustedGlucoseRangeView.swift @@ -0,0 +1,46 @@ +// +// AdjustedGlucoseRangeView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKitUI +import SwiftUI + +struct AdjustedGlucoseRangeView: View { + + @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + + @State var lowerBound: HKQuantity + @State var upperBound: HKQuantity + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) + .frame(maxWidth: .infinity) + + VStack(spacing: 0) { + Text("Adjusted Range") + .font(.subheadline) + .padding(.bottom, 4) + + Group { + Text(displayGlucosePreference.format(lowerBound, includeUnit: false)).foregroundColor(.accentColor) + + Text("-").foregroundColor(.secondary).fontWeight(.light) + + Text(displayGlucosePreference.format(upperBound, includeUnit: false)).foregroundColor(.accentColor) + } + .font(.system(size: UIFontMetrics.default.scaledValue(for: 42), weight: .semibold)) + + Text(displayGlucosePreference.unit.localizedUnitString(in: .medium) ?? displayGlucosePreference.unit.unitString) + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + } + } +} diff --git a/Loop/Views/Presets/Components/ImpactView.swift b/Loop/Views/Presets/Components/ImpactView.swift new file mode 100644 index 0000000000..3dfcb3a27d --- /dev/null +++ b/Loop/Views/Presets/Components/ImpactView.swift @@ -0,0 +1,32 @@ +// +// ImpactView.swift +// Loop +// +// Created by Cameron Ingham on 10/24/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +public struct ImpactView: View { + + @ViewBuilder let content: () -> Content + + public var body: some View { + GroupBox { + VStack(alignment: .leading, spacing: 10) { + Group { + Text(Image(systemName: "exclamationmark.circle.fill")) + .foregroundColor(.accentColor) + + Text(" Consider the Impact", comment: "Impact title") + } + .font(.title3.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityLabel(Text("Consider the Impact", comment: "Impact title accessibility label")) + + content() + .font(.subheadline) + } + } + } +} diff --git a/Loop/Views/Presets/Components/PercentPickerView.swift b/Loop/Views/Presets/Components/PercentPickerView.swift new file mode 100644 index 0000000000..da040a45d0 --- /dev/null +++ b/Loop/Views/Presets/Components/PercentPickerView.swift @@ -0,0 +1,114 @@ +// +// PercentPickerView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct PercentPickerView: View { + + @Binding var value: Int + + let range: ClosedRange + let stepCount: Int + let disabled: Bool + + let numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .none + return formatter + }() + + init(value: Binding, range: ClosedRange = 0...300, stepCount: Int = 5) { + self._value = value + self.range = range + self.stepCount = stepCount + self.disabled = false + } + + init(value: Int, range: ClosedRange = 0...300, stepCount: Int = 5) { + self._value = .constant(value) + self.range = range + self.stepCount = stepCount + self.disabled = true + } + + var downButton: some View { + Button { + withAnimation { + if value - stepCount <= range.lowerBound { + value = range.lowerBound + } else { + value = value - stepCount + } + } + } label: { + Text(Image(systemName: "minus.circle.fill").symbolRenderingMode(.hierarchical)).font(.system(size: UIFontMetrics.default.scaledValue(for: 40), weight: .semibold)) + } + .buttonStyle(PickerButtonStyle(disabled: disabled)) + } + + var valueText: some View { + Text("\(numberFormatter.string(from: Double(value)) ?? "100")%") + .font(.system(size: UIFontMetrics.default.scaledValue(for: 50), weight: .semibold).monospacedDigit()) + .contentTransition(.numericText()) + } + + var upButton: some View { + Button { + withAnimation { + if value + stepCount >= range.upperBound { + value = range.upperBound + } else { + value = value + stepCount + } + } + } label: { + Text(Image(systemName: "plus.circle.fill").symbolRenderingMode(.hierarchical)).font(.system(size: UIFontMetrics.default.scaledValue(for: 40), weight: .semibold)) + } + .buttonStyle(PickerButtonStyle(disabled: disabled)) + } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) + .frame(maxWidth: .infinity) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 16) { + downButton + + valueText + + upButton + } + + VStack(spacing: 0) { + valueText + + HStack(spacing: 32) { + downButton + + upButton + } + } + } + .foregroundColor(.accentColor) + .padding(.vertical, 10) + .padding(.horizontal, 16) + } + } +} + +private struct PickerButtonStyle: ButtonStyle { + let disabled: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed && !disabled ? 1.15 : 1) + } +} diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift new file mode 100644 index 0000000000..aa5c56ebbe --- /dev/null +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -0,0 +1,273 @@ +// +// PresetCard.swift +// Loop +// +// Created by Cameron Ingham on 10/24/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKitUI +import SwiftUI +import LoopKit + +struct PresetCard: View { + @Environment(\.guidanceColors) private var guidanceColors + + @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + + let icon: PresetIcon + let presetName: String + let duration: PresetDurationType + let insulinSensitivityMultiplier: Double? + let correctionRange: ClosedRange? + let guardrail: Guardrail? + let expectedEndTime: PresetExpectedEndTime? + + private var numberFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + return formatter + } + + var presetTitle: some View { + HStack(spacing: 6) { + switch icon { + case .emoji(let emoji): + Text(emoji) + case .image(let name, let iconColor): + Image(name) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(iconColor) + .frame(width: UIFontMetrics.default.scaledValue(for: 20), height: UIFontMetrics.default.scaledValue(for: 20)) + } + + Text(presetName) + .fontWeight(.semibold) + } + } + + var presetDuration: some View { + Group { Text(Image(systemName: "timer")) + Text(" \(duration.localizedTitle)") } + .font(.footnote) + .foregroundColor(.secondary) + .accessibilityLabel(Text(duration.accessibilityLabel)) + } + + var overallInsulinView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Overall Insulin") + .font(.subheadline) + .foregroundColor(.secondary) + .accessibilitySortPriority(2) + + let percent = numberFormatter.string(from: insulinSensitivityMultiplier ?? 1)! + Group { Text(percent).bold() + Text(" of scheduled") } + .font(.subheadline) + .accessibilitySortPriority(1) + } + .accessibilityElement(children: .contain) + } + + func guidanceColor(for classification: SafetyClassification?) -> Color? { + guard let classification else { return nil } + + switch classification { + case .outsideRecommendedRange(let threshold): + switch threshold { + case .aboveRecommended, .belowRecommended: + return guidanceColors.warning + case .maximum, .minimum: + return guidanceColors.critical + } + case .withinRecommendedRange: + return nil + } + } + + func annotatedRangeText(target: ClosedRange) -> some View { + + let lowerColor = guardrail?.color(for: target.lowerBound, guidanceColors: guidanceColors) ?? .primary + let upperColor = guardrail?.color(for: target.upperBound, guidanceColors: guidanceColors) ?? .primary + + let units = Text(" \(displayGlucosePreference.unit.localizedUnitString(in: .medium) ?? displayGlucosePreference.unit.unitString)") + .foregroundStyle(upperColor) + let lower = Text(displayGlucosePreference.format(target.lowerBound, includeUnit: false)) + .foregroundStyle(lowerColor) + .bold() + let upper = Text(displayGlucosePreference.format(target.upperBound, includeUnit: false)) + .foregroundStyle(upperColor) + .bold() + let warningSymbol = Text("\(Image(systemName: "exclamationmark.triangle.fill"))") + + let lowerClassification = guardrail?.classification(for: target.lowerBound) ?? .withinRecommendedRange + let upperClassification = guardrail?.classification(for: target.upperBound) ?? .withinRecommendedRange + + return Group { + switch (lowerClassification, upperClassification) { + case (.withinRecommendedRange, .withinRecommendedRange): + lower + Text(" - ") + upper + units + case (.withinRecommendedRange, .outsideRecommendedRange): + lower + Text(" - ") + warningSymbol.foregroundStyle(upperColor) + upper + units + case (.outsideRecommendedRange, .outsideRecommendedRange): + warningSymbol.foregroundStyle(lowerColor) + lower + Text("-").foregroundStyle(lowerColor) + upper + units + case (.outsideRecommendedRange, .withinRecommendedRange): + warningSymbol.foregroundStyle(lowerColor) + lower + Text("-") + upper + units + } + } + } + + var correctionRangeView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Correction Range") + .font(.subheadline) + .foregroundColor(.secondary) + .accessibilitySortPriority(2) + + Group { + if let target = correctionRange { + annotatedRangeText(target: target) + } else { + Text("Scheduled Range") + .bold() + } + } + .font(.subheadline) + .accessibilitySortPriority(1) + } + .accessibilityElement(children: .contain) + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ViewThatFits(in: .horizontal) { + HStack { + VStack(alignment: .leading) { + if let expectedEndTime { + HStack(spacing: 8) { + Text(Image(systemName: "timer")) + + + Text(" \(expectedEndTime.localizedTitle)") + .accessibilityLabel(Text(expectedEndTime.accessibilityLabel)) + } + .font(.footnote) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 5) + .background(Color.presets) + .cornerRadius(8) + } + presetTitle + } + + Spacer() + + if expectedEndTime == nil { + presetDuration + } + + Image(systemName: "chevron.right") + .imageScale(.small) + .font(.headline) + .foregroundColor(.secondary) + .opacity(0.5) + } + + VStack(alignment: .leading, spacing: 10) { + presetTitle + + presetDuration + } + } + + Divider() + .padding(.horizontal, -10) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 0) { + overallInsulinView + + Spacer() + + correctionRangeView + } + + VStack(alignment: .leading, spacing: 16) { + overallInsulinView + + correctionRangeView + } + } + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.tertiarySystemBackground)) + .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) + .frame(maxWidth: .infinity)) + } +} + +extension PresetExpectedEndTime { + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + return formatter + }() + + var localizedTitle: String { + switch self { + case .untilCarbsEntered: + return NSLocalizedString("on until carbs added", comment: "Preset card pre-meal expected end time") + case .indefinite: + return NSLocalizedString("on indefinitely", comment: "Preset card indefinite scheduled end time") + case .scheduled(let date): + return NSLocalizedString("on until \(Self.timeFormatter.string(from: date))", comment: "Presets card time duration accessibility label") + } + } + + var accessibilityLabel: String { + switch self { + case .untilCarbsEntered: + return NSLocalizedString("on until carbs added", comment: "Presets card pre-meal expected end time accessibility label") + case .indefinite: + return NSLocalizedString("on indefinitely", comment: "Presets card indefinite duration accessibility label") + case .scheduled(let date): + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .spellOut + return NSLocalizedString("on until \(Self.timeFormatter.string(from: date))", comment: "Presets card time duration accessibility label") + } + } +} + +extension PresetDurationType { + var localizedTitle: String { + switch self { + case .untilCarbsEntered: + return NSLocalizedString("until carbs added", comment: "Preset card pre-meal duration") + case .indefinite: + return NSLocalizedString("indefinite", comment: "Preset card indefinite duration") + case .duration(let duration): + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .short + return formatter.string(from: duration) ?? "" + + } + } + + var accessibilityLabel: String { + switch self { + case .untilCarbsEntered: + return NSLocalizedString("Active until carbs are added", comment: "Presets card pre-meal duration accessibility label") + case .indefinite: + return NSLocalizedString("Active indefinitely", comment: "Presets card indefinite duration accessibility label") + case .duration(let duration): + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .spellOut + return NSLocalizedString("Active for \(formatter.string(from: duration) ?? "")", comment: "Presets card time duration accessibility label") + } + } +} diff --git a/Loop/Views/Presets/Components/PresetsTrainingCard.swift b/Loop/Views/Presets/Components/PresetsTrainingCard.swift new file mode 100644 index 0000000000..c16d14d5a7 --- /dev/null +++ b/Loop/Views/Presets/Components/PresetsTrainingCard.swift @@ -0,0 +1,36 @@ +// +// PresetsTrainingCard.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import SwiftUI + +struct PresetsTrainingCard: View { + + @Environment(\.appName) private var appName + + @Binding var showTraining: Bool + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 16) { + Text(String(format: NSLocalizedString("Take more control of your insulin management with presets. Presets inform %1$@ that you anticipate a temporary change in how your diabetes behaves.", comment: "Presets training card, paragraph 1"), appName)) + .multilineTextAlignment(.center) + + Text("Complete the preset training to begin creating your own custom presets.", comment: "Presets training card, paragraph 2") + .multilineTextAlignment(.center) + } + + Button("Start Preset Training") { + showTraining = true + } + .buttonStyle(ActionButtonStyle(.primary)) + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 8).fill( Color(UIColor.tertiarySystemBackground))) + } +} diff --git a/Loop/Views/Presets/Components/PresetsTrainingContentContainerView.swift b/Loop/Views/Presets/Components/PresetsTrainingContentContainerView.swift new file mode 100644 index 0000000000..2504248be9 --- /dev/null +++ b/Loop/Views/Presets/Components/PresetsTrainingContentContainerView.swift @@ -0,0 +1,105 @@ +// +// PresetsTrainingContentContainerView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import SwiftUI + +struct PresetsTrainingContentContainerView: View { + + @ObservedObject var viewModel: PresetsTrainingViewModel + + @State var confirmEndTraining: Bool = false + @State var step: PresetsTrainingViewModel.Step + + var dismiss: () -> Void + let onComplete: () -> Void + + init( + viewModel: PresetsTrainingViewModel, + step: PresetsTrainingViewModel.Step, + dismiss: @escaping () -> Void, + onComplete: @escaping () -> Void = {} + ) { + self.viewModel = viewModel + self.step = step + self.dismiss = dismiss + self.onComplete = onComplete + } + + var body: some View { + ViewThatFits(in: .vertical) { + content(withSpacer: true) + + ScrollView { + content() + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Close") { + confirmEndTraining = true + } + } + } + .alert("End Presets Training?", isPresented: $confirmEndTraining) { + Button("Cancel", role: .cancel) {} + + Button("End Training", role: .destructive) { + dismiss() + } + } message: { + Text("Ending now will require you to restart training before creating new presets.\n\nDo you want to end training?", comment: "End presets training alert message") + } + + } + + private func content(withSpacer: Bool = false) -> some View { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text(step.localizedTitle) + .font(.largeTitle.bold()) + + Divider() + .padding(.horizontal, -16) + } + + switch step { + case .creatingYourOwnPresets: CreatingYourOwnPresetsContentView() + case .howTheyWork1: HowTheyWorkContentView(step: .one) + case .howTheyWork2: HowTheyWorkContentView(step: .two) + case .presetsAndExercise1: PresetsAndExerciseContentView(step: .one) + case .presetsAndExercise2: PresetsAndExerciseContentView(step: .two) + case .presetsAndExercise3: PresetsAndExerciseContentView(step: .three) + case .presetsAndExercise4: PresetsAndExerciseContentView(step: .four) + case .presetsAndIllness1: PresetsAndIllnessContentView(step: .one) + case .presetsAndIllness2: PresetsAndIllnessContentView(step: .two) + case .presetsAndIllness3: PresetsAndIllnessContentView(step: .three) + case .presetsAndIllness4: PresetsAndIllnessContentView(step: .four) + } + + VStack(spacing: 0) { + if withSpacer { + Spacer() + } + + Button { + if step.isFinalStep { + dismiss() + onComplete() + } else { + viewModel.nextPage() + } + } label: { + Text(step.isFinalStep ? "Finish Training" : "Continue") + } + .buttonStyle(ActionButtonStyle(.primary)) + } + } + .padding(.horizontal, 16) + } +} diff --git a/Loop/Views/Presets/Components/TherapySettingsExampleView.swift b/Loop/Views/Presets/Components/TherapySettingsExampleView.swift new file mode 100644 index 0000000000..bfb9e67575 --- /dev/null +++ b/Loop/Views/Presets/Components/TherapySettingsExampleView.swift @@ -0,0 +1,62 @@ +// +// TherapySettingsExampleView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit +import LoopKitUI +import SwiftUI + +struct TherapySettingsExampleView: View { + + @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + + let title: String + let basalRate: Double + let carbRatio: Double + let isf: Double + + let numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() + + private let basalRateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) + + var body: some View { + GroupBox { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.title3.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 36) { + VStack(alignment: .leading, spacing: 6) { + Text("Basal Rate") + + Text("Carb Ratio") + + Text("ISF") + } + .fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 6) { + if let basalRateValue = basalRateFormatter.string(from: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: basalRate)) { + Text(basalRateValue) + } + + Text("\(numberFormatter.string(from: carbRatio) ?? "0") g/U") + + Text(displayGlucosePreference.format(HKQuantity(unit: .milligramsPerDeciliter, doubleValue: isf))) + } + } + .font(.subheadline) + } + } + } +} diff --git a/Loop/Views/Presets/PresetsTrainingView.swift b/Loop/Views/Presets/PresetsTrainingView.swift new file mode 100644 index 0000000000..3132e8253e --- /dev/null +++ b/Loop/Views/Presets/PresetsTrainingView.swift @@ -0,0 +1,38 @@ +// +// PresetsTrainingView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import SwiftUI + +struct PresetsTrainingView: View { + + @Environment(\.dismiss) private var dismiss + + @StateObject var viewModel = PresetsTrainingViewModel() + + let onComplete: () -> Void + + var body: some View { + NavigationStack(path: $viewModel.navigationPath) { + PresetsTrainingContentContainerView( + viewModel: viewModel, + step: .creatingYourOwnPresets, + dismiss: { dismiss() } + ) + .navigationDestination(for: PresetsTrainingViewModel.Step.self) { step in + PresetsTrainingContentContainerView( + viewModel: viewModel, + step: step, + dismiss: { dismiss() }, + onComplete: onComplete + ) + } + } + .interactiveDismissDisabled() + } +} diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift new file mode 100644 index 0000000000..ed97af3f2a --- /dev/null +++ b/Loop/Views/Presets/PresetsView.swift @@ -0,0 +1,243 @@ +// +// PresetsView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import Foundation + +enum PresetSortOption: Int, CaseIterable { + case name + case lastUsed + case dateCreated + + var description: String { + switch self { + case .name: + return NSLocalizedString("Name", comment: "Preset sorting option description for sorting by name") + case .lastUsed: + return NSLocalizedString("Last Used", comment: "Preset sorting option description for sorting by last used") + case .dateCreated: + return NSLocalizedString("Date Created", comment: "Preset sorting option description for sorting by date created") + } + } +} + +struct PresetsView: View { + + @Environment(\.dismiss) private var dismiss + + @StateObject private var viewModel: PresetsViewModel + + @State private var editMode: EditMode = .inactive + @State private var showingMenu: Bool = false + @State var showTraining: Bool = false + + + var isDescending: Bool { !viewModel.presetsSortAscending } + + init(viewModel: PresetsViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + } + + var presetsSorted: [SelectablePreset] { + viewModel.allPresets + .filter { $0.id != viewModel.activeOverride?.presetId } + .sorted(by: { + switch (viewModel.selectedSortOption) { + case .name: + return ($0.name.lowercased() < $1.name.lowercased()) != isDescending + case .dateCreated: + return ($0.dateCreated > $1.dateCreated) != isDescending + default: + return ((viewModel.lastUsed(id: $0.id) ?? .distantPast) > (viewModel.lastUsed(id: $1.id) ?? .distantPast)) != isDescending + } + }) + } + + var body: some View { + NavigationView { + ScrollView { + VStack(spacing: 20) { + + if !viewModel.hasCompletedTraining { + PresetsTrainingCard(showTraining: $showTraining) + } + + if let activePreset = viewModel.activePreset { + PresetCard( + activePreset, + expectedEndTime: viewModel.activeOverride?.expectedEndTime + ) + } + + // All Presets Section + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("All Presets") + .font(.title2.bold()) + Spacer() + + Button("Sort") { + showingMenu.toggle() + } + .popover(isPresented: $showingMenu) { + sortMenu + } + + Button(action: {}) { + Image(systemName: "plus") + }.disabled(!viewModel.hasCompletedTraining) + } + + LazyVStack(spacing: 12) { + ForEach(presetsSorted) { preset in + PresetCard(preset) + .background(Color.white) + .cornerRadius(12) + } + } + } + + // Support Section + VStack(alignment: .leading, spacing: 16) { + Text("Support") + .font(.title2.bold()) + + NavigationLink(destination: EmptyView()) { + HStack { + Image(systemName: "list.bullet") + .foregroundColor(.white) + .padding(8) + .background(Color.presets) + .cornerRadius(8) + + Text("Presets Performance History") + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + } + } + .padding(10) + .foregroundStyle(.primary) + .background(RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.tertiarySystemBackground)) + .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) + .frame(maxWidth: .infinity)) + + if viewModel.hasCompletedTraining { + NavigationLink(destination: PresetsTrainingView { viewModel.hasCompletedTraining = true }) { + HStack { + Text("Review Presets Training") + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + } + } + .padding(10) + .foregroundStyle(.primary) + .background(RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.tertiarySystemBackground)) + .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) + .frame(maxWidth: .infinity)) + } + + } + } + .padding() + } + .background(Color(UIColor.secondarySystemBackground)) + .navigationTitle(Text("Presets", comment: "Presets screen title")) + .navigationBarItems(trailing: dismissButton) + } + + .sheet(isPresented: $showTraining) { + PresetsTrainingView { + viewModel.hasCompletedTraining = true + } + } + .onAppear { // TODO: Remove this + viewModel.hasCompletedTraining = false + } + } + + private var sortMenu: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Sort By") + .font(.headline) + Spacer() + Button(action: { + viewModel.presetsSortAscending.toggle() + }) { + Image(systemName: "arrow.up.arrow.down") + } + } + .padding(.horizontal) + .padding(.top, 20) + Divider() + + ForEach(PresetSortOption.allCases, id: \.self) { option in + Button(action: { + viewModel.selectedSortOption = option + showingMenu = false + }) { + HStack { + if viewModel.selectedSortOption == option { + Image(systemName: "checkmark") + } else { + Image(systemName: "checkmark") + .hidden() + } + Text(option.description) + .font(.body) + } + .padding(.horizontal) + } + .buttonStyle(PlainButtonStyle()) + .padding(.bottom, option == PresetSortOption.allCases.last ? 12 : 0) + if option != PresetSortOption.allCases.last { + Divider() + } + } + } + .frame(width: 200) + .background(Color(UIColor.secondarySystemBackground)) + .cornerRadius(12) + .presentationCompactAdaptation(.popover) + } + + private var dismissButton: some View { + Button("Done") { + dismiss() + }.bold() + } + + private var editButton: some View { + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + editMode.toggle() + } + }) { + Text(editMode.title) + .textCase(nil) + } + } +} + +extension PresetCard { + init (_ preset: SelectablePreset, expectedEndTime: PresetExpectedEndTime? = nil) { + self.init( + icon: preset.icon, + presetName: preset.name, + duration: preset.duration, + insulinSensitivityMultiplier: preset.insulinSensitivityMultiplier, + correctionRange: preset.correctionRange, + guardrail: preset.guardrail, + expectedEndTime: expectedEndTime + ) + } +} diff --git a/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift b/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift new file mode 100644 index 0000000000..d3f8f345fe --- /dev/null +++ b/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift @@ -0,0 +1,64 @@ +// +// CreatingYourOwnPresetsContentView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopUI +import LoopKitUI +import SwiftUI + +struct CreatingYourOwnPresetsContentView: View { + + @Environment(\.appName) private var appName + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(format: NSLocalizedString("%1$@ comes with two provider recommended presets with your prescription:", comment: "Creating your own presets training content, paragraph 1"), appName)) + + BulletedListView { + Text(Image("Pre-Meal-symbol")).foregroundColor(.carbTintColor) + Text(" Pre-Meal") + Text(Image("workout-symbol")).foregroundColor(.glucoseTintColor) + Text(" Workout") + } + } + + Text(String(format: NSLocalizedString("After reviewing this required training, you’ll be able to create your own custom presets. This is an optional feature that can enhance and personalize how the %1$@ system works for you.", comment: "Creating your own presets training content, paragraph 2"), appName)) + + Text("Using presets, you can let the system know about events that may impact your diabetes management such as exercising, sickness or hormonal changes.", comment: "Creating your own presets training content, paragraph 3") + + Text("We encourage you to work with your healthcare provider to find the right preset settings for you.", comment: "Creating your own presets training content, paragraph 4") + + VStack(alignment: .leading, spacing: 8) { + Text("Managing Presets", comment: "Creating your own presets training content, managing presets, subtitle 1") + .font(.title2.bold()) + + Text("You can manage all presets by tapping the Presets button on the toolbar.", comment: "Creating your own presets training content, managing presets, paragraph 1") + + if Image.imageExists("PresetsTraining1") { + Image("PresetsTraining1") + .resizable() + .aspectRatio(contentMode: .fill) + .accessibilityHidden(true) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("When a preset is ON, you’ll notice the following indicators on the home screen:", comment: "Creating your own presets training content, managing presets, paragraph 2") + + BulletedListView { + Text("the Presets button will display with inverted colors on the toolbar", comment: "Creating your own presets training content, managing presets, paragraph 2, bullet 1") + Text("a banner will display at the top of the home screen", comment: "Creating your own presets training content, managing presets, paragraph 2, bullet 2") + Text("(if applicable) the glucose chart will show your adjusted correction range", comment: "Creating your own presets training content, managing presets, paragraph 2, bullet 3") + } + + if Image.imageExists("PresetsTraining2") { + Image("PresetsTraining2") + .resizable() + .aspectRatio(contentMode: .fill) + .accessibilityHidden(true) + } + } + } +} diff --git a/Loop/Views/Presets/Training Content/HowTheyWorkContentView.swift b/Loop/Views/Presets/Training Content/HowTheyWorkContentView.swift new file mode 100644 index 0000000000..dfa5a965c0 --- /dev/null +++ b/Loop/Views/Presets/Training Content/HowTheyWorkContentView.swift @@ -0,0 +1,118 @@ +// +// HowTheyWorkContentView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import SwiftUI + +struct HowTheyWorkContentView: View { + + @Environment(\.appName) private var appName + + enum StepNumber { + case one + case two + } + + let step: StepNumber + + var body: some View { + switch step { + case .one: + stepOneView + case .two: + stepTwoView + } + } + + @ViewBuilder + var stepOneView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("The most important settings when creating a preset will be those that impact your insulin delivery and safety:", comment: "How presets work training content, paragraph 1") + + BulletedListView { + Text("Overall insulin", comment: "How presets work training content, paragraph 1, bullet 1") + + Text("Correction range", comment: "How presets work training content, paragraph 1, bullet 2") + } + } + + Text("Let's do a brief review of each setting.", comment: "How presets work training content, paragraph 2") + + VStack(alignment: .leading, spacing: 16) { + Text("Adjusting Overall Insulin", comment: "How presets work training content, adjusting overall insulin, subtitle 1") + .font(.title2.bold()) + + if Image.imageExists("PresetsTraining3") { + Image("PresetsTraining3") + .resizable() + .aspectRatio(contentMode: .fill) + .accessibilityHidden(true) + } + + Text("Presets allow you to specify an adjusted overall insulin value for the duration of the preset.", comment: "How presets work training content, adjusting overall insulin, paragraph 1") + } + + VStack(alignment: .leading, spacing: 8) { + Group { + Text("Overall insulin is a ", comment: "How presets work training content, adjusting overall insulin, subtitle 1, paragraph 2, part 1") + Text("metabolic", comment: "How presets work training content, adjusting overall insulin, paragraph 2, part 2").bold() + Text(" setting and should be used when your body needs more or less insulin than normal. Adjusting the overall insulin percentage will impact the following settings:", comment: "How presets work training content, adjusting overall insulin, paragraph 2, part 3") + } + + BulletedListView { + Text("Basal Rate", comment: "How presets work training content, adjusting overall insulin, paragraph 2, bullet 1") + + Text("Carb Ratio", comment: "How presets work training content, adjusting overall insulin, paragraph 2, bullet 2") + + Text("Insulin Sensitivity Factor (ISF)", comment: "How presets work training content, adjusting overall insulin, paragraph 2, bullet 3") + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Before adjusting your overall insulin, ask yourself, does my body need more or less than normal?", comment: "How presets work training content, adjusting overall insulin, paragraph 3").bold() + + if + let lower = try? AttributedString(markdown: "Setting a percentage _**lower**_ than 100% will let the system know that you are more insulin sensitive and need less insulin."), + let higher = try? AttributedString(markdown: "Setting a percentage _**higher**_ than 100% will let the system know that you are more insulin resistant and need more insulin.") + { + BulletedListView { + Text(lower) + + Text(higher) + } + } + } + } + + @ViewBuilder + var stepTwoView: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Adjusting the Correction Range", comment: "How presets work training content, adjusting correction range, subtitle 1") + .font(.title2.bold()) + + if Image.imageExists("PresetsTraining4") { + Image("PresetsTraining4") + .resizable() + .aspectRatio(contentMode: .fill) + .accessibilityHidden(true) + } + } + + Text("Presets allow you to specify an adjusted correction range for the duration of the preset to help you meet your glucose goals.", comment: "How presets work training content, adjusting correction range, paragraph 1") + + Text(String(format: NSLocalizedString("It allows you to choose the specific glucose value (or range of values) that you want %1$@ to aim for in adjusting your basal insulin.", comment: "How presets work training content, adjusting correction range, paragraph 2"), appName)) + + if let string = try? AttributedString(markdown: "The correction range is a **safety** setting. Changing the correction range from your scheduled correction range may be particularly useful in reducing the risk of lows / hypoglycemia if you expect your glucose to vary more than normal.") { + Text(string) + .fixedSize(horizontal: false, vertical: true) + } + + Text("You do not have to set a new correction range for each preset.", comment: "How presets work training content, adjusting correction range, paragraph 4") + + Text("Before adjusting your correction range, ask yourself, am I more likely to go high or low during this event?", comment: "How presets work training content, adjusting correction range, paragraph 5") + .bold() + } +} diff --git a/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift b/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift new file mode 100644 index 0000000000..7f8ec50c9f --- /dev/null +++ b/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift @@ -0,0 +1,153 @@ +// +// PresetsAndExerciseContentView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKitUI +import SwiftUI + +struct PresetsAndExerciseContentView: View { + + @Environment(\.appName) private var appName + @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + + enum StepNumber { + case one + case two + case three + case four + } + + let step: StepNumber + + var body: some View { + switch step { + case .one: + stepOneView + case .two: + stepTwoView + case .three: + stepThreeView + case .four: + stepFourView + } + } + + private let lowerBound = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 140) + private let upperBound = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 160) + + @ViewBuilder + var stepOneView: some View { + HStack(alignment: .top, spacing: 16) { + Image("workout") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 28, height: 28) + .foregroundColor(.glucoseTintColor) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text("Common Scenarios", comment: "Presets and exercise training content, callout title") + .fontWeight(.semibold) + + Text("The next few screens will walk you through two common scenarios for using presets to help you better understand how this feature may work for you.", comment: "Presets and exercise training content, callout subtitle") + .font(.subheadline) + } + } + .background(Color.accentColor.opacity(0.1).padding(.horizontal, -16).padding(.vertical, -16)) + .padding(.bottom, 24) + .padding(.top, -8) + + Text("Exercise is a common use case for setting a preset. The following is an example of how a preset can support insulin management during physical activity.", comment: "Presets and exercise training content, paragraph 1") + .bold() + + Text("Let’s imagine Omar Octopus wants to create a preset for a 30-minute walk to work. He wants the system to know he'll be active, so it should aim for a higher glucose correction range during that time.", comment: "Presets and exercise training content, paragraph 2") + + TherapySettingsExampleView( + title: NSLocalizedString("Omar's Therapy Settings", comment: "Presets and exercise training content, therapy settings example, title"), + basalRate: 0.5, + carbRatio: 13, + isf: 50 + ) + + Text("Let’s explore each of the configurable settings that will impact Omar’s insulin delivery.", comment: "Presets and exercise training content, paragraph 3") + } + + @ViewBuilder + var stepTwoView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Overall Insulin Needs", comment: "Presets and exercise training content, overall insulin, subtitle 1") + .font(.title2.bold()) + + Text("Omar asks himself, do I expect I will need more or less insulin than usual?", comment: "Presets and exercise training content, overall insulin, paragraph 1") + } + + Text("In this example, Omar’s overall insulin needs remain the same for his walk, so he will not adjust the overall insulin value.", comment: "Presets and exercise training content, overall insulin, paragraph 2") + + Text("Pay attention to your insulin needs before and after exercising, playing sports, or doing unusually hard physical labor. ", comment: "Presets and exercise training content, overall insulin, paragraph 3") + + PercentPickerView(value: 100) + + ImpactView { + if let string = try? AttributedString(markdown: "Omar’s **basal rate, carb ratio and insulin sensitivity factor (ISF)** remain unchanged.") { + Text(string) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + @ViewBuilder + var stepThreeView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Correction Range", comment: "Presets and exercise training content, correction range, subtitle") + .font(.title2.bold()) + + let range = displayGlucosePreference.format(lowerQuantity: lowerBound, higherQuantity: upperBound) + + if let string = try? AttributedString(markdown: String(format: NSLocalizedString("Omar is worried he will go low while walking so he slightly increases his correction range to **%1$@**. Increasing the lower bound of the correction range tells %2$@ to begin taking action sooner.", comment: "Presets and exercise training content, correction range, paragraph 1"), range, appName)) { + Text(string) + } + } + + Text("You may choose to set a higher temporary glucose Correction Range for physical activity where you anticipate an increased risk of low glucose.", comment: "Presets and exercise training content, correction range, paragraph 2") + + if let string = try? AttributedString(markdown: "For exercise, this range will typically be _**higher**_ than your usual correction range.") { + Text(string) + } + + AdjustedGlucoseRangeView( + lowerBound: lowerBound, + upperBound: upperBound + ) + + VStack(alignment: .leading, spacing: 8) { + Text("Optional: Scheduling a Preset", comment: "Presets and exercise training content, scheduling preset, subtitle") + .font(.title2.bold()) + + Text("Some people use this feature before exercise for a pre-programmed 1-hour, 2-hour, or indefinite length of time in an effort to decrease their risk of low glucose during exercise or other physical activity.", comment: "Presets and exercise training content, scheduling preset, paragraph 1") + } + } + + @ViewBuilder + var stepFourView: some View { + Text("Once saved, Omar’s completed preset will display in his Presets lists.", comment: "Presets and exercise training content, scheduling preset, paragraph 2") + + PresetCard( + icon: .emoji("🚶"), + presetName: NSLocalizedString("Walk to Work", comment: "Presets and exercise training content, scheduling preset, preset example, title"), + duration: .duration(.seconds(1800)), + insulinSensitivityMultiplier: 1.0, + correctionRange: ClosedRange( + uncheckedBounds: ( + HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 140), + HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 260)) + ), + guardrail: nil, + expectedEndTime: .indefinite + ) + } +} diff --git a/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift b/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift new file mode 100644 index 0000000000..c7ae31ee15 --- /dev/null +++ b/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift @@ -0,0 +1,139 @@ +// +// PresetsAndIllnessContentView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit +import LoopKitUI +import SwiftUI + +struct PresetsAndIllnessContentView: View { + + @Environment(\.appName) private var appName + @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + + enum StepNumber { + case one + case two + case three + case four + } + + let step: StepNumber + + var body: some View { + switch step { + case .one: + stepOneView + case .two: + stepTwoView + case .three: + stepThreeView + case .four: + stepFourView + } + } + + private let lowerBound = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 130) + private let upperBound = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 140) + + private let basalRateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) + + @ViewBuilder + var stepOneView: some View { + Text("Physical stressors can cause your glucose to rise and sickness is a common example. Your healthcare provider can help you make a personal plan for sickness. The following is one example of using presets to manage an illness.", comment: "Presets and illness training content, paragraph 1") + .bold() + + Text("Let’s imagine Paloma Porpoise notices her glucose is higher than usual and wants to create a preset to help keep her glucose in range while she is sick.", comment: "Presets and illness training content, paragraph 2") + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Therapy Settings", comment: "Presets and illness training content, therapy settings example, title"), + basalRate: 0.5, + carbRatio: 13, + isf: 50 + ) + } + + @ViewBuilder + var stepTwoView: some View { + Text("Let’s explore each of the configurable settings that will impact Paloma’s insulin delivery.", comment: "Presets and illness training content, paragraph 2") + + VStack(alignment: .leading, spacing: 8) { + Text("Overall Insulin Needs", comment: "Presets and illness training content, overall insulin, subtitle") + .font(.title2.bold()) + + if let string = try? AttributedString(markdown: String(format: NSLocalizedString("Paloma wants to tell the %1$@ system that she needs more insulin than usual since her glucose has been elevated. She will adjust her overall insulin **above** her scheduled delivery.", comment: "Presets and illness training content, overall insulin, paragraph 1"), appName)) { + Text(string) + } + } + + PercentPickerView(value: .constant(110)) + + ImpactView { + if let string = try? AttributedString(markdown: "**Basal** Rate was \(basalRateFormatter.string(from: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: 0.5)) ?? "0") and will be \(basalRateFormatter.string(from: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: 0.6)) ?? "0")\n**Carb Ratio** was 13 g and will be 11.7 g\n**ISF** was \(displayGlucosePreference.format(HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 50))) and will be \(displayGlucosePreference.format(HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 45)))", options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { + Text(string) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + @ViewBuilder + var stepThreeView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Correction Range", comment: "Presets and illness training content, correction range, subtitle") + .font(.title2.bold()) + + Text("Paloma’s normal correction range is set to \(displayGlucosePreference.format(lowerQuantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 105), higherQuantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 110))).", comment: "Presets and illness training content, correction range, paragraph 1") + } + + if let string = try? AttributedString(markdown: "In this scenario, Paloma will increase her correction range to **\(displayGlucosePreference.format(lowerQuantity: lowerBound, higherQuantity: upperBound))** to prevent drops due to eating less or not absorbing what she eats while sick.") { + Text(string) + } + + AdjustedGlucoseRangeView( + lowerBound: lowerBound, + upperBound: upperBound + ) + + VStack(alignment: .leading, spacing: 8) { + Text("Duration", comment: "Presets and illness training content, duration, subtitle") + .font(.title2.bold()) + + Text(String(format: NSLocalizedString("Paloma will set her preset duration to “Until I Turn Off” since she is not sure when her illness will pass. %1$@ will remind her every 8 hours that the preset is running. ", comment: "Presets and illness training content, duration, paragraph 1"), appName)) + } + } + + @ViewBuilder + var stepFourView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Impact on Bolusing", comment: "Presets and illness training content, impact on bolusing, subtitle") + .font(.title2.bold()) + + Text("Let’s imagine Paloma decides to eat a meal of 31g carbs. How will her preset impact her bolus recommendation?", comment: "Presets and illness training content, impact on bolusing, paragraph 1") + } + + Text("While a preset is ON, the modified basal rates, carb ratio and insulin sensitivity factor (ISF) are applied for every bolus.", comment: "Presets and illness training content, impact on bolusing, paragraph 2") + + ImpactView { + VStack(alignment: .leading, spacing: 16) { + Text("Paloma’s bolus recommendation for 31g of carbs will increase due to her preset.", comment: "Presets and illness training content, impact on bolusing, impact title") + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 16) { + Group { Text("3.9").font(.title.weight(.bold)) + Text(" U").font(.title2) } + + Text(Image(systemName: "arrow.forward")) + .font(.title.weight(.medium)) + + Group { Text("4.3").font(.title.weight(.bold)) + Text(" U").font(.title2) } + } + } + } + } +} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index c47b5f62d6..557472a10e 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -53,6 +53,7 @@ public struct SettingsView: View { case favoriteFoods case therapySettings + case presets } } @@ -83,6 +84,7 @@ public struct SettingsView: View { if viewModel.pumpManagerSettingsViewModel.isSetUp() { therapySection } + presetsSection deviceSettingsSection if FeatureFlags.allowExperimentalFeatures { favoriteFoodsSection @@ -138,33 +140,51 @@ public struct SettingsView: View { } } .sheet(item: $sheet) { sheet in - switch sheet { - case .therapySettings: - TherapySettingsView( - mode: .settings, - viewModel: TherapySettingsViewModel( - therapySettings: viewModel.therapySettings(), - sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, - delegate: viewModel.therapySettingsViewModelDelegate + Group { + switch sheet { + case .therapySettings: + TherapySettingsView( + mode: .settings, + viewModel: TherapySettingsViewModel( + therapySettings: viewModel.therapySettings(), + sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, + adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, + delegate: viewModel.therapySettingsViewModelDelegate + ) ) - ) - .environmentObject(displayGlucosePreference) - .environment(\.dismissAction, self.dismiss) - .environment(\.appName, self.appName) - .environment(\.chartColorPalette, .primary) - .environment(\.carbTintColor, self.carbTintColor) - .environment(\.glucoseTintColor, self.glucoseTintColor) - .environment(\.guidanceColors, self.guidanceColors) - .environment(\.insulinTintColor, self.insulinTintColor) - case .favoriteFoods: - FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) + case .presets: + presetsView + case .favoriteFoods: + FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) + } } + .environmentObject(displayGlucosePreference) + .environment(\.dismissAction, self.dismiss) + .environment(\.appName, self.appName) + .environment(\.chartColorPalette, .primary) + .environment(\.carbTintColor, self.carbTintColor) + .environment(\.glucoseTintColor, self.glucoseTintColor) + .environment(\.guidanceColors, self.guidanceColors) + .environment(\.insulinTintColor, self.insulinTintColor) } } .navigationViewStyle(.stack) } + public var presetsView: some View { + PresetsView( + viewModel: PresetsViewModel( + customPresets: viewModel.therapySettings().overridePresets ?? [], + correctionRangeOverrides: viewModel.therapySettings().correctionRangeOverrides, + presetsHistory: viewModel.presetHistory, + preMealGuardrail: viewModel.preMealGuardrail, + legacyWorkoutGuardrail: viewModel.legacyWorkoutPresetGuardrail + ) + ) + } + + + private func menuItemsForSection(name: String) -> some View { Section(header: SectionHeader(label: name)) { ForEach(pluginMenuItems.filter {$0.section.customLocalizedTitle == name}) { item in @@ -342,6 +362,18 @@ extension SettingsView { } } } + + private var presetsSection: some View { + Section { + LargeButton( + action: { sheet = .presets }, + includeArrow: true, + imageView: Image("Presets Icon"), + label: NSLocalizedString("Presets", comment: "Title text for button to Preset Settings"), + descriptiveText: NSLocalizedString("Temporary Settings Adjustments", comment: "Descriptive text for Preset Settings") + ) + } + } private var pluginMenuItems: [PluginMenuItem] { self.viewModel.availableSupports.flatMap { plugin in diff --git a/LoopUI/Extensions/Color.swift b/LoopUI/Extensions/Color.swift index f12a2ba746..189e4baa06 100644 --- a/LoopUI/Extensions/Color.swift +++ b/LoopUI/Extensions/Color.swift @@ -10,13 +10,15 @@ import SwiftUI // MARK: - Color palette for common elements extension Color { - static let carbs = Color("carbs") + public static let carbs = Color("carbs") - static let fresh = Color("fresh") + public static let fresh = Color("fresh") - static let glucose = Color("glucose") - - static let insulin = Color("insulin") + public static let glucose = Color("glucose") + + public static let insulin = Color("insulin") + + public static let presets = Color("presets") // The loopAccent color is intended to be use as the app accent color. public static let loopAccent = Color("accent") diff --git a/LoopUI/Extensions/UIColor.swift b/LoopUI/Extensions/UIColor.swift index db638084f8..9b474b42c5 100644 --- a/LoopUI/Extensions/UIColor.swift +++ b/LoopUI/Extensions/UIColor.swift @@ -18,6 +18,8 @@ extension UIColor { @nonobjc static let insulin = UIColor(named: "insulin") ?? systemOrange + @nonobjc static let presets = UIColor(named: "presets") ?? systemTeal + // The loopAccent color is intended to be use as the app accent color. @nonobjc public static let loopAccent = UIColor(named: "accent") ?? systemBlue From f1cf4bfba6daaa87c717ba53f3f9288fc8aff678 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 9 Dec 2024 09:42:42 -0800 Subject: [PATCH 188/421] Loop 2.0 Presets x Loop Algo Updates (#730) --- Loop.xcodeproj/project.pbxproj | 24 +++---------------- Loop/View Models/PresetsViewModel.swift | 18 +++++++------- Loop/View Models/SettingsViewModel.swift | 5 ++-- .../Components/AdjustedGlucoseRangeView.swift | 6 ++--- .../Views/Presets/Components/PresetCard.swift | 8 +++---- .../TherapySettingsExampleView.swift | 6 ++--- .../PresetsAndExerciseContentView.swift | 10 ++++---- .../PresetsAndIllnessContentView.swift | 10 ++++---- 8 files changed, 35 insertions(+), 52 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 1b88d07dfe..142f6f6eb3 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -4571,7 +4571,6 @@ INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4620,7 +4619,6 @@ INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4734,7 +4732,7 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, CFLocalizedString, @@ -4844,7 +4842,7 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, CFLocalizedString, @@ -4878,7 +4876,6 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 17.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4907,7 +4904,6 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 17.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5083,7 +5079,6 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5105,7 +5100,6 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5124,7 +5118,6 @@ BUNDLE_LOADER = "$(TEST_HOST)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5143,7 +5136,6 @@ BUNDLE_LOADER = "$(TEST_HOST)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5165,7 +5157,6 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5186,7 +5177,6 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5288,7 +5278,7 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, CFLocalizedString, @@ -5322,7 +5312,6 @@ DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 17.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5416,7 +5405,6 @@ INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5448,7 +5436,6 @@ ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5475,7 +5462,6 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5521,7 +5507,6 @@ DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5540,7 +5525,6 @@ BUNDLE_LOADER = "$(TEST_HOST)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5563,7 +5547,6 @@ ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5591,7 +5574,6 @@ ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift index 1ab80df384..67d45e31b3 100644 --- a/Loop/View Models/PresetsViewModel.swift +++ b/Loop/View Models/PresetsViewModel.swift @@ -7,8 +7,8 @@ // import SwiftUI +import LoopAlgorithm import LoopKit -import HealthKit enum PresetDurationType { case untilCarbsEntered @@ -89,8 +89,8 @@ enum SelectablePreset: Hashable, Identifiable { } case custom(TemporaryScheduleOverridePreset) - case preMeal(range: ClosedRange, guardrail: Guardrail?) - case legacyWorkout(range: ClosedRange, guardrail: Guardrail?) + case preMeal(range: ClosedRange, guardrail: Guardrail?) + case legacyWorkout(range: ClosedRange, guardrail: Guardrail?) var icon: PresetIcon { switch self { @@ -122,7 +122,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var correctionRange: ClosedRange? { + var correctionRange: ClosedRange? { switch self { case .custom(let preset): return preset.settings.targetRange case .preMeal(let range, _): return range @@ -138,7 +138,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var guardrail: Guardrail? { + var guardrail: Guardrail? { switch self { case .custom: return nil @@ -173,8 +173,8 @@ class PresetsViewModel: ObservableObject { @Published var customPresets: [TemporaryScheduleOverridePreset] @Published var activeOverride: TemporaryScheduleOverride? - let preMealGuardrail: Guardrail? - let legacyWorkoutGuardrail: Guardrail? + let preMealGuardrail: Guardrail? + let legacyWorkoutGuardrail: Guardrail? private var presetHistory: TemporaryScheduleOverrideHistory @@ -228,8 +228,8 @@ class PresetsViewModel: ObservableObject { customPresets: [TemporaryScheduleOverridePreset], correctionRangeOverrides: CorrectionRangeOverrides?, presetsHistory: TemporaryScheduleOverrideHistory, - preMealGuardrail: Guardrail?, - legacyWorkoutGuardrail: Guardrail? + preMealGuardrail: Guardrail?, + legacyWorkoutGuardrail: Guardrail? ) { self.customPresets = customPresets self.correctionRangeOverrides = correctionRangeOverrides diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index b8fdfcee12..5c82a62441 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -7,6 +7,7 @@ // import Combine +import LoopAlgorithm import LoopCore import LoopKit import LoopKitUI @@ -103,7 +104,7 @@ public class SettingsViewModel: ObservableObject { } } - var preMealGuardrail: Guardrail? { + var preMealGuardrail: Guardrail? { guard let scheduleRange = therapySettings().glucoseTargetRangeSchedule?.scheduleRange() else { return nil } @@ -114,7 +115,7 @@ public class SettingsViewModel: ObservableObject { ) } - var legacyWorkoutPresetGuardrail: Guardrail? { + var legacyWorkoutPresetGuardrail: Guardrail? { guard let scheduleRange = therapySettings().glucoseTargetRangeSchedule?.scheduleRange() else { return nil } diff --git a/Loop/Views/Presets/Components/AdjustedGlucoseRangeView.swift b/Loop/Views/Presets/Components/AdjustedGlucoseRangeView.swift index 2088b4b241..40b100e62a 100644 --- a/Loop/Views/Presets/Components/AdjustedGlucoseRangeView.swift +++ b/Loop/Views/Presets/Components/AdjustedGlucoseRangeView.swift @@ -6,7 +6,7 @@ // Copyright © 2024 LoopKit Authors. All rights reserved. // -import HealthKit +import LoopAlgorithm import LoopKitUI import SwiftUI @@ -14,8 +14,8 @@ struct AdjustedGlucoseRangeView: View { @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - @State var lowerBound: HKQuantity - @State var upperBound: HKQuantity + @State var lowerBound: LoopQuantity + @State var upperBound: LoopQuantity var body: some View { ZStack { diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index aa5c56ebbe..eae6393c6d 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -6,7 +6,7 @@ // Copyright © 2024 LoopKit Authors. All rights reserved. // -import HealthKit +import LoopAlgorithm import LoopKitUI import SwiftUI import LoopKit @@ -20,8 +20,8 @@ struct PresetCard: View { let presetName: String let duration: PresetDurationType let insulinSensitivityMultiplier: Double? - let correctionRange: ClosedRange? - let guardrail: Guardrail? + let correctionRange: ClosedRange? + let guardrail: Guardrail? let expectedEndTime: PresetExpectedEndTime? private var numberFormatter: NumberFormatter { @@ -86,7 +86,7 @@ struct PresetCard: View { } } - func annotatedRangeText(target: ClosedRange) -> some View { + func annotatedRangeText(target: ClosedRange) -> some View { let lowerColor = guardrail?.color(for: target.lowerBound, guidanceColors: guidanceColors) ?? .primary let upperColor = guardrail?.color(for: target.upperBound, guidanceColors: guidanceColors) ?? .primary diff --git a/Loop/Views/Presets/Components/TherapySettingsExampleView.swift b/Loop/Views/Presets/Components/TherapySettingsExampleView.swift index bfb9e67575..e33029e65b 100644 --- a/Loop/Views/Presets/Components/TherapySettingsExampleView.swift +++ b/Loop/Views/Presets/Components/TherapySettingsExampleView.swift @@ -6,7 +6,7 @@ // Copyright © 2024 LoopKit Authors. All rights reserved. // -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI import SwiftUI @@ -46,13 +46,13 @@ struct TherapySettingsExampleView: View { .fontWeight(.semibold) VStack(alignment: .leading, spacing: 6) { - if let basalRateValue = basalRateFormatter.string(from: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: basalRate)) { + if let basalRateValue = basalRateFormatter.string(from: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: basalRate)) { Text(basalRateValue) } Text("\(numberFormatter.string(from: carbRatio) ?? "0") g/U") - Text(displayGlucosePreference.format(HKQuantity(unit: .milligramsPerDeciliter, doubleValue: isf))) + Text(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: isf))) } } .font(.subheadline) diff --git a/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift b/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift index 7f8ec50c9f..573fd9ad91 100644 --- a/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift +++ b/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift @@ -6,7 +6,7 @@ // Copyright © 2024 LoopKit Authors. All rights reserved. // -import HealthKit +import LoopAlgorithm import LoopKitUI import SwiftUI @@ -37,8 +37,8 @@ struct PresetsAndExerciseContentView: View { } } - private let lowerBound = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 140) - private let upperBound = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 160) + private let lowerBound = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140) + private let upperBound = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 160) @ViewBuilder var stepOneView: some View { @@ -143,8 +143,8 @@ struct PresetsAndExerciseContentView: View { insulinSensitivityMultiplier: 1.0, correctionRange: ClosedRange( uncheckedBounds: ( - HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 140), - HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 260)) + LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140), + LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 260)) ), guardrail: nil, expectedEndTime: .indefinite diff --git a/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift b/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift index c7ae31ee15..7dbf13ffa3 100644 --- a/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift +++ b/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift @@ -6,7 +6,7 @@ // Copyright © 2024 LoopKit Authors. All rights reserved. // -import HealthKit +import LoopAlgorithm import LoopKit import LoopKitUI import SwiftUI @@ -38,8 +38,8 @@ struct PresetsAndIllnessContentView: View { } } - private let lowerBound = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 130) - private let upperBound = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 140) + private let lowerBound = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 130) + private let upperBound = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140) private let basalRateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) @@ -74,7 +74,7 @@ struct PresetsAndIllnessContentView: View { PercentPickerView(value: .constant(110)) ImpactView { - if let string = try? AttributedString(markdown: "**Basal** Rate was \(basalRateFormatter.string(from: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: 0.5)) ?? "0") and will be \(basalRateFormatter.string(from: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: 0.6)) ?? "0")\n**Carb Ratio** was 13 g and will be 11.7 g\n**ISF** was \(displayGlucosePreference.format(HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 50))) and will be \(displayGlucosePreference.format(HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 45)))", options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { + if let string = try? AttributedString(markdown: "**Basal** Rate was \(basalRateFormatter.string(from: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 0.5)) ?? "0") and will be \(basalRateFormatter.string(from: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 0.6)) ?? "0")\n**Carb Ratio** was 13 g and will be 11.7 g\n**ISF** was \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 50))) and will be \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 45)))", options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { Text(string) .font(.subheadline) .fixedSize(horizontal: false, vertical: true) @@ -88,7 +88,7 @@ struct PresetsAndIllnessContentView: View { Text("Correction Range", comment: "Presets and illness training content, correction range, subtitle") .font(.title2.bold()) - Text("Paloma’s normal correction range is set to \(displayGlucosePreference.format(lowerQuantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 105), higherQuantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 110))).", comment: "Presets and illness training content, correction range, paragraph 1") + Text("Paloma’s normal correction range is set to \(displayGlucosePreference.format(lowerQuantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 105), higherQuantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 110))).", comment: "Presets and illness training content, correction range, paragraph 1") } if let string = try? AttributedString(markdown: "In this scenario, Paloma will increase her correction range to **\(displayGlucosePreference.format(lowerQuantity: lowerBound, higherQuantity: upperBound))** to prevent drops due to eating less or not absorbing what she eats while sick.") { From 84ebeb3874cd1158155b6a0a25d678c06a8a7326 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 9 Dec 2024 09:49:06 -0800 Subject: [PATCH 189/421] [LOOP-4754] Presets Storage (#729) --- Loop.xcodeproj/project.pbxproj | 10 ++-- Loop/Managers/LoopAppManager.swift | 3 +- Loop/Managers/TemporaryPresetsManager.swift | 8 +-- Loop/Views/Presets/PresetsHistoryView.swift | 52 +++++++++++++++++++ Loop/Views/Presets/PresetsView.swift | 5 +- LoopCore/NSUserDefaults.swift | 12 ----- .../TemporaryPresetsManagerTests.swift | 2 +- 7 files changed, 70 insertions(+), 22 deletions(-) create mode 100644 Loop/Views/Presets/PresetsHistoryView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 142f6f6eb3..2eb821b885 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -269,6 +269,7 @@ 84E8BBCE2CCA1E070078E6CF /* PresetsTrainingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */; }; 84E8BBD02CCA279B0078E6CF /* Image+Exists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */; }; 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; + 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */; }; 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */; }; 892A5D59222F0A27008961AB /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */; }; @@ -1145,6 +1146,7 @@ 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingCard.swift; sourceTree = ""; }; 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Exists.swift"; sourceTree = ""; }; 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; + 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsHistoryView.swift; sourceTree = ""; }; 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlowViewModel.swift; sourceTree = ""; }; 892A5D29222EF60A008961AB /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKit.framework; path = Carthage/Build/iOS/MockKit.framework; sourceTree = SOURCE_ROOT; }; 892A5D2B222EF60A008961AB /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKitUI.framework; path = Carthage/Build/iOS/MockKitUI.framework; sourceTree = SOURCE_ROOT; }; @@ -2484,6 +2486,7 @@ isa = PBXGroup; children = ( 84E8BBC92CCA16290078E6CF /* PresetsView.swift */, + 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */, 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */, 84E8BBC22CC9B9780078E6CF /* Components */, 84E8BBB62CC990480078E6CF /* Training Content */, @@ -3657,6 +3660,7 @@ 84E8BBC82CC9D34B0078E6CF /* TherapySettingsExampleView.swift in Sources */, 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */, + 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */, C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */, A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, @@ -4751,7 +4755,7 @@ TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; WARNING_CFLAGS = "-Wall"; - WATCHOS_DEPLOYMENT_TARGET = 8.0; + WATCHOS_DEPLOYMENT_TARGET = 10.6; }; name = Debug; }; @@ -4861,7 +4865,7 @@ VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; WARNING_CFLAGS = "-Wall"; - WATCHOS_DEPLOYMENT_TARGET = 8.0; + WATCHOS_DEPLOYMENT_TARGET = 10.6; }; name = Release; }; @@ -5297,7 +5301,7 @@ TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; WARNING_CFLAGS = "-Wall"; - WATCHOS_DEPLOYMENT_TARGET = 8.0; + WATCHOS_DEPLOYMENT_TARGET = 10.6; }; name = Testflight; }; diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index c83414ef54..317606c228 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -877,7 +877,8 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { extension LoopAppManager: TemporaryScheduleOverrideHistoryDelegate { func temporaryScheduleOverrideHistoryDidUpdate(_ history: TemporaryScheduleOverrideHistory) { - UserDefaults.appGroup?.overrideHistory = history + TemporaryScheduleOverrideHistoryContainer.shared.deleteAll() + TemporaryScheduleOverrideHistoryContainer.shared.context.insert(history) remoteDataServicesManager.triggerUpload(for: .overrides) } } diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 31bb4af3a1..08fc198924 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -22,16 +22,18 @@ class TemporaryPresetsManager { private var settingsProvider: SettingsProvider - var overrideHistory = UserDefaults.appGroup?.overrideHistory ?? TemporaryScheduleOverrideHistory.init() + var overrideHistory: TemporaryScheduleOverrideHistory private var presetActivationObservers: [PresetActivationObserver] = [] private var overrideIntentObserver: NSKeyValueObservation? = nil + @MainActor init(settingsProvider: SettingsProvider) { self.settingsProvider = settingsProvider - - self.overrideHistory.relevantTimeWindow = LoopCoreConstants.defaultCarbAbsorptionTimes.slow * 2 + + self.overrideHistory = TemporaryScheduleOverrideHistoryContainer.shared.fetch() + TemporaryScheduleOverrideHistory.relevantTimeWindow = Bundle.main.localCacheDuration scheduleOverride = overrideHistory.activeOverride(at: Date()) diff --git a/Loop/Views/Presets/PresetsHistoryView.swift b/Loop/Views/Presets/PresetsHistoryView.swift new file mode 100644 index 0000000000..265f0f6f40 --- /dev/null +++ b/Loop/Views/Presets/PresetsHistoryView.swift @@ -0,0 +1,52 @@ +// +// PresetsHistoryView.swift +// Loop +// +// Created by Cameron Ingham on 11/27/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKit +import SwiftUI + +struct PresetsHistoryView: View { + + @State var history: TemporaryScheduleOverrideHistory + + init () { + self.history = TemporaryScheduleOverrideHistoryContainer.shared.fetch() + } + + let formatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.unitsStyle = .short + return formatter + }() + + var body: some View { + List { + Section("Recent Events") { + ForEach(history.recentEvents.sorted(by: { $0.override.actualEndDate > $1.override.actualEndDate }), id: \.self) { recentEvent in + + let scheduledDuration = recentEvent.override.duration.timeInterval + let actualDuration = recentEvent.override.actualDuration.timeInterval + + let value = scheduledDuration == actualDuration ? "\(formatter.string(from: scheduledDuration) ?? "")" : "\(formatter.string(from: actualDuration) ?? "") / \(formatter.string(from: scheduledDuration) ?? "")" + + LabeledContent { + Text(value) + } label: { + Text(recentEvent.override.presetId) + + Text(recentEvent.override.startDate.formatted(date: .abbreviated, time: .shortened)) + } + } + } + } + } +} + +#Preview { + PresetsHistoryView() +} diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index ed97af3f2a..8c6564b96a 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -6,8 +6,9 @@ // Copyright © 2024 LoopKit Authors. All rights reserved. // -import SwiftUI import Foundation +import LoopKit +import SwiftUI enum PresetSortOption: Int, CaseIterable { case name @@ -107,7 +108,7 @@ struct PresetsView: View { Text("Support") .font(.title2.bold()) - NavigationLink(destination: EmptyView()) { + NavigationLink(destination: PresetsHistoryView()) { HStack { Image(systemName: "list.bullet") .foregroundColor(.white) diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index beb9446061..1f0428bd98 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -77,18 +77,6 @@ extension UserDefaults { } } - public var overrideHistory: TemporaryScheduleOverrideHistory? { - get { - if let rawValue = object(forKey: Key.overrideHistory.rawValue) as? TemporaryScheduleOverrideHistory.RawValue { - return TemporaryScheduleOverrideHistory(rawValue: rawValue) - } else { - return nil - } - } - set { - set(newValue?.rawValue, forKey: Key.overrideHistory.rawValue) - } - } public var lastBedtimeQuery: Date? { get { diff --git a/LoopTests/Managers/TemporaryPresetsManagerTests.swift b/LoopTests/Managers/TemporaryPresetsManagerTests.swift index 492762864f..a1e097c76f 100644 --- a/LoopTests/Managers/TemporaryPresetsManagerTests.swift +++ b/LoopTests/Managers/TemporaryPresetsManagerTests.swift @@ -30,7 +30,7 @@ class TemporaryPresetsManagerTests: XCTestCase { override func setUp() async throws { let settingsProvider = MockSettingsProvider(settings: settings) - manager = TemporaryPresetsManager(settingsProvider: settingsProvider) + manager = await TemporaryPresetsManager(settingsProvider: settingsProvider) } func testPreMealOverride() { From ce7c83faa54d68e0fd8879a271975f9c16f87280 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 12 Dec 2024 10:35:53 -0800 Subject: [PATCH 190/421] [LOOP-5056] Presets Homepage Updates - Part 1 (#733) --- Loop.xcodeproj/project.pbxproj | 12 + Loop/Managers/LoopAppManager.swift | 46 +-- Loop/Managers/TemporaryPresetsManager.swift | 17 +- .../StatusTableViewController.swift | 357 +++++++++++------- Loop/View Models/PresetsViewModel.swift | 61 ++- Loop/View Models/SettingsViewModel.swift | 118 +++--- .../Views/Presets/Components/PresetCard.swift | 110 +----- .../Presets/Components/PresetDetentView.swift | 175 +++++++++ .../Presets/Components/PresetStatsView.swift | 128 +++++++ Loop/Views/Presets/PresetsView.swift | 25 +- Loop/Views/SettingsView.swift | 19 +- Loop/Views/StatusTableView.swift | 315 ++++++++++++++++ Loop/Views/TitleSubtitleTableViewCell.swift | 2 +- LoopUI/Extensions/UIColor.swift | 2 +- 14 files changed, 1029 insertions(+), 358 deletions(-) create mode 100644 Loop/Views/Presets/Components/PresetDetentView.swift create mode 100644 Loop/Views/Presets/Components/PresetStatsView.swift create mode 100644 Loop/Views/StatusTableView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 2eb821b885..8a75eb14b3 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -253,6 +253,8 @@ 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E62A4A4DEF000B658B /* PumpView.swift */; }; 84C170ED2CCA362A0098E52F /* ImpactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EC2CCA361F0098E52F /* ImpactView.swift */; }; 84C170EF2CCA37680098E52F /* PresetCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EE2CCA37680098E52F /* PresetCard.swift */; }; + 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A62D09053A00CB271F /* StatusTableView.swift */; }; + 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A82D09800700CB271F /* PresetDetentView.swift */; }; 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; 84E8BBAE2CC9791E0078E6CF /* PresetsTrainingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */; }; 84E8BBB12CC979820078E6CF /* PresetsTrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */; }; @@ -269,6 +271,7 @@ 84E8BBCE2CCA1E070078E6CF /* PresetsTrainingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */; }; 84E8BBD02CCA279B0078E6CF /* Image+Exists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */; }; 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; + 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */; }; 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */; }; 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */; }; 892A5D59222F0A27008961AB /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; @@ -1130,6 +1133,8 @@ 84AA81E62A4A4DEF000B658B /* PumpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpView.swift; sourceTree = ""; }; 84C170EC2CCA361F0098E52F /* ImpactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImpactView.swift; sourceTree = ""; }; 84C170EE2CCA37680098E52F /* PresetCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetCard.swift; sourceTree = ""; }; + 84D1F1A62D09053A00CB271F /* StatusTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableView.swift; sourceTree = ""; }; + 84D1F1A82D09800700CB271F /* PresetDetentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDetentView.swift; sourceTree = ""; }; 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.swift; sourceTree = ""; }; 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingViewModel.swift; sourceTree = ""; }; 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingView.swift; sourceTree = ""; }; @@ -1146,6 +1151,7 @@ 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingCard.swift; sourceTree = ""; }; 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Exists.swift"; sourceTree = ""; }; 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; + 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetStatsView.swift; sourceTree = ""; }; 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsHistoryView.swift; sourceTree = ""; }; 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlowViewModel.swift; sourceTree = ""; }; 892A5D29222EF60A008961AB /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKit.framework; path = Carthage/Build/iOS/MockKit.framework; sourceTree = SOURCE_ROOT; }; @@ -2214,6 +2220,7 @@ DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */, + 84D1F1A62D09053A00CB271F /* StatusTableView.swift */, ); path = Views; sourceTree = ""; @@ -2515,6 +2522,8 @@ 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */, 84E8BBB42CC98C3F0078E6CF /* PresetsTrainingContentContainerView.swift */, 84C170EE2CCA37680098E52F /* PresetCard.swift */, + 84D1F1A82D09800700CB271F /* PresetDetentView.swift */, + 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */, ); path = Components; sourceTree = ""; @@ -3516,6 +3525,7 @@ 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */, 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */, + 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, @@ -3567,6 +3577,7 @@ B455C7332BD14E25002B847E /* Comparable.swift in Sources */, A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */, A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */, + 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */, 1DB1065124467E18005542BD /* AlertManager.swift in Sources */, 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */, 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */, @@ -3613,6 +3624,7 @@ A96DAC2C2838F31200D94E38 /* SharedLogging.swift in Sources */, 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */, 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */, + 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */, 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */, 14C9707E2C5A9EB600E8A01B /* GlucoseCarbChartView.swift in Sources */, 43E93FB61E469A4000EAB8DB /* NumberFormatter.swift in Sources */, diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 317606c228..d6b71ba930 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -17,6 +17,7 @@ import HealthKit import WidgetKit import LoopCore import LoopAlgorithm +import SwiftUI #if targetEnvironment(simulator) enum SimulatorError: Error { @@ -544,26 +545,27 @@ class LoopAppManager: NSObject { dispatchPrecondition(condition: .onQueue(.main)) precondition(state == .launchHomeScreen) - let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: Self.self)) - let statusTableViewController = storyboard.instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController - statusTableViewController.alertPermissionsChecker = alertPermissionsChecker - statusTableViewController.alertMuter = alertManager.alertMuter - statusTableViewController.automaticDosingStatus = automaticDosingStatus - statusTableViewController.deviceManager = deviceDataManager - statusTableViewController.onboardingManager = onboardingManager - statusTableViewController.supportManager = supportManager - statusTableViewController.testingScenariosManager = testingScenariosManager - 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) + let statusTableView = StatusTableView( + displayGlucosePreference: displayGlucosePreference, + alertPermissionsChecker: alertPermissionsChecker, + alertMuter: alertManager.alertMuter, + automaticDosingStatus: automaticDosingStatus, + deviceDataManager: deviceDataManager, + onboardingManager: onboardingManager, + supportManager: supportManager, + testingScenariosManager: testingScenariosManager, + settingsManager: settingsManager, + temporaryPresetsManager: temporaryPresetsManager, + loopDataManager: loopDataManager, + diagnosticReportGenerator: self, + simulatedData: self, + analyticsServicesManager: analyticsServicesManager, + servicesManager: servicesManager, + carbStore: carbStore, + doseStore: doseStore, + criticalEventLogExportManager: criticalEventLogExportManager, + bluetoothStateManager: bluetoothStateManager + ).edgesIgnoringSafeArea(.top) var rootNavigationController = rootViewController as? RootNavigationController if rootNavigationController == nil { @@ -571,7 +573,7 @@ class LoopAppManager: NSObject { rootViewController = rootNavigationController } - rootNavigationController?.setViewControllers([statusTableViewController], animated: true) + rootNavigationController?.setViewControllers([UIHostingController(rootView: statusTableView)], animated: true) await deviceDataManager.refreshDeviceData() @@ -837,7 +839,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { } case NotificationManager.Action.acknowledgeAlert.rawValue: let userInfo = response.notification.request.content.userInfo - if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? Alert.AlertIdentifier, + if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? LoopKit.Alert.AlertIdentifier, let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String { alertManager?.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: alertIdentifier)) } diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 08fc198924..584b0f8366 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -16,17 +16,18 @@ protocol PresetActivationObserver: AnyObject { func presetDeactivated(context: TemporaryScheduleOverride.Context) } +@Observable class TemporaryPresetsManager { - private let log = OSLog(category: "TemporaryPresetsManager") + @ObservationIgnored private let log = OSLog(category: "TemporaryPresetsManager") - private var settingsProvider: SettingsProvider + @ObservationIgnored private var settingsProvider: SettingsProvider var overrideHistory: TemporaryScheduleOverrideHistory - private var presetActivationObservers: [PresetActivationObserver] = [] + @ObservationIgnored private var presetActivationObservers: [PresetActivationObserver] = [] - private var overrideIntentObserver: NSKeyValueObservation? = nil + @ObservationIgnored private var overrideIntentObserver: NSKeyValueObservation? = nil @MainActor init(settingsProvider: SettingsProvider) { @@ -87,7 +88,7 @@ class TemporaryPresetsManager { } 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") +// preconditionFailure("The `scheduleOverride` field should not be used for a pre-meal target range override; use `preMealOverride` instead") } if scheduleOverride != oldValue { @@ -198,12 +199,12 @@ class TemporaryPresetsManager { ) } - public func enableLegacyWorkoutOverride(at date: Date = Date(), for duration: TimeInterval) { + public func enableLegacyWorkoutOverride(at date: Date = Date(), for duration: TemporaryScheduleOverride.Duration) { scheduleOverride = legacyWorkoutOverride(beginningAt: date, for: duration) preMealOverride = nil } - public func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + public func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TemporaryScheduleOverride.Duration) -> TemporaryScheduleOverride? { guard let legacyWorkoutTargetRange = settingsProvider.settings.workoutTargetRange else { return nil } @@ -212,7 +213,7 @@ class TemporaryPresetsManager { context: .legacyWorkout, settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), startDate: date, - duration: duration.isInfinite ? .indefinite : .finite(duration), + duration: duration, enactTrigger: .local, syncIdentifier: UUID() ) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 3ea9ba1c59..7d8638153f 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -69,8 +69,78 @@ final class StatusTableViewController: LoopChartsTableViewController { var criticalEventLogExportManager: CriticalEventLogExportManager! + lazy var settingsViewModel: SettingsViewModel = { + let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in + (self?.deviceManager.pumpManager is TestingPumpManager) ? { + Task { [weak self] in try? await self?.deviceManager.deleteTestingPumpData() + }} : nil + } + let deleteCGMDataFunc: () -> CGMManagerViewModel.DeleteTestingDataFunc? = { [weak self] in + (self?.deviceManager.cgmManager is TestingCGMManager) ? { + Task { [weak self] in try? await self?.deviceManager.deleteTestingCGMData() + }} : nil + } + let pumpViewModel = PumpManagerViewModel( + 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, + deleteTestingDataFunc: deletePumpDataFunc, + onTapped: { [weak self] in + self?.onPumpTapped() + }, + didTapAddDevice: { [weak self] in + self?.addPumpManager(withIdentifier: $0.identifier) + }) + let cgmViewModel = CGMManagerViewModel( + image: {[weak self] in (self?.deviceManager.cgmManager as? DeviceManagerUI)?.smallImage }, + name: {[weak self] in self?.deviceManager.cgmManager?.localizedTitle ?? "" }, + isSetUp: {[weak self] in self?.deviceManager.cgmManager?.isOnboarded == true }, + availableDevices: deviceManager.availableCGMManagers, + deleteTestingDataFunc: deleteCGMDataFunc, + onTapped: { [weak self] in + self?.onCGMTapped() + }, + didTapAddDevice: { [weak self] in + self?.addCGMManager(withIdentifier: $0.identifier) + }) + let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled, + availableServices: { [weak self] in self?.servicesManager.availableServices ?? [] }, + activeServices: { [weak self] in self?.servicesManager.activeServices ?? [] }, + delegate: self) + let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) + + let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, + alertMuter: alertMuter, + versionUpdateViewModel: versionUpdateViewModel, + pumpManagerSettingsViewModel: pumpViewModel, + cgmManagerSettingsViewModel: cgmViewModel, + servicesViewModel: servicesViewModel, + criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), + therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, + sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, + initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, + automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, + lastLoopCompletion: loopManager.$lastLoopCompleted, + mostRecentGlucoseDataDate: loopManager.$publishedMostRecentGlucoseDataDate, + mostRecentPumpDataDate: loopManager.$publishedMostRecentPumpDataDate, + availableSupports: supportManager.availableSupports, + isOnboardingComplete: onboardingManager.isComplete, + therapySettingsViewModelDelegate: deviceManager, + presetHistory: temporaryPresetsManager.overrideHistory, + temporaryPresetsManager: temporaryPresetsManager, + delegate: self + ) + + viewModel.favoriteFoodInsightsDelegate = loopManager + + return viewModel + }() + lazy private var cancellables = Set() + + var statusBarBackgroundView: UIView? override func viewDidLoad() { @@ -199,8 +269,7 @@ final class StatusTableViewController: LoopChartsTableViewController { addScenarioStepGestureRecognizers() - tableView.backgroundColor = .secondarySystemBackground - + setupPresetsStatusBar() } override func didReceiveMemoryWarning() { @@ -312,6 +381,17 @@ final class StatusTableViewController: LoopChartsTableViewController { } } } + + private func setupPresetsStatusBar() { + let backgroundContainerView = UIView() + backgroundContainerView.backgroundColor = .secondarySystemBackground + let statusBarBackgroundView = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 0)) + self.statusBarBackgroundView = statusBarBackgroundView + backgroundContainerView.addSubview(statusBarBackgroundView) + tableView.backgroundView = backgroundContainerView + + updateStatusBar() + } private var bolusProgressReporter: DoseProgressReporter? @@ -389,6 +469,10 @@ final class StatusTableViewController: LoopChartsTableViewController { private var refreshContext = RefreshContext.all + private var shouldShowPresets: Bool { + presetsRowMode.hasRow + } + private var shouldShowHUD: Bool { return !landscapeMode } @@ -649,6 +733,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } private enum Section: Int, CaseIterable { + case presets case alertWarning case hud case status @@ -681,14 +766,31 @@ final class StatusTableViewController: LoopChartsTableViewController { private var currentCOBDescription: String? // MARK: - Loop Status Section Data + + private enum PresetsRow: Int, CaseIterable { + case presets = 0 + } + private enum PresetsRowMode { + case hidden + case scheduleOverrideEnabled(TemporaryScheduleOverride) + + var hasRow: Bool { + switch self { + case .hidden: + return false + default: + return true + } + } + } + private enum StatusRow: Int, CaseIterable { case status = 0 } private enum StatusRowMode { case hidden - case scheduleOverrideEnabled(TemporaryScheduleOverride) case enactingBolus case bolusing(dose: DoseEntry) case cancelingBolus @@ -707,10 +809,19 @@ final class StatusTableViewController: LoopChartsTableViewController { } } + private var presetsRowMode = PresetsRowMode.hidden private var statusRowMode = StatusRowMode.hidden private var canceledDose: DoseEntry? = nil + private func determinePresetsRowMode() -> PresetsRowMode { + if let preset = temporaryPresetsManager.scheduleOverride ?? temporaryPresetsManager.preMealOverride, !preset.hasFinished() { + return .scheduleOverrideEnabled(preset) + } else { + return .hidden + } + } + private func determineStatusRowMode() -> StatusRowMode { let statusRowMode: StatusRowMode @@ -731,14 +842,6 @@ final class StatusTableViewController: LoopChartsTableViewController { statusRowMode = .onboardingSuspended } else if onboardingManager.isComplete, deviceManager.isGlucoseValueStale { statusRowMode = .recommendManualGlucoseEntry - } else if let scheduleOverride = temporaryPresetsManager.scheduleOverride, - !scheduleOverride.hasFinished() - { - statusRowMode = .scheduleOverrideEnabled(scheduleOverride) - } else if let premealOverride = temporaryPresetsManager.preMealOverride, - !premealOverride.hasFinished() - { - statusRowMode = .scheduleOverrideEnabled(premealOverride) } else { statusRowMode = .hidden } @@ -749,6 +852,10 @@ final class StatusTableViewController: LoopChartsTableViewController { private var shouldShowBannerWarning: Bool { alertPermissionsChecker.showWarning || alertMuter.configuration.shouldMute } + + override func viewDidLayoutSubviews() { + updateStatusBar() + } private func updateBannerRow(animated: Bool) { let warningWasVisible = tableView.numberOfRows(inSection: Section.alertWarning.rawValue) != 0 @@ -760,19 +867,27 @@ final class StatusTableViewController: LoopChartsTableViewController { tableView.reloadRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: .none) } } + + private func updateStatusBar() { + statusBarBackgroundView?.backgroundColor = shouldShowPresets ? .presets : .secondarySystemBackground + statusBarBackgroundView?.frame.size.height = abs(tableView.contentOffset.y) + (shouldShowPresets ? tableView(tableView, cellForRowAt: IndexPath(row: 0, section: 0)).contentView.frame.height + 8 : 0) + } private func updateBannerAndHUDandStatusRows(statusRowMode: StatusRowMode, newSize: CGSize?, animated: Bool) { + let presetsWasVisible = self.shouldShowPresets let hudWasVisible = self.shouldShowHUD let statusWasVisible = self.shouldShowStatus let oldStatusRowMode = self.statusRowMode + self.presetsRowMode = determinePresetsRowMode() self.statusRowMode = statusRowMode if let newSize = newSize { landscapeMode = newSize.width > newSize.height } + let presetsIsVisible = self.shouldShowPresets let hudIsVisible = self.shouldShowHUD let statusIsVisible = self.shouldShowStatus @@ -782,7 +897,16 @@ final class StatusTableViewController: LoopChartsTableViewController { tableView.beginUpdates() updateBannerRow(animated: animated) - + + switch (presetsWasVisible, presetsIsVisible) { + case (false, true): + tableView.insertRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .top : .none) + case (true, false): + tableView.deleteRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .top : .none) + default: + break + } + switch (hudWasVisible, hudIsVisible) { case (false, true): tableView.insertRows(at: [IndexPath(row: 0, section: Section.hud.rawValue)], with: animated ? .top : .none) @@ -807,7 +931,7 @@ final class StatusTableViewController: LoopChartsTableViewController { if oldDose.syncIdentifier != newDose.syncIdentifier { tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } - case (.cancelingBolus, .bolusing(let oldDose)): + case (.cancelingBolus, .bolusing): // this occurs when a cancel command fails tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) case (.canceledBolus(let oldDose), .canceledBolus(let newDose)): @@ -872,7 +996,7 @@ final class StatusTableViewController: LoopChartsTableViewController { updateToolbarItems() } - private var workoutMode: Bool? = nil { + private(set) var workoutMode: Bool? = nil { didSet { guard oldValue != workoutMode else { return @@ -893,6 +1017,8 @@ final class StatusTableViewController: LoopChartsTableViewController { override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch Section(rawValue: section)! { + case .presets: + return shouldShowPresets ? PresetsRow.allCases.count : 0 case .alertWarning: return shouldShowBannerWarning ? 1 : 0 case .hud: @@ -994,6 +1120,64 @@ final class StatusTableViewController: LoopChartsTableViewController { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch Section(rawValue: indexPath.section)! { + case .presets: + func getTitleSubtitleCell() -> TitleSubtitleTableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: TitleSubtitleTableViewCell.className, for: indexPath) as! TitleSubtitleTableViewCell + cell.selectionStyle = .none + cell.backgroundColor = .clear + cell.titleLabel.text = nil + cell.titleLabel.textColor = .white + cell.titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + cell.subtitleLabel.text = nil + cell.subtitleLabel.textColor = .white + cell.subtitleLabel.font = .systemFont(ofSize: 15) + cell.accessoryView = nil + cell.gradient.isHidden = true + return cell + } + + let cell = getTitleSubtitleCell() + + switch presetsRowMode { + case .hidden: + break + case .scheduleOverrideEnabled(let override): + switch override.context { + case .preMeal: + let symbolAttachment = NSTextAttachment() + symbolAttachment.image = UIImage(named: "Pre-Meal-symbol")?.withTintColor(.white) + + let attributedString = NSMutableAttributedString(attachment: symbolAttachment) + attributedString.append(NSAttributedString(string: NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)"))) + cell.titleLabel.attributedText = attributedString + case .legacyWorkout: + let symbolAttachment = NSTextAttachment() + symbolAttachment.image = UIImage(named: "workout-symbol")?.withTintColor(.white) + + let attributedString = NSMutableAttributedString(attachment: symbolAttachment) + attributedString.append(NSAttributedString(string: NSLocalizedString(" Workout Preset", comment: "Status row title for workout override enabled (leading space is to separate from symbol)"))) + cell.titleLabel.attributedText = attributedString + case .preset(let preset): + cell.titleLabel.text = String(format: NSLocalizedString("%@ %@", comment: "The format for an active custom preset. (1: preset symbol)(2: preset name)"), preset.symbol, preset.name) + case .custom: + cell.titleLabel.text = NSLocalizedString("Custom Preset", comment: "The title of the cell indicating a generic custom preset is enabled") + } + + if override.isActive() { + switch override.duration { + case .finite: + let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) + cell.subtitleLabel.text = String(format: NSLocalizedString("on until %@", comment: "The format for the description of a custom preset end date"), endTimeText) + case .indefinite: + cell.subtitleLabel.text = nil + } + } else { + let startTimeText = DateFormatter.localizedString(from: override.startDate, dateStyle: .none, timeStyle: .short) + cell.subtitleLabel.text = String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText) + } + } + + return cell case .alertWarning: if alertPermissionsChecker.showWarning { var cell = tableView.dequeueReusableCell(withIdentifier: AlertPermissionsDisabledWarningCell.className, for: indexPath) as! AlertPermissionsDisabledWarningCell @@ -1062,43 +1246,6 @@ final class StatusTableViewController: LoopChartsTableViewController { switch statusRowMode { case .hidden: let cell = getTitleSubtitleCell() - return cell - case .scheduleOverrideEnabled(let override): - let cell = getTitleSubtitleCell() - switch override.context { - case .preMeal: - let symbolAttachment = NSTextAttachment() - symbolAttachment.image = UIImage(named: "Pre-Meal-symbol")?.withTintColor(.carbTintColor) - - let attributedString = NSMutableAttributedString(attachment: symbolAttachment) - attributedString.append(NSAttributedString(string: NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)"))) - cell.titleLabel.attributedText = attributedString - case .legacyWorkout: - let symbolAttachment = NSTextAttachment() - symbolAttachment.image = UIImage(named: "workout-symbol")?.withTintColor(.glucoseTintColor) - - let attributedString = NSMutableAttributedString(attachment: symbolAttachment) - attributedString.append(NSAttributedString(string: NSLocalizedString(" Workout Preset", comment: "Status row title for workout override enabled (leading space is to separate from symbol)"))) - cell.titleLabel.attributedText = attributedString - case .preset(let preset): - cell.titleLabel.text = String(format: NSLocalizedString("%@ %@", comment: "The format for an active custom preset. (1: preset symbol)(2: preset name)"), preset.symbol, preset.name) - case .custom: - cell.titleLabel.text = NSLocalizedString("Custom Preset", comment: "The title of the cell indicating a generic custom preset is enabled") - } - - if override.isActive() { - switch override.duration { - case .finite: - let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) - cell.subtitleLabel.text = String(format: NSLocalizedString("until %@", comment: "The format for the description of a custom preset end date"), endTimeText) - case .indefinite: - cell.subtitleLabel.text = nil - } - } else { - let startTimeText = DateFormatter.localizedString(from: override.startDate, dateStyle: .none, timeStyle: .short) - cell.subtitleLabel.text = String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText) - } - return cell case .enactingBolus: let progressCell = tableView.dequeueReusableCell(withIdentifier: BolusProgressTableViewCell.className, for: indexPath) as! BolusProgressTableViewCell @@ -1195,7 +1342,7 @@ final class StatusTableViewController: LoopChartsTableViewController { cell.setSubtitleLabel(label: nil) } } - case .hud, .status, .alertWarning: + case .presets, .hud, .status, .alertWarning: break } } @@ -1216,13 +1363,15 @@ final class StatusTableViewController: LoopChartsTableViewController { case .iob, .dose, .cob: return max(106, 0.21 * availableSize) } - case .hud, .status, .alertWarning: + case .presets, .hud, .status, .alertWarning: return UITableView.automaticDimension } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch Section(rawValue: indexPath.section)! { + case .presets: + settingsViewModel.presetsViewModel.pendingPreset = settingsViewModel.presetsViewModel.allPresets.first(where: { $0.id == (temporaryPresetsManager.scheduleOverride ?? temporaryPresetsManager.preMealOverride)?.presetId }) case .alertWarning: if alertPermissionsChecker.showWarning { tableView.deselectRow(at: indexPath, animated: true) @@ -1257,16 +1406,6 @@ final class StatusTableViewController: LoopChartsTableViewController { } } } - case .scheduleOverrideEnabled(let override): - switch override.context { - case .preMeal, .legacyWorkout: - break - default: - let vc = AddEditOverrideTableViewController(glucoseUnit: statusCharts.glucose.glucoseUnit) - vc.inputMode = .editOverride(override) - vc.delegate = self - show(vc, sender: tableView.cellForRow(at: indexPath)) - } case .bolusing(var dose): bolusState = .canceling updateBannerAndHUDandStatusRows(statusRowMode: .cancelingBolus, newSize: nil, animated: true) @@ -1513,7 +1652,7 @@ final class StatusTableViewController: LoopChartsTableViewController { return item } - @IBAction func premealButtonTapped(_ sender: UIBarButtonItem) { + @IBAction func premealButtonTapped(_ sender: UIBarButtonItem? = nil) { togglePreMealMode() } @@ -1573,12 +1712,12 @@ final class StatusTableViewController: LoopChartsTableViewController { // allow cell animation when switching between presets self.temporaryPresetsManager.clearOverride(matching: .preMeal) DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: duration) + self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: .finite(duration)) } return } - self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: duration) + self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: .finite(duration)) }) present(vc, animated: true, completion: nil) @@ -1588,74 +1727,28 @@ final class StatusTableViewController: LoopChartsTableViewController { presentCustomPresets() } + private(set) var isShowingPresets: Bool = false + + func presentPresets() { + let hostingController = DismissibleHostingController( + rootView: PresetsView(viewModel: settingsViewModel.presetsViewModel) + .onAppear { self.isShowingPresets = true } + .onDisappear { self.isShowingPresets = false } + .environmentObject(deviceManager.displayGlucosePreference) + .environment(\.appName, Bundle.main.bundleDisplayName) + .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) + .environment(\.loopStatusColorPalette, .loopStatus), + isModalInPresentation: false) + present(hostingController, animated: true) + } + @IBAction func onSettingsTapped(_ sender: UIBarButtonItem) { presentSettings() } - private func presentSettings() { - let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in - (self?.deviceManager.pumpManager is TestingPumpManager) ? { - Task { [weak self] in try? await self?.deviceManager.deleteTestingPumpData() - }} : nil - } - let deleteCGMDataFunc: () -> CGMManagerViewModel.DeleteTestingDataFunc? = { [weak self] in - (self?.deviceManager.cgmManager is TestingCGMManager) ? { - Task { [weak self] in try? await self?.deviceManager.deleteTestingCGMData() - }} : nil - } - let pumpViewModel = PumpManagerViewModel( - 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, - deleteTestingDataFunc: deletePumpDataFunc, - onTapped: { [weak self] in - self?.onPumpTapped() - }, - didTapAddDevice: { [weak self] in - self?.addPumpManager(withIdentifier: $0.identifier) - }) - - let cgmViewModel = CGMManagerViewModel( - image: {[weak self] in (self?.deviceManager.cgmManager as? DeviceManagerUI)?.smallImage }, - name: {[weak self] in self?.deviceManager.cgmManager?.localizedTitle ?? "" }, - isSetUp: {[weak self] in self?.deviceManager.cgmManager?.isOnboarded == true }, - availableDevices: deviceManager.availableCGMManagers, - deleteTestingDataFunc: deleteCGMDataFunc, - onTapped: { [weak self] in - self?.onCGMTapped() - }, - didTapAddDevice: { [weak self] in - self?.addCGMManager(withIdentifier: $0.identifier) - }) - let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled, - availableServices: { [weak self] in self?.servicesManager.availableServices ?? [] }, - activeServices: { [weak self] in self?.servicesManager.activeServices ?? [] }, - delegate: self) - let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) - let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, - alertMuter: alertMuter, - versionUpdateViewModel: versionUpdateViewModel, - pumpManagerSettingsViewModel: pumpViewModel, - cgmManagerSettingsViewModel: cgmViewModel, - servicesViewModel: servicesViewModel, - criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), - therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, - sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, - automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, - lastLoopCompletion: loopManager.$lastLoopCompleted, - mostRecentGlucoseDataDate: loopManager.$publishedMostRecentGlucoseDataDate, - mostRecentPumpDataDate: loopManager.$publishedMostRecentPumpDataDate, - availableSupports: supportManager.availableSupports, - isOnboardingComplete: onboardingManager.isComplete, - therapySettingsViewModelDelegate: deviceManager, - presetHistory: temporaryPresetsManager.overrideHistory, - delegate: self - ) - viewModel.favoriteFoodInsightsDelegate = loopManager + func presentSettings() { let hostingController = DismissibleHostingController( - rootView: SettingsView(viewModel: viewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) + rootView: SettingsView(viewModel: settingsViewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucosePreference) .environment(\.appName, Bundle.main.bundleDisplayName) .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) @@ -2301,3 +2394,9 @@ extension StatusTableViewController: ServicesViewModelDelegate { show(settingsViewController, sender: self) } } + +extension StatusTableViewController { + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + updateStatusBar() + } +} diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift index 67d45e31b3..c48562e565 100644 --- a/Loop/View Models/PresetsViewModel.swift +++ b/Loop/View Models/PresetsViewModel.swift @@ -37,8 +37,8 @@ extension TemporaryScheduleOverride { var presetId: String { switch context { - case .preMeal: return "premeal" - case .legacyWorkout: return "legacyworkout" + case .preMeal: return "preMeal" + case .legacyWorkout: return "legacyWorkout" case .custom: return self.syncIdentifier.uuidString case .preset(let preset): return preset.id.uuidString } @@ -59,10 +59,10 @@ enum SelectablePreset: Hashable, Identifiable { case .custom(let preset): hasher.combine(preset) case .legacyWorkout(let range, _): - hasher.combine("legacyworkout") + hasher.combine("legacyWorkout") hasher.combine(range) case .preMeal(let range, _): - hasher.combine("premeal") + hasher.combine("preMeal") hasher.combine(range) } } @@ -161,23 +161,31 @@ enum SelectablePreset: Hashable, Identifiable { } } -class PresetsViewModel: ObservableObject { +@MainActor +@Observable +public class PresetsViewModel { // MARK: Training - @AppStorage("hasCompletedPresetsTraining") var hasCompletedTraining: Bool = false - @AppStorage("presetsSortOrder") var selectedSortOption: PresetSortOption = .name - @AppStorage("presetsSortDirectionReversed") var presetsSortAscending: Bool = true + @ObservationIgnored @AppStorage("hasCompletedPresetsTraining") var hasCompletedTraining: Bool = false + @ObservationIgnored @AppStorage("presetsSortOrder") var selectedSortOption: PresetSortOption = .name + @ObservationIgnored @AppStorage("presetsSortDirectionReversed") var presetsSortAscending: Bool = true - var correctionRangeOverrides: CorrectionRangeOverrides? + @ObservationIgnored var correctionRangeOverrides: CorrectionRangeOverrides? + + let temporaryPresetsManager: TemporaryPresetsManager - @Published var customPresets: [TemporaryScheduleOverridePreset] - @Published var activeOverride: TemporaryScheduleOverride? + var customPresets: [TemporaryScheduleOverridePreset] + var pendingPreset: SelectablePreset? - let preMealGuardrail: Guardrail? - let legacyWorkoutGuardrail: Guardrail? + public private(set) var preMealGuardrail: Guardrail? + public private(set) var legacyWorkoutGuardrail: Guardrail? private var presetHistory: TemporaryScheduleOverrideHistory + var activeOverride: TemporaryScheduleOverride? { + temporaryPresetsManager.preMealOverride ?? temporaryPresetsManager.scheduleOverride + } + var activePreset: SelectablePreset? { return allPresets.first(where: { $0.id == activeOverride?.presetId }) } @@ -229,16 +237,33 @@ class PresetsViewModel: ObservableObject { correctionRangeOverrides: CorrectionRangeOverrides?, presetsHistory: TemporaryScheduleOverrideHistory, preMealGuardrail: Guardrail?, - legacyWorkoutGuardrail: Guardrail? + legacyWorkoutGuardrail: Guardrail?, + temporaryPresetsManager: TemporaryPresetsManager ) { self.customPresets = customPresets self.correctionRangeOverrides = correctionRangeOverrides self.presetHistory = presetsHistory self.preMealGuardrail = preMealGuardrail self.legacyWorkoutGuardrail = legacyWorkoutGuardrail - - // TODO: If active preset changes, data store should update us. - activeOverride = presetsHistory.activeOverride(at: Date()) + self.temporaryPresetsManager = temporaryPresetsManager + } + + func startPreset(_ preset: SelectablePreset) { + switch preset { + case .custom(let temporaryScheduleOverridePreset): + temporaryPresetsManager.scheduleOverride = temporaryScheduleOverridePreset.createOverride(enactTrigger: .local) + case .preMeal: + temporaryPresetsManager.enablePreMealOverride(for: .hours(2)) // FIX TIME + case .legacyWorkout: + temporaryPresetsManager.enableLegacyWorkoutOverride(for: .indefinite) // FIX TIME + } + } + + func endPreset() { + if case .preMeal(_, _) = activePreset { + temporaryPresetsManager.preMealOverride = nil + } else { + temporaryPresetsManager.scheduleOverride = nil + } } - } diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 5c82a62441..c785f2a7ab 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -56,7 +56,8 @@ public protocol SettingsViewModelDelegate: AnyObject { var closedLoopDescriptiveText: String? { get } } -public class SettingsViewModel: ObservableObject { +@Observable +class SettingsViewModel { let alertPermissionsChecker: AlertPermissionsChecker @@ -81,53 +82,36 @@ public class SettingsViewModel: ObservableObject { let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? let presetHistory: TemporaryScheduleOverrideHistory - @Published private(set) var automaticDosingStatus: AutomaticDosingStatus + private(set) var automaticDosingStatus: AutomaticDosingStatus - @Published private(set) var lastLoopCompletion: Date? - @Published private(set) var mostRecentGlucoseDataDate: Date? - @Published private(set) var mostRecentPumpDataDate: Date? + private(set) var lastLoopCompletion: Date? + private(set) var mostRecentGlucoseDataDate: Date? + private(set) var mostRecentPumpDataDate: Date? + + var presetsViewModel: PresetsViewModel var closedLoopDescriptiveText: String? { return delegate?.closedLoopDescriptiveText } - @Published var automaticDosingStrategy: AutomaticDosingStrategy { + var automaticDosingStrategy: AutomaticDosingStrategy { didSet { delegate?.dosingStrategyChanged(automaticDosingStrategy) } } - @Published var closedLoopPreference: Bool { + var closedLoopPreference: Bool { didSet { delegate?.dosingEnabledChanged(closedLoopPreference) } } - var preMealGuardrail: Guardrail? { - guard let scheduleRange = therapySettings().glucoseTargetRangeSchedule?.scheduleRange() else { - return nil - } - return Guardrail.correctionRangeOverride( - for: .preMeal, - correctionRangeScheduleRange: scheduleRange, - suspendThreshold: therapySettings().suspendThreshold - ) - } - - var legacyWorkoutPresetGuardrail: Guardrail? { - guard let scheduleRange = therapySettings().glucoseTargetRangeSchedule?.scheduleRange() else { - return nil - } - return Guardrail.correctionRangeOverride( - for: .workout, - correctionRangeScheduleRange: scheduleRange, - suspendThreshold: therapySettings().suspendThreshold - ) - } - + + var preMealGuardrail: Guardrail? + var legacyWorkoutPresetGuardrail: Guardrail? - weak var favoriteFoodInsightsDelegate: FavoriteFoodInsightsViewModelDelegate? + @ObservationIgnored weak var favoriteFoodInsightsDelegate: FavoriteFoodInsightsViewModelDelegate? var showDeleteTestData: Bool { availableSupports.contains(where: { $0.showsDeleteTestDataUI }) @@ -148,8 +132,9 @@ public class SettingsViewModel: ObservableObject { return LoopCompletionFreshness(age: age) } - lazy private var cancellables = Set() + @ObservationIgnored lazy private var cancellables = Set() + @MainActor public init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, versionUpdateViewModel: VersionUpdateViewModel, @@ -169,6 +154,7 @@ public class SettingsViewModel: ObservableObject { isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, presetHistory: TemporaryScheduleOverrideHistory, + temporaryPresetsManager: TemporaryPresetsManager, delegate: SettingsViewModelDelegate? ) { self.alertPermissionsChecker = alertPermissionsChecker @@ -191,28 +177,29 @@ public class SettingsViewModel: ObservableObject { self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate self.presetHistory = presetHistory self.delegate = delegate + + var preMealGuardrail: Guardrail? + var legacyWorkoutPresetGuardrail: Guardrail? + if let scheduleRange = therapySettings().glucoseTargetRangeSchedule?.scheduleRange() { + preMealGuardrail = Guardrail.correctionRangeOverride( + for: .preMeal, + correctionRangeScheduleRange: scheduleRange, + suspendThreshold: therapySettings().suspendThreshold + ) + self.preMealGuardrail = preMealGuardrail + self.legacyWorkoutPresetGuardrail = legacyWorkoutPresetGuardrail + } + + self.presetsViewModel = PresetsViewModel( + customPresets: therapySettings().overridePresets ?? [], + correctionRangeOverrides: therapySettings().correctionRangeOverrides, + presetsHistory: presetHistory, + preMealGuardrail: preMealGuardrail, + legacyWorkoutGuardrail: legacyWorkoutPresetGuardrail, + temporaryPresetsManager: temporaryPresetsManager + ) // This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject) - alertPermissionsChecker.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) - alertMuter.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) - pumpManagerSettingsViewModel.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) - cgmManagerSettingsViewModel.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) - automaticDosingStatus.objectWillChange.sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &cancellables) lastLoopCompletion .assign(to: \.lastLoopCompletion, on: self) .store(in: &cancellables) @@ -231,6 +218,34 @@ extension SettingsViewModel { fileprivate class FakeLastLoopCompletionPublisher { @Published var mockLastLoopCompletion: Date? = nil } + + fileprivate class FakeSettingsProvider: SettingsProvider { + let settings = StoredSettings() + + func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + [] + } + + func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + [] + } + + func getInsulinSensitivityHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { + [] + } + + func getTargetRangeHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue>] { + [] + } + + func getDosingLimits(at date: Date) async throws -> DosingLimits { + DosingLimits() + } + + func executeSettingsQuery(fromQueryAnchor queryAnchor: SettingsStore.QueryAnchor?, limit: Int, completion: @escaping (SettingsStore.SettingsQueryResult) -> Void) {} + + + } static var preview: SettingsViewModel { return SettingsViewModel(alertPermissionsChecker: AlertPermissionsChecker(), @@ -252,6 +267,7 @@ extension SettingsViewModel { isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, presetHistory: TemporaryScheduleOverrideHistory(), + temporaryPresetsManager: TemporaryPresetsManager(settingsProvider: FakeSettingsProvider()), delegate: nil ) } diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index eae6393c6d..2836be779a 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -23,12 +23,6 @@ struct PresetCard: View { let correctionRange: ClosedRange? let guardrail: Guardrail? let expectedEndTime: PresetExpectedEndTime? - - private var numberFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .percent - return formatter - } var presetTitle: some View { HStack(spacing: 6) { @@ -54,90 +48,6 @@ struct PresetCard: View { .foregroundColor(.secondary) .accessibilityLabel(Text(duration.accessibilityLabel)) } - - var overallInsulinView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Overall Insulin") - .font(.subheadline) - .foregroundColor(.secondary) - .accessibilitySortPriority(2) - - let percent = numberFormatter.string(from: insulinSensitivityMultiplier ?? 1)! - Group { Text(percent).bold() + Text(" of scheduled") } - .font(.subheadline) - .accessibilitySortPriority(1) - } - .accessibilityElement(children: .contain) - } - - func guidanceColor(for classification: SafetyClassification?) -> Color? { - guard let classification else { return nil } - - switch classification { - case .outsideRecommendedRange(let threshold): - switch threshold { - case .aboveRecommended, .belowRecommended: - return guidanceColors.warning - case .maximum, .minimum: - return guidanceColors.critical - } - case .withinRecommendedRange: - return nil - } - } - - func annotatedRangeText(target: ClosedRange) -> some View { - - let lowerColor = guardrail?.color(for: target.lowerBound, guidanceColors: guidanceColors) ?? .primary - let upperColor = guardrail?.color(for: target.upperBound, guidanceColors: guidanceColors) ?? .primary - - let units = Text(" \(displayGlucosePreference.unit.localizedUnitString(in: .medium) ?? displayGlucosePreference.unit.unitString)") - .foregroundStyle(upperColor) - let lower = Text(displayGlucosePreference.format(target.lowerBound, includeUnit: false)) - .foregroundStyle(lowerColor) - .bold() - let upper = Text(displayGlucosePreference.format(target.upperBound, includeUnit: false)) - .foregroundStyle(upperColor) - .bold() - let warningSymbol = Text("\(Image(systemName: "exclamationmark.triangle.fill"))") - - let lowerClassification = guardrail?.classification(for: target.lowerBound) ?? .withinRecommendedRange - let upperClassification = guardrail?.classification(for: target.upperBound) ?? .withinRecommendedRange - - return Group { - switch (lowerClassification, upperClassification) { - case (.withinRecommendedRange, .withinRecommendedRange): - lower + Text(" - ") + upper + units - case (.withinRecommendedRange, .outsideRecommendedRange): - lower + Text(" - ") + warningSymbol.foregroundStyle(upperColor) + upper + units - case (.outsideRecommendedRange, .outsideRecommendedRange): - warningSymbol.foregroundStyle(lowerColor) + lower + Text("-").foregroundStyle(lowerColor) + upper + units - case (.outsideRecommendedRange, .withinRecommendedRange): - warningSymbol.foregroundStyle(lowerColor) + lower + Text("-") + upper + units - } - } - } - - var correctionRangeView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Correction Range") - .font(.subheadline) - .foregroundColor(.secondary) - .accessibilitySortPriority(2) - - Group { - if let target = correctionRange { - annotatedRangeText(target: target) - } else { - Text("Scheduled Range") - .bold() - } - } - .font(.subheadline) - .accessibilitySortPriority(1) - } - .accessibilityElement(children: .contain) - } var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -184,21 +94,11 @@ struct PresetCard: View { Divider() .padding(.horizontal, -10) - ViewThatFits(in: .horizontal) { - HStack(spacing: 0) { - overallInsulinView - - Spacer() - - correctionRangeView - } - - VStack(alignment: .leading, spacing: 16) { - overallInsulinView - - correctionRangeView - } - } + PresetStatsView( + insulinSensitivityMultiplier: insulinSensitivityMultiplier, + correctionRange: correctionRange, + guardrail: guardrail + ) } .padding(10) .background(RoundedRectangle(cornerRadius: 8) diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift new file mode 100644 index 0000000000..2d65e81590 --- /dev/null +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -0,0 +1,175 @@ +// +// PresetDetentView.swift +// Loop +// +// Created by Cameron Ingham on 12/11/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import SwiftUI + +struct PresetDetentView: View { + + enum Operation { + case start + case end + } + + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.dismiss) private var dismiss + + let preset: SelectablePreset + let viewModel: PresetsViewModel + + let activeOverride: TemporaryScheduleOverride? + + init(viewModel: PresetsViewModel, preset: SelectablePreset) { + self.viewModel = viewModel + self.preset = preset + + self.activeOverride = viewModel.temporaryPresetsManager.preMealOverride ?? viewModel.temporaryPresetsManager.scheduleOverride + } + + var operation: Operation { + if activeOverride?.presetId == preset.id { + return .end + } else { + return .start + } + } + + private func title(font: Font, iconSize: Double) -> some View { + HStack(spacing: 6) { + switch preset.icon { + case .emoji(let emoji): + Text(emoji) + case .image(let name, let iconColor): + Image(name) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(iconColor) + .frame(width: UIFontMetrics.default.scaledValue(for: iconSize), height: UIFontMetrics.default.scaledValue(for: iconSize)) + } + + Text(preset.name) + .font(font) + .fontWeight(.semibold) + } + } + + @ViewBuilder + private var subtitle: some View { + Group { + switch operation { + case .start: + Text("Duration: \(preset.duration.localizedTitle)") + case .end: + if let activeOverride { + if activeOverride.presetId == preset.id { + switch activeOverride.duration { + case .finite: + let endTimeText = DateFormatter.localizedString(from: activeOverride.activeInterval.end, dateStyle: .none, timeStyle: .short) + Text(String(format: NSLocalizedString("on until %@", comment: "The format for the description of a custom preset end date"), endTimeText)) + case .indefinite: + EmptyView() + } + } else { + let startTimeText = DateFormatter.localizedString(from: activeOverride.startDate, dateStyle: .none, timeStyle: .short) + Text(String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText)) + } + } + } + } + .font(.subheadline) + } + + @ViewBuilder + var actionArea: some View { + VStack(spacing: 12) { + switch operation { + case .start: + Button("Start Preset") { + dismiss() + viewModel.startPreset(preset) + } + .buttonStyle(ActionButtonStyle()) + case .end: + Button("End Preset") { + dismiss() + viewModel.endPreset() + } + .buttonStyle(ActionButtonStyle(.destructive)) + + NavigationLink("Adjust Preset Duration") { + ZStack { + Color(UIColor.secondarySystemBackground) + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 24) { + title(font: .largeTitle, iconSize: 36) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + + DatePicker("On until", selection: .constant(Date()), displayedComponents: .hourAndMinute) + .padding(6) + .padding(.leading, 10) + .background(Color.white.cornerRadius(10)) + + Spacer() + } + .padding(.horizontal) + } + } + .buttonStyle(ActionButtonStyle(.tertiary)) + } + + Button("Close") { + dismiss() + } + .tint(.accentColor) + .fontWeight(.semibold) + } + } + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + VStack(spacing: 16) { + VStack(spacing: 4) { + title(font: .title2, iconSize: 20) + subtitle + } + + if operation == .start { + Button { + print("Edit \(preset.name)") + } label: { + Group { + Text(Image(systemName: "pencil")) + Text(" ") + Text("Edit Preset") + } + .font(.subheadline) + } + .tint(.accentColor) + .padding(.bottom, -8) + } + } + + Divider() + + PresetStatsView( + insulinSensitivityMultiplier: preset.insulinSensitivityMultiplier, + correctionRange: preset.correctionRange, + guardrail: preset.guardrail + ) + + actionArea + } + .toolbar(.hidden) + .padding(.top) + .padding(16) + .presentationHuggingDetent() + } + } +} diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift new file mode 100644 index 0000000000..6cb5558e64 --- /dev/null +++ b/Loop/Views/Presets/Components/PresetStatsView.swift @@ -0,0 +1,128 @@ +// +// PresetStatsView.swift +// Loop +// +// Created by Cameron Ingham on 12/11/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +struct PresetStatsView: View { + @Environment(\.guidanceColors) private var guidanceColors + @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + + let insulinSensitivityMultiplier: Double? + let correctionRange: ClosedRange? + let guardrail: Guardrail? + + private var numberFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + return formatter + } + + var overallInsulinView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Overall Insulin") + .font(.subheadline) + .foregroundColor(.secondary) + .accessibilitySortPriority(2) + + let percent = numberFormatter.string(from: insulinSensitivityMultiplier ?? 1)! + Group { Text(percent).bold() + Text(" of scheduled") } + .font(.subheadline) + .accessibilitySortPriority(1) + } + .accessibilityElement(children: .contain) + } + + func guidanceColor(for classification: SafetyClassification?) -> Color? { + guard let classification else { return nil } + + switch classification { + case .outsideRecommendedRange(let threshold): + switch threshold { + case .aboveRecommended, .belowRecommended: + return guidanceColors.warning + case .maximum, .minimum: + return guidanceColors.critical + } + case .withinRecommendedRange: + return nil + } + } + + func annotatedRangeText(target: ClosedRange) -> some View { + let lowerColor = guardrail?.color(for: target.lowerBound, guidanceColors: guidanceColors) ?? .primary + let upperColor = guardrail?.color(for: target.upperBound, guidanceColors: guidanceColors) ?? .primary + + let units = Text(" \(displayGlucosePreference.unit.localizedUnitString(in: .medium) ?? displayGlucosePreference.unit.unitString)") + .foregroundStyle(upperColor) + let lower = Text(displayGlucosePreference.format(target.lowerBound, includeUnit: false)) + .foregroundStyle(lowerColor) + .bold() + let upper = Text(displayGlucosePreference.format(target.upperBound, includeUnit: false)) + .foregroundStyle(upperColor) + .bold() + let warningSymbol = Text("\(Image(systemName: "exclamationmark.triangle.fill"))") + + let lowerClassification = guardrail?.classification(for: target.lowerBound) ?? .withinRecommendedRange + let upperClassification = guardrail?.classification(for: target.upperBound) ?? .withinRecommendedRange + + return Group { + switch (lowerClassification, upperClassification) { + case (.withinRecommendedRange, .withinRecommendedRange): + lower + Text(" - ") + upper + units + case (.withinRecommendedRange, .outsideRecommendedRange): + lower + Text(" - ") + warningSymbol.foregroundStyle(upperColor) + upper + units + case (.outsideRecommendedRange, .outsideRecommendedRange): + warningSymbol.foregroundStyle(lowerColor) + lower + Text("-").foregroundStyle(lowerColor) + upper + units + case (.outsideRecommendedRange, .withinRecommendedRange): + warningSymbol.foregroundStyle(lowerColor) + lower + Text("-") + upper + units + } + } + } + + var correctionRangeView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Correction Range") + .font(.subheadline) + .foregroundColor(.secondary) + .accessibilitySortPriority(2) + + Group { + if let target = correctionRange { + annotatedRangeText(target: target) + } else { + Text("Scheduled Range") + .bold() + } + } + .font(.subheadline) + .accessibilitySortPriority(1) + } + .accessibilityElement(children: .contain) + } + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 0) { + overallInsulinView + + Spacer() + + correctionRangeView + } + + VStack(alignment: .leading, spacing: 16) { + overallInsulinView + + correctionRangeView + } + } + } +} diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 8c6564b96a..3347bb358b 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -6,8 +6,9 @@ // Copyright © 2024 LoopKit Authors. All rights reserved. // -import Foundation +import LoopAlgorithm import LoopKit +import LoopKitUI import SwiftUI enum PresetSortOption: Int, CaseIterable { @@ -29,19 +30,19 @@ enum PresetSortOption: Int, CaseIterable { struct PresetsView: View { + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.dismiss) private var dismiss - @StateObject private var viewModel: PresetsViewModel + @State private var viewModel: PresetsViewModel @State private var editMode: EditMode = .inactive @State private var showingMenu: Bool = false @State var showTraining: Bool = false - var isDescending: Bool { !viewModel.presetsSortAscending } init(viewModel: PresetsViewModel) { - self._viewModel = StateObject(wrappedValue: viewModel) + self.viewModel = viewModel } var presetsSorted: [SelectablePreset] { @@ -73,6 +74,9 @@ struct PresetsView: View { activePreset, expectedEndTime: viewModel.activeOverride?.expectedEndTime ) +// .onTapGesture { +// viewModel.pendingPreset = activePreset +// } } // All Presets Section @@ -99,6 +103,9 @@ struct PresetsView: View { PresetCard(preset) .background(Color.white) .cornerRadius(12) +// .onTapGesture { +// viewModel.pendingPreset = preset +// } } } } @@ -154,15 +161,17 @@ struct PresetsView: View { .navigationTitle(Text("Presets", comment: "Presets screen title")) .navigationBarItems(trailing: dismissButton) } - + .sheet(item: $viewModel.pendingPreset) { preset in + PresetDetentView( + viewModel: viewModel, + preset: preset + ) + } .sheet(isPresented: $showTraining) { PresetsTrainingView { viewModel.hasCompletedTraining = true } } - .onAppear { // TODO: Remove this - viewModel.hasCompletedTraining = false - } } private var sortMenu: some View { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 557472a10e..c2d9e4a407 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -12,8 +12,7 @@ import MockKit import SwiftUI import LoopUI - -public struct SettingsView: View { +struct SettingsView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.dismissAction) private var dismiss @Environment(\.appName) private var appName @@ -23,7 +22,7 @@ public struct SettingsView: View { @Environment(\.insulinTintColor) private var insulinTintColor @Environment(\.isInvestigationalDevice) private var isInvestigationalDevice - @ObservedObject var viewModel: SettingsViewModel + @State var viewModel: SettingsViewModel @ObservedObject var versionUpdateViewModel: VersionUpdateViewModel enum Destination { @@ -63,7 +62,7 @@ public struct SettingsView: View { var localizedAppNameAndVersion: String - public init(viewModel: SettingsViewModel, localizedAppNameAndVersion: String) { + init(viewModel: SettingsViewModel, localizedAppNameAndVersion: String) { self.viewModel = viewModel self.versionUpdateViewModel = viewModel.versionUpdateViewModel self.localizedAppNameAndVersion = localizedAppNameAndVersion @@ -172,19 +171,9 @@ public struct SettingsView: View { } public var presetsView: some View { - PresetsView( - viewModel: PresetsViewModel( - customPresets: viewModel.therapySettings().overridePresets ?? [], - correctionRangeOverrides: viewModel.therapySettings().correctionRangeOverrides, - presetsHistory: viewModel.presetHistory, - preMealGuardrail: viewModel.preMealGuardrail, - legacyWorkoutGuardrail: viewModel.legacyWorkoutPresetGuardrail - ) - ) + PresetsView(viewModel: viewModel.presetsViewModel) } - - private func menuItemsForSection(name: String) -> some View { Section(header: SectionHeader(label: name)) { ForEach(pluginMenuItems.filter {$0.section.customLocalizedTitle == name}) { item in diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift new file mode 100644 index 0000000000..2dc0305523 --- /dev/null +++ b/Loop/Views/StatusTableView.swift @@ -0,0 +1,315 @@ +// +// StatusTableView.swift +// Loop +// +// Created by Cameron Ingham on 12/10/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI +import UIKit + +private struct WrappedStatusTableViewController: UIViewControllerRepresentable { + + private let alertPermissionsChecker: AlertPermissionsChecker + private let alertMuter: AlertMuter + private let automaticDosingStatus: AutomaticDosingStatus + private let deviceDataManager: DeviceDataManager + private let onboardingManager: OnboardingManager + private let supportManager: SupportManager + private let testingScenariosManager: TestingScenariosManager? + private let settingsManager: SettingsManager + private let temporaryPresetsManager: TemporaryPresetsManager + private let loopDataManager: LoopDataManager + private let diagnosticReportGenerator: DiagnosticReportGenerator + private let simulatedData: SimulatedData + private let analyticsServicesManager: AnalyticsServicesManager + private let servicesManager: ServicesManager + private let carbStore: CarbStore + private let doseStore: DoseStore + private let criticalEventLogExportManager: CriticalEventLogExportManager + private let bluetoothStateManager: BluetoothStateManager + + let viewController: StatusTableViewController + + init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager) { + self.alertPermissionsChecker = alertPermissionsChecker + self.alertMuter = alertMuter + self.automaticDosingStatus = automaticDosingStatus + self.deviceDataManager = deviceDataManager + self.onboardingManager = onboardingManager + self.supportManager = supportManager + self.testingScenariosManager = testingScenariosManager + self.settingsManager = settingsManager + self.temporaryPresetsManager = temporaryPresetsManager + self.loopDataManager = loopDataManager + self.diagnosticReportGenerator = diagnosticReportGenerator + self.simulatedData = simulatedData + self.analyticsServicesManager = analyticsServicesManager + self.servicesManager = servicesManager + self.carbStore = carbStore + self.doseStore = doseStore + self.criticalEventLogExportManager = criticalEventLogExportManager + self.bluetoothStateManager = bluetoothStateManager + + let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: StatusTableViewController.self)) + let statusTableViewController = storyboard.instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController + statusTableViewController.alertPermissionsChecker = alertPermissionsChecker + statusTableViewController.alertMuter = alertMuter + statusTableViewController.automaticDosingStatus = automaticDosingStatus + statusTableViewController.deviceManager = deviceDataManager + statusTableViewController.onboardingManager = onboardingManager + statusTableViewController.supportManager = supportManager + statusTableViewController.testingScenariosManager = testingScenariosManager + statusTableViewController.settingsManager = settingsManager + statusTableViewController.temporaryPresetsManager = temporaryPresetsManager + statusTableViewController.loopManager = loopDataManager + statusTableViewController.diagnosticReportGenerator = diagnosticReportGenerator + statusTableViewController.simulatedData = simulatedData + statusTableViewController.analyticsServicesManager = analyticsServicesManager + statusTableViewController.servicesManager = servicesManager + statusTableViewController.carbStore = carbStore + statusTableViewController.doseStore = doseStore + statusTableViewController.criticalEventLogExportManager = criticalEventLogExportManager + bluetoothStateManager.addBluetoothObserver(statusTableViewController) + + self.viewController = statusTableViewController + } + + func makeUIViewController(context: Context) -> some UIViewController { + viewController + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} +} + +struct StatusTableView: View { + + private let alertPermissionsChecker: AlertPermissionsChecker + private let alertMuter: AlertMuter + private let automaticDosingStatus: AutomaticDosingStatus + private let deviceDataManager: DeviceDataManager + private let displayGlucosePreference: DisplayGlucosePreference + private let onboardingManager: OnboardingManager + private let supportManager: SupportManager + private let testingScenariosManager: TestingScenariosManager? + private let settingsManager: SettingsManager + private let loopDataManager: LoopDataManager + private let diagnosticReportGenerator: DiagnosticReportGenerator + private let simulatedData: SimulatedData + private let analyticsServicesManager: AnalyticsServicesManager + private let servicesManager: ServicesManager + private let carbStore: CarbStore + private let doseStore: DoseStore + private let criticalEventLogExportManager: CriticalEventLogExportManager + private let bluetoothStateManager: BluetoothStateManager + + @Bindable var settingsViewModel: SettingsViewModel + + private let wrapped: WrappedStatusTableViewController + + var viewController: StatusTableViewController { + wrapped.viewController + } + + init(displayGlucosePreference: DisplayGlucosePreference, alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager) { + self.displayGlucosePreference = displayGlucosePreference + self.alertPermissionsChecker = alertPermissionsChecker + self.alertMuter = alertMuter + self.automaticDosingStatus = automaticDosingStatus + self.deviceDataManager = deviceDataManager + self.onboardingManager = onboardingManager + self.supportManager = supportManager + self.testingScenariosManager = testingScenariosManager + self.settingsManager = settingsManager + self.loopDataManager = loopDataManager + self.diagnosticReportGenerator = diagnosticReportGenerator + self.simulatedData = simulatedData + self.analyticsServicesManager = analyticsServicesManager + self.servicesManager = servicesManager + self.carbStore = carbStore + self.doseStore = doseStore + self.criticalEventLogExportManager = criticalEventLogExportManager + self.bluetoothStateManager = bluetoothStateManager + + self.wrapped = WrappedStatusTableViewController(alertPermissionsChecker: alertPermissionsChecker, alertMuter: alertMuter, automaticDosingStatus: automaticDosingStatus, deviceDataManager: deviceDataManager, onboardingManager: onboardingManager, supportManager: supportManager, testingScenariosManager: testingScenariosManager, settingsManager: settingsManager, temporaryPresetsManager: temporaryPresetsManager, loopDataManager: loopDataManager, diagnosticReportGenerator: diagnosticReportGenerator, simulatedData: simulatedData, analyticsServicesManager: analyticsServicesManager, servicesManager: servicesManager, carbStore: carbStore, doseStore: doseStore, criticalEventLogExportManager: criticalEventLogExportManager, bluetoothStateManager: bluetoothStateManager) + + self.settingsViewModel = wrapped.viewController.settingsViewModel + } + + func isActive(action: ToolbarAction) -> Bool { + switch action { + case .addCarbs, .bolus, .settings: // No active states for these actions + return false + case .preMealPreset: + return settingsViewModel.presetsViewModel.temporaryPresetsManager.preMealTargetEnabled() + case .workoutPreset: + return settingsViewModel.presetsViewModel.temporaryPresetsManager.nonPreMealOverrideEnabled() + case .presets: + return settingsViewModel.presetsViewModel.activeOverride != nil + } + } + + func isDisabled(action: ToolbarAction) -> Bool { + switch action { + case .addCarbs, .bolus, .presets, .settings: + false + case .preMealPreset: + !(onboardingManager.isComplete && + (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) + && settingsManager.settings.preMealTargetRange != nil) + case .workoutPreset: + viewController.workoutMode != nil && onboardingManager.isComplete + } + } + + var body: some View { + wrapped + .sheet(item: $settingsViewModel.presetsViewModel.pendingPreset) { preset in + PresetDetentView( + viewModel: settingsViewModel.presetsViewModel, + preset: preset + ) + } + .toolbar { + ToolbarItem(placement: .bottomBar) { + HStack(alignment: .bottom) { + ForEach(ToolbarAction.new) { action in + action.button( + showTitle: true, + isActive: isActive(action: action), + disabled: isDisabled(action: action) + ) { + switch action { + case .addCarbs: + viewController.userTappedAddCarbs() + case .preMealPreset: + viewController.togglePreMealMode() + case .bolus: + viewController.presentBolusScreen() + case .workoutPreset: + viewController.presentCustomPresets() + case .presets: + viewController.presentPresets() + case .settings: + viewController.presentSettings() + } + } + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 16) + .padding(.bottom, -8) + } + } + } +} + +enum ToolbarAction: String, Identifiable, CaseIterable { + case addCarbs + case preMealPreset + case bolus + case workoutPreset + case presets + case settings + + static var legacy: [ToolbarAction] = [ + .addCarbs, + .preMealPreset, + .bolus, + .workoutPreset, + .settings + ] + + static var new: [ToolbarAction] = [ + .addCarbs, + .bolus, + .presets, + .settings + ] + + var id: String { self.rawValue } + + @ViewBuilder + func icon(isActive: Bool) -> some View { + Group { + switch self { + case .addCarbs: + Image("carbs") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.carbs) + case .preMealPreset: + Image(isActive ? "Pre-Meal Selected" : "Pre-Meal") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.carbs) + case .bolus: + Image("bolus") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.insulin) + case .workoutPreset: + Image(isActive ? "workout-selected" : "workout") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.glucose) + case .presets: + Image(isActive ? "presets-selected" : "presets") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.presets) + case .settings: + Image("settings") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color(UIColor.secondaryLabel)) + } + } + .frame(width: 32, height: 32) + .aspectRatio(contentMode: .fit) + } + + @ViewBuilder + var title: some View { + Group { + switch self { + case .addCarbs: + Text("Add Carbs", comment: "The label of the carb entry button") + case .preMealPreset: + Text("Pre-Meal Preset", comment: "The label of the pre-meal mode toggle button") + case .bolus: + Text("Bolus", comment: "The label of the bolus entry button") + case .workoutPreset: + Text("Workout Preset", comment: "The label of the workout mode toggle button") + case .presets: + Text("Presets", comment: "The label of the presets button") + case .settings: + Text("Settings", comment: "The label of the settings button") + } + } + .foregroundStyle(.secondary) + .font(.footnote) + } + + @ViewBuilder + func button(showTitle: Bool, isActive: Bool, disabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 4) { + icon(isActive: isActive) + + if showTitle { + title + } + } + .animation(.default, value: isActive) + .padding(.vertical) + } + .buttonStyle(.plain) + .disabled(disabled) + .contentShape(Rectangle()) + } +} diff --git a/Loop/Views/TitleSubtitleTableViewCell.swift b/Loop/Views/TitleSubtitleTableViewCell.swift index d9e24e7185..ce1dbe815a 100644 --- a/Loop/Views/TitleSubtitleTableViewCell.swift +++ b/Loop/Views/TitleSubtitleTableViewCell.swift @@ -25,7 +25,7 @@ class TitleSubtitleTableViewCell: UITableViewCell { gradient.frame = bounds } - private lazy var gradient = CAGradientLayer() + private(set) lazy var gradient = CAGradientLayer() override func awakeFromNib() { super.awakeFromNib() diff --git a/LoopUI/Extensions/UIColor.swift b/LoopUI/Extensions/UIColor.swift index 9b474b42c5..9f6885a536 100644 --- a/LoopUI/Extensions/UIColor.swift +++ b/LoopUI/Extensions/UIColor.swift @@ -18,7 +18,7 @@ extension UIColor { @nonobjc static let insulin = UIColor(named: "insulin") ?? systemOrange - @nonobjc static let presets = UIColor(named: "presets") ?? systemTeal + @nonobjc public static let presets = UIColor(named: "presets") ?? systemTeal // The loopAccent color is intended to be use as the app accent color. @nonobjc public static let loopAccent = UIColor(named: "accent") ?? systemBlue From 3a1144cbf30520992645d2ee5d05935ee9beba55 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 13 Dec 2024 08:06:55 -0400 Subject: [PATCH 191/421] [COASTAL-1389] display status row until bolus is completed (#731) --- Loop/View Controllers/StatusTableViewController.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 7d8638153f..ea4611e955 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1427,10 +1427,10 @@ final class StatusTableViewController: LoopChartsTableViewController { case .failure(let error): self.canceledDose = nil self.presentErrorCancelingBolus(error) - if case .inProgress(let dose) = self.bolusState { - self.updateBannerAndHUDandStatusRows(statusRowMode: .bolusing(dose: dose), newSize: nil, animated: true) - } else { + if case .noBolus = self.bolusState { self.updateBannerAndHUDandStatusRows(statusRowMode: .hidden, newSize: nil, animated: true) + } else { + self.updateBannerAndHUDandStatusRows(statusRowMode: .bolusing(dose: dose), newSize: nil, animated: true) } } } From 6003381385c1b5d2381f789dc9e7552a352befea Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 16 Dec 2024 12:06:07 -0800 Subject: [PATCH 192/421] [LOOP-5056] Presets Homepage Updates - Part 2 (#734) --- Loop.xcodeproj/project.pbxproj | 4 + Loop/Managers/LoopAppManager.swift | 75 ++++- .../StatusTableViewController.swift | 262 ++++++------------ Loop/View Models/PresetsViewModel.swift | 23 +- Loop/View Models/ServicesViewModel.swift | 4 +- Loop/View Models/SettingsViewModel.swift | 13 +- .../Components/EditOverrideDurationView.swift | 60 ++++ .../Presets/Components/PresetDetentView.swift | 61 ++-- Loop/Views/Presets/PresetsHistoryView.swift | 98 +++++-- Loop/Views/Presets/PresetsView.swift | 20 +- Loop/Views/StatusTableView.swift | 182 ++++++------ 11 files changed, 458 insertions(+), 344 deletions(-) create mode 100644 Loop/Views/Presets/Components/EditOverrideDurationView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 8a75eb14b3..64152d89d5 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -272,6 +272,7 @@ 84E8BBD02CCA279B0078E6CF /* Image+Exists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */; }; 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */; }; + 84F20DFD2D0B9C3A0089DF02 /* EditOverrideDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFC2D0B9C3A0089DF02 /* EditOverrideDurationView.swift */; }; 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */; }; 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */; }; 892A5D59222F0A27008961AB /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; @@ -1152,6 +1153,7 @@ 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Exists.swift"; sourceTree = ""; }; 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetStatsView.swift; sourceTree = ""; }; + 84F20DFC2D0B9C3A0089DF02 /* EditOverrideDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditOverrideDurationView.swift; sourceTree = ""; }; 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsHistoryView.swift; sourceTree = ""; }; 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlowViewModel.swift; sourceTree = ""; }; 892A5D29222EF60A008961AB /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKit.framework; path = Carthage/Build/iOS/MockKit.framework; sourceTree = SOURCE_ROOT; }; @@ -2524,6 +2526,7 @@ 84C170EE2CCA37680098E52F /* PresetCard.swift */, 84D1F1A82D09800700CB271F /* PresetDetentView.swift */, 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */, + 84F20DFC2D0B9C3A0089DF02 /* EditOverrideDurationView.swift */, ); path = Components; sourceTree = ""; @@ -3530,6 +3533,7 @@ 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, + 84F20DFD2D0B9C3A0089DF02 /* EditOverrideDurationView.swift in Sources */, 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */, 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */, C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */, diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index d6b71ba930..1087ee8930 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -10,6 +10,7 @@ import UIKit import Intents import BackgroundTasks import Combine +import LoopTestingKit import LoopKit import LoopKitUI import MockKit @@ -540,13 +541,69 @@ class LoopAppManager: NSObject { } } } + + private lazy var settingsViewModel: SettingsViewModel = { + let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in + (self?.deviceDataManager.pumpManager is TestingPumpManager) ? { + Task { [weak self] in try? await self?.deviceDataManager.deleteTestingPumpData() + }} : nil + } + let deleteCGMDataFunc: () -> CGMManagerViewModel.DeleteTestingDataFunc? = { [weak self] in + (self?.deviceDataManager.cgmManager is TestingCGMManager) ? { + Task { [weak self] in try? await self?.deviceDataManager.deleteTestingCGMData() + }} : nil + } + let pumpViewModel = PumpManagerViewModel( + image: { [weak self] in (self?.deviceDataManager.pumpManager as? PumpManagerUI)?.smallImage }, + name: { [weak self] in self?.deviceDataManager.pumpManager?.localizedTitle ?? "" }, + isSetUp: { [weak self] in self?.deviceDataManager.pumpManager?.isOnboarded == true }, + availableDevices: deviceDataManager.availablePumpManagers, + deleteTestingDataFunc: deletePumpDataFunc + ) + + let cgmViewModel = CGMManagerViewModel( + image: {[weak self] in (self?.deviceDataManager.cgmManager as? DeviceManagerUI)?.smallImage }, + name: {[weak self] in self?.deviceDataManager.cgmManager?.localizedTitle ?? "" }, + isSetUp: {[weak self] in self?.deviceDataManager.cgmManager?.isOnboarded == true }, + availableDevices: deviceDataManager.availableCGMManagers, + deleteTestingDataFunc: deleteCGMDataFunc + ) + let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled, + availableServices: { [weak self] in self?.servicesManager.availableServices ?? [] }, + activeServices: { [weak self] in self?.servicesManager.activeServices ?? [] }) + let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) + + let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, + alertMuter: alertManager.alertMuter, + versionUpdateViewModel: versionUpdateViewModel, + pumpManagerSettingsViewModel: pumpViewModel, + cgmManagerSettingsViewModel: cgmViewModel, + servicesViewModel: servicesViewModel, + criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), + therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, + sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, + initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, + automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, + lastLoopCompletion: loopDataManager.$lastLoopCompleted, + mostRecentGlucoseDataDate: loopDataManager.$publishedMostRecentGlucoseDataDate, + mostRecentPumpDataDate: loopDataManager.$publishedMostRecentPumpDataDate, + availableSupports: supportManager.availableSupports, + isOnboardingComplete: onboardingManager.isComplete, + therapySettingsViewModelDelegate: deviceDataManager, + presetHistory: temporaryPresetsManager.overrideHistory, + temporaryPresetsManager: temporaryPresetsManager + ) + + viewModel.favoriteFoodInsightsDelegate = loopDataManager + + return viewModel + }() private func launchHomeScreen() async { dispatchPrecondition(condition: .onQueue(.main)) precondition(state == .launchHomeScreen) - - let statusTableView = StatusTableView( - displayGlucosePreference: displayGlucosePreference, + + let viewModel = StatusTableViewModel( alertPermissionsChecker: alertPermissionsChecker, alertMuter: alertManager.alertMuter, automaticDosingStatus: automaticDosingStatus, @@ -564,8 +621,16 @@ class LoopAppManager: NSObject { carbStore: carbStore, doseStore: doseStore, criticalEventLogExportManager: criticalEventLogExportManager, - bluetoothStateManager: bluetoothStateManager - ).edgesIgnoringSafeArea(.top) + bluetoothStateManager: bluetoothStateManager, + settingsViewModel: settingsViewModel + ) + + let statusTableView = StatusTableView(viewModel: viewModel) + .environmentObject(deviceDataManager.displayGlucosePreference) + .environment(\.appName, Bundle.main.bundleDisplayName) + .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) + .environment(\.loopStatusColorPalette, .loopStatus) + .edgesIgnoringSafeArea(.top) var rootNavigationController = rootViewController as? RootNavigationController if rootNavigationController == nil { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index ea4611e955..014f942e3d 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -68,75 +68,9 @@ final class StatusTableViewController: LoopChartsTableViewController { var doseStore: DoseStore! var criticalEventLogExportManager: CriticalEventLogExportManager! - - lazy var settingsViewModel: SettingsViewModel = { - let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in - (self?.deviceManager.pumpManager is TestingPumpManager) ? { - Task { [weak self] in try? await self?.deviceManager.deleteTestingPumpData() - }} : nil - } - let deleteCGMDataFunc: () -> CGMManagerViewModel.DeleteTestingDataFunc? = { [weak self] in - (self?.deviceManager.cgmManager is TestingCGMManager) ? { - Task { [weak self] in try? await self?.deviceManager.deleteTestingCGMData() - }} : nil - } - let pumpViewModel = PumpManagerViewModel( - 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, - deleteTestingDataFunc: deletePumpDataFunc, - onTapped: { [weak self] in - self?.onPumpTapped() - }, - didTapAddDevice: { [weak self] in - self?.addPumpManager(withIdentifier: $0.identifier) - }) - - let cgmViewModel = CGMManagerViewModel( - image: {[weak self] in (self?.deviceManager.cgmManager as? DeviceManagerUI)?.smallImage }, - name: {[weak self] in self?.deviceManager.cgmManager?.localizedTitle ?? "" }, - isSetUp: {[weak self] in self?.deviceManager.cgmManager?.isOnboarded == true }, - availableDevices: deviceManager.availableCGMManagers, - deleteTestingDataFunc: deleteCGMDataFunc, - onTapped: { [weak self] in - self?.onCGMTapped() - }, - didTapAddDevice: { [weak self] in - self?.addCGMManager(withIdentifier: $0.identifier) - }) - let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled, - availableServices: { [weak self] in self?.servicesManager.availableServices ?? [] }, - activeServices: { [weak self] in self?.servicesManager.activeServices ?? [] }, - delegate: self) - let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) - - let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, - alertMuter: alertMuter, - versionUpdateViewModel: versionUpdateViewModel, - pumpManagerSettingsViewModel: pumpViewModel, - cgmManagerSettingsViewModel: cgmViewModel, - servicesViewModel: servicesViewModel, - criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), - therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, - sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, - automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, - lastLoopCompletion: loopManager.$lastLoopCompleted, - mostRecentGlucoseDataDate: loopManager.$publishedMostRecentGlucoseDataDate, - mostRecentPumpDataDate: loopManager.$publishedMostRecentPumpDataDate, - availableSupports: supportManager.availableSupports, - isOnboardingComplete: onboardingManager.isComplete, - therapySettingsViewModelDelegate: deviceManager, - presetHistory: temporaryPresetsManager.overrideHistory, - temporaryPresetsManager: temporaryPresetsManager, - delegate: self - ) - - viewModel.favoriteFoodInsightsDelegate = loopManager - - return viewModel - }() + + var settingsViewModel: SettingsViewModel! + var statusTableViewModel: StatusTableViewModel! lazy private var cancellables = Set() @@ -147,10 +81,21 @@ final class StatusTableViewController: LoopChartsTableViewController { super.viewDidLoad() setupToolbarItems() - + statusTableViewModel.settingsViewModel.delegate = self + statusTableViewModel.settingsViewModel.pumpManagerSettingsViewModel.didTap = { [weak self] in + self?.onPumpTapped() + } + statusTableViewModel.settingsViewModel.pumpManagerSettingsViewModel.didTapAdd = { [weak self] in + self?.addPumpManager(withIdentifier: $0.identifier) + } + statusTableViewModel.settingsViewModel.cgmManagerSettingsViewModel.didTap = { [weak self] in + self?.onCGMTapped() + } + statusTableViewModel.settingsViewModel.cgmManagerSettingsViewModel.didTapAdd = { [weak self] in + self?.addCGMManager(withIdentifier: $0.identifier) + } + tableView.register(BolusProgressTableViewCell.nib(), forCellReuseIdentifier: BolusProgressTableViewCell.className) - tableView.register(AlertPermissionsDisabledWarningCell.self, forCellReuseIdentifier: AlertPermissionsDisabledWarningCell.className) - tableView.register(MuteAlertsWarningCell.self, forCellReuseIdentifier: MuteAlertsWarningCell.className) if FeatureFlags.predictedGlucoseChartClampEnabled { statusCharts.glucose.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayBoundClamped @@ -1029,93 +974,9 @@ final class StatusTableViewController: LoopChartsTableViewController { return shouldShowStatus ? StatusRow.allCases.count : 0 } } - - 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 - - var contentConfig = defaultContentConfiguration().updated(for: state) - let titleImageAttachment = NSTextAttachment() - titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.white) - 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 = alert.bannerBody - contentConfig.secondaryTextProperties.color = .white - contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 13 : 15) - contentConfiguration = contentConfig - - var backgroundConfig = backgroundConfiguration?.updated(for: state) - backgroundConfig?.backgroundColor = .critical - backgroundConfiguration = backgroundConfig - backgroundConfiguration?.backgroundInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10) - backgroundConfiguration?.cornerRadius = 10 - - let disclosureIndicator = UIImage(systemName: "chevron.right")?.withTintColor(.white) - let imageView = UIImageView(image: disclosureIndicator) - imageView.tintColor = .white - accessoryView = imageView - - contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 13, trailing: 0) - } - } - - private class MuteAlertsWarningCell: UITableViewCell { - var formattedAlertMuteEndTime: String = NSLocalizedString("Unknown", comment: "label for when the alert mute end time is unknown") - - fileprivate class GradientView: UIView { - override static var layerClass: AnyClass { CAGradientLayer.self } - } - - override func updateConfiguration(using state: UICellConfigurationState) { - super.updateConfiguration(using: state) - - let adjustViewForNarrowDisplay = bounds.width < 350 - - var contentConfig = defaultContentConfiguration().updated(for: state) - let title = NSMutableAttributedString(string: NSLocalizedString("All 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 - contentConfig.attributedText = title - contentConfig.textProperties.color = .white - contentConfig.textProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 16 : 18, weight: .semibold) - contentConfig.textProperties.adjustsFontSizeToFitWidth = true - contentConfig.secondaryText = String(format: NSLocalizedString("Until %1$@", comment: "indication of when alerts will be unmuted (1: time when alerts unmute)"), formattedAlertMuteEndTime) - contentConfig.secondaryTextProperties.color = .white - contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 13 : 15) - contentConfiguration = contentConfig - - let backgroundGradient = GradientView() - (backgroundGradient.layer as? CAGradientLayer)?.colors = [UIColor.warning.cgColor, UIColor.warning.withAlphaComponent(0.9).cgColor] - - var backgroundConfig = backgroundConfiguration?.updated(for: state) - backgroundConfig?.customView = backgroundGradient - backgroundConfiguration = backgroundConfig - backgroundConfiguration?.backgroundInsets = NSDirectionalEdgeInsets(top: 0, leading: 5, bottom: 5, trailing: 5) - backgroundConfiguration?.cornerRadius = 10 - - let unmuteIndicator = UIImage(systemName: "stop.circle")?.withTintColor(.white) - let imageView = UIImageView(image: unmuteIndicator) - imageView.tintColor = .white - imageView.frame.size = CGSize(width: 30, height: 30) - accessoryView = imageView - - contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 13, trailing: 0) - } + + private class GradientView: UIView { + override static var layerClass: AnyClass { CAGradientLayer.self } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -1164,12 +1025,16 @@ final class StatusTableViewController: LoopChartsTableViewController { } if override.isActive() { - switch override.duration { - case .finite: - let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) - cell.subtitleLabel.text = String(format: NSLocalizedString("on until %@", comment: "The format for the description of a custom preset end date"), endTimeText) - case .indefinite: - cell.subtitleLabel.text = nil + if let preset = settingsViewModel.presetsViewModel.allPresets.first(where: { $0.id == override.presetId }), case .preMeal(_, _) = preset { + cell.subtitleLabel.text = NSLocalizedString("on until carbs added", comment: "The format for the description of a premeal preset end date") + } else { + switch override.duration { + case .finite: + let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) + cell.subtitleLabel.text = String(format: NSLocalizedString("on until %@", comment: "The format for the description of a custom preset end date"), endTimeText) + case .indefinite: + cell.subtitleLabel.text = nil + } } } else { let startTimeText = DateFormatter.localizedString(from: override.startDate, dateStyle: .none, timeStyle: .short) @@ -1179,16 +1044,59 @@ final class StatusTableViewController: LoopChartsTableViewController { return cell case .alertWarning: - if alertPermissionsChecker.showWarning { - 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 - cell.formattedAlertMuteEndTime = alertMuter.formattedEndTime - cell.selectionStyle = .none - return cell + let cell = UITableViewCell() + let alert = AlertPermissionsChecker.UnsafeNotificationPermissionAlert(permissions: alertPermissionsChecker.notificationCenterSettings) + + cell.contentConfiguration = UIHostingConfiguration { + if alertPermissionsChecker.showWarning { + if let alert { + HStack { + VStack(alignment: .leading) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(" ") + Text(alert.bannerTitle) + .font(.headline.bold()) + + Text(alert.bannerBody) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + + Text(Image(systemName: "chevron.right")) + .font(.headline) + } + .foregroundStyle(Color.white) + .padding(8) + .background(Color.critical.cornerRadius(10)) + .padding([.top, .horizontal], 8) + } + } else { + HStack { + VStack(alignment: .leading) { + Text(Image(systemName: "speaker.slash.fill")) + Text(" ") + Text(NSLocalizedString("All App Sounds Muted", comment: "Warning text for when alerts are muted")) + .font(.headline.bold()) + + Text(String(format: NSLocalizedString("Until %1$@", comment: "indication of when alerts will be unmuted (1: time when alerts unmute)"), alertMuter.formattedEndTime)) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + + Text(Image(systemName: "stop.circle")) + .font(.title) + } + .foregroundStyle(Color.white) + .padding(8) + .background(Color.warning.cornerRadius(10)) + .padding([.top, .horizontal], 8) + } } + .margins(.all, 0) + + cell.backgroundColor = .secondarySystemBackground + + return cell case .hud: let cell = tableView.dequeueReusableCell(withIdentifier: HUDViewTableViewCell.className, for: indexPath) as! HUDViewTableViewCell hudView = cell.hudView @@ -1363,7 +1271,9 @@ final class StatusTableViewController: LoopChartsTableViewController { case .iob, .dose, .cob: return max(106, 0.21 * availableSize) } - case .presets, .hud, .status, .alertWarning: + case .alertWarning: + return UITableView.automaticDimension + case .presets, .hud, .status: return UITableView.automaticDimension } } @@ -1371,7 +1281,7 @@ final class StatusTableViewController: LoopChartsTableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch Section(rawValue: indexPath.section)! { case .presets: - settingsViewModel.presetsViewModel.pendingPreset = settingsViewModel.presetsViewModel.allPresets.first(where: { $0.id == (temporaryPresetsManager.scheduleOverride ?? temporaryPresetsManager.preMealOverride)?.presetId }) + statusTableViewModel.pendingPreset = settingsViewModel.presetsViewModel.allPresets.first(where: { $0.id == (temporaryPresetsManager.scheduleOverride ?? temporaryPresetsManager.preMealOverride)?.presetId }) case .alertWarning: if alertPermissionsChecker.showWarning { tableView.deselectRow(at: indexPath, animated: true) diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift index c48562e565..3822ff139c 100644 --- a/Loop/View Models/PresetsViewModel.swift +++ b/Loop/View Models/PresetsViewModel.swift @@ -159,6 +159,25 @@ enum SelectablePreset: Hashable, Identifiable { return .distantPast } } + + func title(font: Font, iconSize: Double) -> some View { + HStack(spacing: 6) { + switch icon { + case .emoji(let emoji): + Text(emoji) + case .image(let name, let iconColor): + Image(name) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(iconColor) + .frame(width: UIFontMetrics.default.scaledValue(for: iconSize), height: UIFontMetrics.default.scaledValue(for: iconSize)) + } + + Text(name) + .font(font) + .fontWeight(.semibold) + } + } } @MainActor @@ -253,9 +272,9 @@ public class PresetsViewModel { case .custom(let temporaryScheduleOverridePreset): temporaryPresetsManager.scheduleOverride = temporaryScheduleOverridePreset.createOverride(enactTrigger: .local) case .preMeal: - temporaryPresetsManager.enablePreMealOverride(for: .hours(2)) // FIX TIME + temporaryPresetsManager.enablePreMealOverride(for: .hours(1)) case .legacyWorkout: - temporaryPresetsManager.enableLegacyWorkoutOverride(for: .indefinite) // FIX TIME + temporaryPresetsManager.enableLegacyWorkoutOverride(for: .indefinite) } } diff --git a/Loop/View Models/ServicesViewModel.swift b/Loop/View Models/ServicesViewModel.swift index 3021247b2c..6878a5fa5f 100644 --- a/Loop/View Models/ServicesViewModel.swift +++ b/Loop/View Models/ServicesViewModel.swift @@ -33,12 +33,10 @@ public class ServicesViewModel: ObservableObject { init(showServices: Bool, availableServices: @escaping () -> [ServiceDescriptor], - activeServices: @escaping () -> [Service], - delegate: ServicesViewModelDelegate? = nil) { + activeServices: @escaping () -> [Service]) { self.showServices = showServices self.activeServices = activeServices self.availableServices = availableServices - self.delegate = delegate } func didTapService(_ index: Int) { diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index c785f2a7ab..0521909082 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -20,8 +20,8 @@ public class DeviceViewModel: ObservableObject { let image: () -> UIImage? let name: () -> String let deleteTestingDataFunc: () -> DeleteTestingDataFunc? - let didTap: () -> Void - let didTapAdd: (_ device: T) -> Void + var didTap: () -> Void + var didTapAdd: (_ device: T) -> Void var isTestingDevice: Bool { return deleteTestingDataFunc() != nil } @@ -65,7 +65,7 @@ class SettingsViewModel { let versionUpdateViewModel: VersionUpdateViewModel - private weak var delegate: SettingsViewModelDelegate? + weak var delegate: SettingsViewModelDelegate? func didTapIssueReport() { delegate?.didTapIssueReport() @@ -154,8 +154,7 @@ class SettingsViewModel { isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, presetHistory: TemporaryScheduleOverrideHistory, - temporaryPresetsManager: TemporaryPresetsManager, - delegate: SettingsViewModelDelegate? + temporaryPresetsManager: TemporaryPresetsManager ) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter @@ -176,7 +175,6 @@ class SettingsViewModel { self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate self.presetHistory = presetHistory - self.delegate = delegate var preMealGuardrail: Guardrail? var legacyWorkoutPresetGuardrail: Guardrail? @@ -267,8 +265,7 @@ extension SettingsViewModel { isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, presetHistory: TemporaryScheduleOverrideHistory(), - temporaryPresetsManager: TemporaryPresetsManager(settingsProvider: FakeSettingsProvider()), - delegate: nil + temporaryPresetsManager: TemporaryPresetsManager(settingsProvider: FakeSettingsProvider()) ) } } diff --git a/Loop/Views/Presets/Components/EditOverrideDurationView.swift b/Loop/Views/Presets/Components/EditOverrideDurationView.swift new file mode 100644 index 0000000000..5b871d1627 --- /dev/null +++ b/Loop/Views/Presets/Components/EditOverrideDurationView.swift @@ -0,0 +1,60 @@ +// +// EditPresetDurationView.swift +// Loop +// +// Created by Cameron Ingham on 12/12/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import SwiftUI + +struct EditOverrideDurationView: View { + + let viewModel: PresetsViewModel + let override: TemporaryScheduleOverride + @State var dateSelection: Date + + init(override: TemporaryScheduleOverride, viewModel: PresetsViewModel) { + self.override = override + self.viewModel = viewModel + dateSelection = override.actualEndDate + } + + var preset: SelectablePreset? { + viewModel.allPresets.first(where: { $0.id == override.presetId }) + } + + var body: some View { + ZStack { + Color(UIColor.secondarySystemBackground) + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + VStack(spacing: 24) { + preset?.title(font: .largeTitle, iconSize: 36) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + + DatePicker("On until", selection: $dateSelection, displayedComponents: .hourAndMinute) + .padding(6) + .padding(.leading, 10) + .background(Color.white.cornerRadius(10)) + + Spacer() + } + .padding(.horizontal) + + Button("Save") { + // + } + .buttonStyle(ActionButtonStyle()) + .padding([.top, .horizontal]) + .background(Color.white) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: -4) + .disabled(dateSelection == override.actualEndDate) + } + } + } +} diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 2d65e81590..a4b6640515 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -32,6 +32,11 @@ struct PresetDetentView: View { self.activeOverride = viewModel.temporaryPresetsManager.preMealOverride ?? viewModel.temporaryPresetsManager.scheduleOverride } + init?(viewModel: PresetsViewModel) { + guard let preset = viewModel.pendingPreset else { return nil } + self.init(viewModel: viewModel, preset: preset) + } + var operation: Operation { if activeOverride?.presetId == preset.id { return .end @@ -40,25 +45,6 @@ struct PresetDetentView: View { } } - private func title(font: Font, iconSize: Double) -> some View { - HStack(spacing: 6) { - switch preset.icon { - case .emoji(let emoji): - Text(emoji) - case .image(let name, let iconColor): - Image(name) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(iconColor) - .frame(width: UIFontMetrics.default.scaledValue(for: iconSize), height: UIFontMetrics.default.scaledValue(for: iconSize)) - } - - Text(preset.name) - .font(font) - .fontWeight(.semibold) - } - } - @ViewBuilder private var subtitle: some View { Group { @@ -95,6 +81,7 @@ struct PresetDetentView: View { viewModel.startPreset(preset) } .buttonStyle(ActionButtonStyle()) + .disabled(viewModel.activePreset != nil && preset != viewModel.activePreset) case .end: Button("End Preset") { dismiss() @@ -102,27 +89,25 @@ struct PresetDetentView: View { } .buttonStyle(ActionButtonStyle(.destructive)) - NavigationLink("Adjust Preset Duration") { - ZStack { - Color(UIColor.secondarySystemBackground) - .edgesIgnoringSafeArea(.all) - - VStack(spacing: 24) { - title(font: .largeTitle, iconSize: 36) - .fontWeight(.bold) - .frame(maxWidth: .infinity, alignment: .leading) - - DatePicker("On until", selection: .constant(Date()), displayedComponents: .hourAndMinute) - .padding(6) - .padding(.leading, 10) - .background(Color.white.cornerRadius(10)) - - Spacer() + + switch preset { + case .custom: + NavigationLink("Adjust Preset Duration") { + if let activeOverride { + EditOverrideDurationView(override: activeOverride, viewModel: viewModel) + } + } + .buttonStyle(ActionButtonStyle(.tertiary)) + case .preMeal: + EmptyView() + case .legacyWorkout: + NavigationLink("Adjust Preset Duration") { + if let activeOverride { + EditOverrideDurationView(override: activeOverride, viewModel: viewModel) } - .padding(.horizontal) } + .buttonStyle(ActionButtonStyle(.tertiary)) } - .buttonStyle(ActionButtonStyle(.tertiary)) } Button("Close") { @@ -138,7 +123,7 @@ struct PresetDetentView: View { VStack(spacing: 24) { VStack(spacing: 16) { VStack(spacing: 4) { - title(font: .title2, iconSize: 20) + preset.title(font: .title2, iconSize: 20) subtitle } diff --git a/Loop/Views/Presets/PresetsHistoryView.swift b/Loop/Views/Presets/PresetsHistoryView.swift index 265f0f6f40..53c5f21985 100644 --- a/Loop/Views/Presets/PresetsHistoryView.swift +++ b/Loop/Views/Presets/PresetsHistoryView.swift @@ -11,42 +11,100 @@ import SwiftUI struct PresetsHistoryView: View { + let viewModel: PresetsViewModel @State var history: TemporaryScheduleOverrideHistory - init () { + init (viewModel: PresetsViewModel) { + self.viewModel = viewModel self.history = TemporaryScheduleOverrideHistoryContainer.shared.fetch() } let formatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] - formatter.unitsStyle = .short + formatter.unitsStyle = .abbreviated return formatter }() + var overridesByDate: Dictionary { + Dictionary( + grouping: history.recentEvents + .map(\.override) + .filter({ !$0.isActive() }) + .sorted(by: { $0.actualEndDate > $1.actualEndDate }) + ) { override in + override.startDate.formatted(date: .abbreviated, time: .omitted) + } + } + var body: some View { List { - Section("Recent Events") { - ForEach(history.recentEvents.sorted(by: { $0.override.actualEndDate > $1.override.actualEndDate }), id: \.self) { recentEvent in - - let scheduledDuration = recentEvent.override.duration.timeInterval - let actualDuration = recentEvent.override.actualDuration.timeInterval - - let value = scheduledDuration == actualDuration ? "\(formatter.string(from: scheduledDuration) ?? "")" : "\(formatter.string(from: actualDuration) ?? "") / \(formatter.string(from: scheduledDuration) ?? "")" - - LabeledContent { - Text(value) - } label: { - Text(recentEvent.override.presetId) - - Text(recentEvent.override.startDate.formatted(date: .abbreviated, time: .shortened)) + ForEach(Array(overridesByDate.keys)) { date in + Section(date) { + ForEach(overridesByDate[date] ?? [], id: \.self) { override in + LabeledContent { + VStack(alignment: .trailing, spacing: 8) { + Text("Duration") + .font(.footnote) + .foregroundStyle(.secondary) + + durationText(for: override) + } + } label: { + VStack(alignment: .leading, spacing: 8) { + Text(override.startDate.formatted(date: .omitted, time: .shortened)) + .font(.footnote) + .foregroundStyle(.secondary) + + if let preset = viewModel.allPresets.first(where: { $0.id == override.presetId }) { + HStack(spacing: 4) { + switch preset.icon { + case .emoji(let emoji): + Text(emoji) + case .image(let name, let iconColor): + Image(name) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(iconColor) + .frame(width: UIFontMetrics.default.scaledValue(for: 22), height: UIFontMetrics.default.scaledValue(for: 22)) + } + + Text(preset.name) + .fontWeight(.semibold) + } + } + } + } } } } } + .navigationTitle("Recent Events") + } + + @ViewBuilder + func durationText(for override: TemporaryScheduleOverride) -> some View { + switch override.duration { + case let .finite(scheduledDuration): + let actualDuration = override.actualDuration.timeInterval + if let scheduledDurationString = formatter.string(from: scheduledDuration), let actualDurationString = formatter.string(from: actualDuration) { + if scheduledDuration <= actualDuration { + Text(actualDurationString) + .foregroundStyle(.primary) + } else { + Text(actualDurationString) + .foregroundStyle(.primary) + .fontWeight(.semibold) + + Text(" / ") + + Text(scheduledDurationString) + } + } + case .indefinite: + if let durationString = formatter.string(from: override.actualDuration.timeInterval) { + Text(durationString) + .foregroundStyle(.primary) + .fontWeight(.semibold) + } + } } -} - -#Preview { - PresetsHistoryView() } diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 3347bb358b..4b8ac975bf 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -74,9 +74,9 @@ struct PresetsView: View { activePreset, expectedEndTime: viewModel.activeOverride?.expectedEndTime ) -// .onTapGesture { -// viewModel.pendingPreset = activePreset -// } + .onTapGesture { + viewModel.pendingPreset = activePreset + } } // All Presets Section @@ -103,9 +103,9 @@ struct PresetsView: View { PresetCard(preset) .background(Color.white) .cornerRadius(12) -// .onTapGesture { -// viewModel.pendingPreset = preset -// } + .onTapGesture { + viewModel.pendingPreset = preset + } } } } @@ -115,7 +115,7 @@ struct PresetsView: View { Text("Support") .font(.title2.bold()) - NavigationLink(destination: PresetsHistoryView()) { + NavigationLink(destination: PresetsHistoryView(viewModel: viewModel)) { HStack { Image(systemName: "list.bullet") .foregroundColor(.white) @@ -137,7 +137,9 @@ struct PresetsView: View { .frame(maxWidth: .infinity)) if viewModel.hasCompletedTraining { - NavigationLink(destination: PresetsTrainingView { viewModel.hasCompletedTraining = true }) { + Button { + showTraining = true + } label: { HStack { Text("Review Presets Training") Spacer() @@ -156,6 +158,8 @@ struct PresetsView: View { } } .padding() + .animation(.default, value: viewModel.hasCompletedTraining) + .animation(.default, value: viewModel.activeOverride) } .background(Color(UIColor.secondarySystemBackground)) .navigationTitle(Text("Presets", comment: "Presets screen title")) diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 2dc0305523..e2da6fae75 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -32,10 +32,12 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { private let doseStore: DoseStore private let criticalEventLogExportManager: CriticalEventLogExportManager private let bluetoothStateManager: BluetoothStateManager + private let settingsViewModel: SettingsViewModel + private let statusTableViewModel: StatusTableViewModel let viewController: StatusTableViewController - init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager) { + init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, settingsViewModel: SettingsViewModel, statusTableViewModel: StatusTableViewModel) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter self.automaticDosingStatus = automaticDosingStatus @@ -54,6 +56,8 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { self.doseStore = doseStore self.criticalEventLogExportManager = criticalEventLogExportManager self.bluetoothStateManager = bluetoothStateManager + self.settingsViewModel = settingsViewModel + self.statusTableViewModel = statusTableViewModel let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: StatusTableViewController.self)) let statusTableViewController = storyboard.instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController @@ -74,6 +78,8 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { statusTableViewController.carbStore = carbStore statusTableViewController.doseStore = doseStore statusTableViewController.criticalEventLogExportManager = criticalEventLogExportManager + statusTableViewController.settingsViewModel = settingsViewModel + statusTableViewController.statusTableViewModel = statusTableViewModel bluetoothStateManager.addBluetoothObserver(statusTableViewController) self.viewController = statusTableViewController @@ -86,37 +92,36 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} } -struct StatusTableView: View { +@MainActor +@Observable +class StatusTableViewModel { + let alertPermissionsChecker: AlertPermissionsChecker + let alertMuter: AlertMuter + let deviceDataManager: DeviceDataManager + let supportManager: SupportManager + let testingScenariosManager: TestingScenariosManager? + let loopDataManager: LoopDataManager + let diagnosticReportGenerator: DiagnosticReportGenerator + let simulatedData: SimulatedData + let analyticsServicesManager: AnalyticsServicesManager + let servicesManager: ServicesManager + let carbStore: CarbStore + let doseStore: DoseStore + let criticalEventLogExportManager: CriticalEventLogExportManager + let bluetoothStateManager: BluetoothStateManager + let settingsManager: SettingsManager + let automaticDosingStatus: AutomaticDosingStatus + let onboardingManager: OnboardingManager + let temporaryPresetsManager: TemporaryPresetsManager + let settingsViewModel: SettingsViewModel - private let alertPermissionsChecker: AlertPermissionsChecker - private let alertMuter: AlertMuter - private let automaticDosingStatus: AutomaticDosingStatus - private let deviceDataManager: DeviceDataManager - private let displayGlucosePreference: DisplayGlucosePreference - private let onboardingManager: OnboardingManager - private let supportManager: SupportManager - private let testingScenariosManager: TestingScenariosManager? - private let settingsManager: SettingsManager - private let loopDataManager: LoopDataManager - private let diagnosticReportGenerator: DiagnosticReportGenerator - private let simulatedData: SimulatedData - private let analyticsServicesManager: AnalyticsServicesManager - private let servicesManager: ServicesManager - private let carbStore: CarbStore - private let doseStore: DoseStore - private let criticalEventLogExportManager: CriticalEventLogExportManager - private let bluetoothStateManager: BluetoothStateManager - - @Bindable var settingsViewModel: SettingsViewModel - - private let wrapped: WrappedStatusTableViewController - - var viewController: StatusTableViewController { - wrapped.viewController + var pendingPreset: SelectablePreset? { + didSet { + settingsViewModel.presetsViewModel.pendingPreset = pendingPreset + } } - init(displayGlucosePreference: DisplayGlucosePreference, alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager) { - self.displayGlucosePreference = displayGlucosePreference + init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, settingsViewModel: SettingsViewModel) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter self.automaticDosingStatus = automaticDosingStatus @@ -124,6 +129,7 @@ struct StatusTableView: View { self.onboardingManager = onboardingManager self.supportManager = supportManager self.testingScenariosManager = testingScenariosManager + self.temporaryPresetsManager = temporaryPresetsManager self.settingsManager = settingsManager self.loopDataManager = loopDataManager self.diagnosticReportGenerator = diagnosticReportGenerator @@ -134,50 +140,79 @@ struct StatusTableView: View { self.doseStore = doseStore self.criticalEventLogExportManager = criticalEventLogExportManager self.bluetoothStateManager = bluetoothStateManager + self.settingsViewModel = settingsViewModel + } +} + +struct StatusTableView: View { + + private let wrapped: WrappedStatusTableViewController + + var viewController: StatusTableViewController { + wrapped.viewController + } + + @ViewBuilder + var wrappedView: some View { wrapped } + + @Bindable var viewModel: StatusTableViewModel + + init(viewModel: StatusTableViewModel) { + self.viewModel = viewModel - self.wrapped = WrappedStatusTableViewController(alertPermissionsChecker: alertPermissionsChecker, alertMuter: alertMuter, automaticDosingStatus: automaticDosingStatus, deviceDataManager: deviceDataManager, onboardingManager: onboardingManager, supportManager: supportManager, testingScenariosManager: testingScenariosManager, settingsManager: settingsManager, temporaryPresetsManager: temporaryPresetsManager, loopDataManager: loopDataManager, diagnosticReportGenerator: diagnosticReportGenerator, simulatedData: simulatedData, analyticsServicesManager: analyticsServicesManager, servicesManager: servicesManager, carbStore: carbStore, doseStore: doseStore, criticalEventLogExportManager: criticalEventLogExportManager, bluetoothStateManager: bluetoothStateManager) - - self.settingsViewModel = wrapped.viewController.settingsViewModel + self.wrapped = WrappedStatusTableViewController( + alertPermissionsChecker: viewModel.alertPermissionsChecker, + alertMuter: viewModel.alertMuter, + automaticDosingStatus: viewModel.automaticDosingStatus, + deviceDataManager: viewModel.deviceDataManager, + onboardingManager: viewModel.onboardingManager, + supportManager: viewModel.supportManager, + testingScenariosManager: viewModel.testingScenariosManager, + settingsManager: viewModel.settingsManager, + temporaryPresetsManager: viewModel.temporaryPresetsManager, + loopDataManager: viewModel.loopDataManager, + diagnosticReportGenerator: viewModel.diagnosticReportGenerator, + simulatedData: viewModel.simulatedData, + analyticsServicesManager: viewModel.analyticsServicesManager, + servicesManager: viewModel.servicesManager, + carbStore: viewModel.carbStore, + doseStore: viewModel.doseStore, + criticalEventLogExportManager: viewModel.criticalEventLogExportManager, + bluetoothStateManager: viewModel.bluetoothStateManager, + settingsViewModel: viewModel.settingsViewModel, + statusTableViewModel: viewModel + ) } func isActive(action: ToolbarAction) -> Bool { switch action { case .addCarbs, .bolus, .settings: // No active states for these actions return false - case .preMealPreset: - return settingsViewModel.presetsViewModel.temporaryPresetsManager.preMealTargetEnabled() - case .workoutPreset: - return settingsViewModel.presetsViewModel.temporaryPresetsManager.nonPreMealOverrideEnabled() case .presets: - return settingsViewModel.presetsViewModel.activeOverride != nil + return viewModel.settingsViewModel.presetsViewModel.activeOverride != nil } } func isDisabled(action: ToolbarAction) -> Bool { switch action { - case .addCarbs, .bolus, .presets, .settings: + case .addCarbs, .bolus, .settings: false - case .preMealPreset: - !(onboardingManager.isComplete && - (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) - && settingsManager.settings.preMealTargetRange != nil) - case .workoutPreset: - viewController.workoutMode != nil && onboardingManager.isComplete + case .presets: + !viewModel.onboardingManager.isComplete } } var body: some View { - wrapped - .sheet(item: $settingsViewModel.presetsViewModel.pendingPreset) { preset in + wrappedView + .sheet(item: $viewModel.pendingPreset) { _ in PresetDetentView( - viewModel: settingsViewModel.presetsViewModel, - preset: preset + viewModel: viewModel.settingsViewModel.presetsViewModel ) } .toolbar { ToolbarItem(placement: .bottomBar) { HStack(alignment: .bottom) { - ForEach(ToolbarAction.new) { action in + ForEach(ToolbarAction.allCases) { action in action.button( showTitle: true, isActive: isActive(action: action), @@ -186,12 +221,8 @@ struct StatusTableView: View { switch action { case .addCarbs: viewController.userTappedAddCarbs() - case .preMealPreset: - viewController.togglePreMealMode() case .bolus: viewController.presentBolusScreen() - case .workoutPreset: - viewController.presentCustomPresets() case .presets: viewController.presentPresets() case .settings: @@ -210,29 +241,25 @@ struct StatusTableView: View { enum ToolbarAction: String, Identifiable, CaseIterable { case addCarbs - case preMealPreset case bolus - case workoutPreset case presets case settings - static var legacy: [ToolbarAction] = [ - .addCarbs, - .preMealPreset, - .bolus, - .workoutPreset, - .settings - ] - - static var new: [ToolbarAction] = [ - .addCarbs, - .bolus, - .presets, - .settings - ] - var id: String { self.rawValue } + var accessibilityIdentifier: String { + switch self { + case .addCarbs: + "statusTableViewControllerCarbsButton" + case .bolus: + "statusTableViewControllerBolusButton" + case .presets: + "statusTableViewPresetsButton" + case .settings: + "statusTableViewControllerSettingsButton" + } + } + @ViewBuilder func icon(isActive: Bool) -> some View { Group { @@ -242,21 +269,11 @@ enum ToolbarAction: String, Identifiable, CaseIterable { .resizable() .renderingMode(.template) .foregroundStyle(Color.carbs) - case .preMealPreset: - Image(isActive ? "Pre-Meal Selected" : "Pre-Meal") - .resizable() - .renderingMode(.template) - .foregroundStyle(Color.carbs) case .bolus: Image("bolus") .resizable() .renderingMode(.template) .foregroundStyle(Color.insulin) - case .workoutPreset: - Image(isActive ? "workout-selected" : "workout") - .resizable() - .renderingMode(.template) - .foregroundStyle(Color.glucose) case .presets: Image(isActive ? "presets-selected" : "presets") .resizable() @@ -279,12 +296,8 @@ enum ToolbarAction: String, Identifiable, CaseIterable { switch self { case .addCarbs: Text("Add Carbs", comment: "The label of the carb entry button") - case .preMealPreset: - Text("Pre-Meal Preset", comment: "The label of the pre-meal mode toggle button") case .bolus: Text("Bolus", comment: "The label of the bolus entry button") - case .workoutPreset: - Text("Workout Preset", comment: "The label of the workout mode toggle button") case .presets: Text("Presets", comment: "The label of the presets button") case .settings: @@ -311,5 +324,6 @@ enum ToolbarAction: String, Identifiable, CaseIterable { .buttonStyle(.plain) .disabled(disabled) .contentShape(Rectangle()) + .accessibilityIdentifier(accessibilityIdentifier) } } From df1ff80d6cef435dabd0f1fd61455e8b07c4a999 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 17 Dec 2024 00:54:27 -0800 Subject: [PATCH 193/421] [LOOP-5056] Presets Homepage Updates - Part 3 (#735) --- Loop/Managers/LoopAppManager.swift | 2 -- Loop/Managers/TemporaryPresetsManager.swift | 19 +++++++++---- .../StatusTableViewController.swift | 6 ++-- Loop/View Models/PresetsViewModel.swift | 11 +++++--- .../Components/EditOverrideDurationView.swift | 28 +++++++++++++++++-- .../Presets/Components/PresetDetentView.swift | 13 +-------- .../PresetsAndExerciseContentView.swift | 28 +++++++++++-------- 7 files changed, 66 insertions(+), 41 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 1087ee8930..683ae775fd 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -944,8 +944,6 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { extension LoopAppManager: TemporaryScheduleOverrideHistoryDelegate { func temporaryScheduleOverrideHistoryDidUpdate(_ history: TemporaryScheduleOverrideHistory) { - TemporaryScheduleOverrideHistoryContainer.shared.deleteAll() - TemporaryScheduleOverrideHistoryContainer.shared.context.insert(history) remoteDataServicesManager.triggerUpload(for: .overrides) } } diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 584b0f8366..cdf61c6bc1 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -86,11 +86,7 @@ class TemporaryPresetsManager { if scheduleOverride != nil { preMealOverride = nil } - - if let newValue = scheduleOverride, newValue.context == .preMeal { -// preconditionFailure("The `scheduleOverride` field should not be used for a pre-meal target range override; use `preMealOverride` instead") - } - + if scheduleOverride != oldValue { overrideHistory.recordOverride(scheduleOverride) @@ -269,7 +265,18 @@ class TemporaryPresetsManager { ] ) } - + + func updateActiveOverrideDuration(newEndDate: Date) { + if var scheduleOverride { + if newEndDate > Date() { + scheduleOverride.scheduledEndDate = newEndDate + } else { + scheduleOverride.scheduledEndDate = newEndDate.addingTimeInterval(.days(1)) + } + + self.scheduleOverride = scheduleOverride + } + } } public protocol SettingsWithOverridesProvider { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 014f942e3d..4b331d1dda 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -849,7 +849,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case (true, false): tableView.deleteRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .top : .none) default: - break + tableView.reloadRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .automatic : .none) } switch (hudWasVisible, hudIsVisible) { @@ -1031,9 +1031,9 @@ final class StatusTableViewController: LoopChartsTableViewController { switch override.duration { case .finite: let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) - cell.subtitleLabel.text = String(format: NSLocalizedString("on until %@", comment: "The format for the description of a custom preset end date"), endTimeText) + cell.subtitleLabel.text = String(format: NSLocalizedString("on until %@", comment: "The format for the description of a finite custom preset end date"), endTimeText) case .indefinite: - cell.subtitleLabel.text = nil + cell.subtitleLabel.text = NSLocalizedString("on indefinitely", comment: "The format for the description of an indefinite custom preset end date") } } } else { diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift index 3822ff139c..2d291d62f6 100644 --- a/Loop/View Models/PresetsViewModel.swift +++ b/Loop/View Models/PresetsViewModel.swift @@ -10,7 +10,7 @@ import SwiftUI import LoopAlgorithm import LoopKit -enum PresetDurationType { +enum PresetDurationType: Equatable { case untilCarbsEntered case duration(TimeInterval) case indefinite @@ -26,8 +26,7 @@ extension TemporaryScheduleOverride { var expectedEndTime: PresetExpectedEndTime? { switch context { case .preMeal: return .untilCarbsEntered - case .legacyWorkout: return .indefinite - case .custom, .preset: + case .legacyWorkout, .custom, .preset: switch duration { case .indefinite: return .indefinite case .finite: return .scheduled(scheduledEndDate) @@ -96,7 +95,7 @@ enum SelectablePreset: Hashable, Identifiable { switch self { case .custom(let preset): return .emoji(preset.symbol) case .preMeal: return .image("Pre-Meal", .carbTintColor) - case .legacyWorkout: return .image("workout", .insulinTintColor) + case .legacyWorkout: return .image("workout", .glucoseTintColor) } } @@ -285,4 +284,8 @@ public class PresetsViewModel { temporaryPresetsManager.scheduleOverride = nil } } + + func updateActivePresetDuration(newEndDate: Date) { + temporaryPresetsManager.updateActiveOverrideDuration(newEndDate: newEndDate) + } } diff --git a/Loop/Views/Presets/Components/EditOverrideDurationView.swift b/Loop/Views/Presets/Components/EditOverrideDurationView.swift index 5b871d1627..52011b3bfe 100644 --- a/Loop/Views/Presets/Components/EditOverrideDurationView.swift +++ b/Loop/Views/Presets/Components/EditOverrideDurationView.swift @@ -12,20 +12,41 @@ import SwiftUI struct EditOverrideDurationView: View { + @Environment(\.dismiss) private var dismiss + let viewModel: PresetsViewModel let override: TemporaryScheduleOverride + @State var dateSelection: Date + + private let currentDate: Date init(override: TemporaryScheduleOverride, viewModel: PresetsViewModel) { self.override = override self.viewModel = viewModel - dateSelection = override.actualEndDate + self.currentDate = Date() + + if case let .duration(timeInterval) = viewModel.activePreset?.duration { + dateSelection = override.startDate.addingTimeInterval(timeInterval) + } else { + dateSelection = currentDate + } } var preset: SelectablePreset? { viewModel.allPresets.first(where: { $0.id == override.presetId }) } + var buttonDisabled: Bool { + if case .duration = viewModel.activePreset?.duration { + return dateSelection == override.actualEndDate + } else if case .indefinite = viewModel.activePreset?.duration { + return false + } else { + return dateSelection == currentDate + } + } + var body: some View { ZStack { Color(UIColor.secondarySystemBackground) @@ -47,13 +68,14 @@ struct EditOverrideDurationView: View { .padding(.horizontal) Button("Save") { - // + viewModel.updateActivePresetDuration(newEndDate: dateSelection) + dismiss() } .buttonStyle(ActionButtonStyle()) .padding([.top, .horizontal]) .background(Color.white) .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: -4) - .disabled(dateSelection == override.actualEndDate) + .disabled(buttonDisabled) } } } diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index a4b6640515..54b0df1412 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -89,18 +89,7 @@ struct PresetDetentView: View { } .buttonStyle(ActionButtonStyle(.destructive)) - - switch preset { - case .custom: - NavigationLink("Adjust Preset Duration") { - if let activeOverride { - EditOverrideDurationView(override: activeOverride, viewModel: viewModel) - } - } - .buttonStyle(ActionButtonStyle(.tertiary)) - case .preMeal: - EmptyView() - case .legacyWorkout: + if preset.duration != .untilCarbsEntered { NavigationLink("Adjust Preset Duration") { if let activeOverride { EditOverrideDurationView(override: activeOverride, viewModel: viewModel) diff --git a/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift b/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift index 573fd9ad91..f2467b1e2c 100644 --- a/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift +++ b/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift @@ -7,6 +7,7 @@ // import LoopAlgorithm +import LoopKit import LoopKitUI import SwiftUI @@ -137,17 +138,22 @@ struct PresetsAndExerciseContentView: View { Text("Once saved, Omar’s completed preset will display in his Presets lists.", comment: "Presets and exercise training content, scheduling preset, paragraph 2") PresetCard( - icon: .emoji("🚶"), - presetName: NSLocalizedString("Walk to Work", comment: "Presets and exercise training content, scheduling preset, preset example, title"), - duration: .duration(.seconds(1800)), - insulinSensitivityMultiplier: 1.0, - correctionRange: ClosedRange( - uncheckedBounds: ( - LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140), - LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 260)) - ), - guardrail: nil, - expectedEndTime: .indefinite + SelectablePreset.custom( + TemporaryScheduleOverridePreset( + symbol: "🚶", + name: NSLocalizedString("Walk to Work", comment: "Presets and exercise training content, scheduling preset, preset example, title"), + settings: TemporaryScheduleOverrideSettings( + targetRange: ClosedRange( + uncheckedBounds: ( + LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140), + LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 160) + ) + ), + insulinNeedsScaleFactor: 1.0 + ), + duration: TemporaryScheduleOverride.Duration.finite(1800) + ) + ) ) } } From f6a4cd0f515565f7547cd6ed186a621244c1f8b0 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 18 Dec 2024 05:47:21 -0400 Subject: [PATCH 194/421] [LOOOP-5175] assigning services view model delegate (#736) --- 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 4b331d1dda..f2c9709fe3 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -79,9 +79,10 @@ final class StatusTableViewController: LoopChartsTableViewController { override func viewDidLoad() { super.viewDidLoad() - + setupToolbarItems() statusTableViewModel.settingsViewModel.delegate = self + statusTableViewModel.settingsViewModel.servicesViewModel.delegate = self statusTableViewModel.settingsViewModel.pumpManagerSettingsViewModel.didTap = { [weak self] in self?.onPumpTapped() } From 19ca694ac36eb80c6ddb1cae7eb040ceabebb939 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 19 Dec 2024 11:36:07 -0800 Subject: [PATCH 195/421] [LOOP-5056] Presets Homepage Bugs (#737) --- .../presets-selected.imageset/Contents.json | 12 + .../Temp Presets.pdf | Bin 0 -> 4256 bytes .../presets.imageset/Contents.json | 12 + .../presets.imageset/Temp Presets-2.pdf | Bin 0 -> 4057 bytes .../settings.imageset/Contents.json | 39 +--- .../settings.imageset/settings.png | Bin 820 -> 0 bytes .../settings.imageset/settings@2x.png | Bin 1898 -> 0 bytes .../settings.imageset/settings@3x.png | Bin 2921 -> 3369 bytes .../settings.imageset/settings_compact@2x.png | Bin 1424 -> 0 bytes .../settings.imageset/settings_compact@3x.png | Bin 2223 -> 0 bytes Loop/Extensions/ChartColorPalette+Loop.swift | 2 +- Loop/Managers/ExtensionDataManager.swift | 4 +- Loop/Managers/LoopDataManager.swift | 4 +- Loop/Managers/TemporaryPresetsManager.swift | 46 +++- .../RootNavigationController.swift | 4 +- .../StatusTableViewController.swift | 217 +----------------- Loop/View Models/CarbEntryViewModel.swift | 4 +- Loop/View Models/PresetsViewModel.swift | 10 +- .../Presets/Components/PresetDetentView.swift | 11 +- Loop/Views/Presets/PresetsHistoryView.swift | 8 +- Loop/Views/Presets/PresetsView.swift | 6 +- Loop/Views/StatusTableView.swift | 51 ++-- WatchApp/DerivedAssets.xcassets/Contents.json | 6 +- 23 files changed, 138 insertions(+), 298 deletions(-) create mode 100644 Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Contents.json create mode 100644 Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Temp Presets.pdf create mode 100644 Loop/DerivedAssetsBase.xcassets/presets.imageset/Contents.json create mode 100644 Loop/DerivedAssetsBase.xcassets/presets.imageset/Temp Presets-2.pdf delete mode 100644 Loop/DerivedAssetsBase.xcassets/settings.imageset/settings.png delete mode 100644 Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@2x.png delete mode 100644 Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@2x.png delete mode 100644 Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@3x.png diff --git a/Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Contents.json new file mode 100644 index 0000000000..454c684ec3 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Temp Presets.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Temp Presets.pdf b/Loop/DerivedAssetsBase.xcassets/presets-selected.imageset/Temp Presets.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e963fb799f3c1e32c284a7828c4646744a3717c2 GIT binary patch literal 4256 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 literal 0 HcmV?d00001 diff --git a/Loop/DerivedAssetsBase.xcassets/presets.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/presets.imageset/Contents.json new file mode 100644 index 0000000000..4e31050302 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/presets.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Temp Presets-2.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/presets.imageset/Temp Presets-2.pdf b/Loop/DerivedAssetsBase.xcassets/presets.imageset/Temp Presets-2.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4fd9b592f23f0d64a9a7cce10e406f24908090f0 GIT binary patch literal 4057 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 B0C)fZ literal 0 HcmV?d00001 diff --git a/Loop/DerivedAssetsBase.xcassets/settings.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/settings.imageset/Contents.json index d5ccdb99c9..50dba31153 100644 --- a/Loop/DerivedAssetsBase.xcassets/settings.imageset/Contents.json +++ b/Loop/DerivedAssetsBase.xcassets/settings.imageset/Contents.json @@ -1,40 +1,15 @@ { "images" : [ { - "idiom" : "universal", - "filename" : "settings.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "settings@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", "filename" : "settings@3x.png", - "scale" : "3x" - }, - { - "idiom" : "universal", - "scale" : "1x", - "height-class" : "compact" - }, - { - "idiom" : "universal", - "filename" : "settings_compact@2x.png", - "height-class" : "compact", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "settings_compact@3x.png", - "height-class" : "compact", - "scale" : "3x" + "idiom" : "universal" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } -} \ No newline at end of file +} diff --git a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings.png deleted file mode 100644 index 14fc3623dbe31fa52209545f2dc65e0831f20a16..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 820 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 diff --git a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@3x.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@3x.png index 6ee011f3c9edd0661bf7a62fcd29632d4b68d909..53afbae4cc3a8655e87e6f1d5a55bb0e5d7a26b2 100644 GIT binary patch literal 3369 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%0DYMw^ky`Ib)6RT*(2qxP*8WzP} q`hiyD9+M)A7XQzj9PDiYc+!x~tAZ~@|NeK9d2U)FZqO{S3I7EqU60=Y diff --git a/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@2x.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@2x.png deleted file mode 100644 index ccac27347e3adb0f03004325d66cbf2e0935d92f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1424 zcmV;B1#kL^P)Px#32;bRa{vGqB>(^xB>_oNB=7(L1vN=TK~!i3?U-APol_KmYfMU|K@+zr>aNvz zAPALWs+Fc8t$0#YRniBcXo$69;CdKB1rJkR7KDb38Jc`R6D9gl)9v) zrRDpMf0=#$IcLt<hKp$xT7<5d=5XqqO z6PyDh;1LLYv+^};4uPZK^Thsg>K&D}M2kt7tzVON=$nCr*|DAedn`)jMg!!G| za-?z}jb;6_M8w0P z5N+o}1aX;rA>=?RUxNKNgfgRazixquzoeP(enp3y`E1w^=4p zY==|8`!Pp6L>?elz^!vVINoUR0p5p)pe7sm8&6X%O@BPdKi2(!R~CLt`&C)B!7h3e z_3yYR(0BvNle66m&14)MKK&nHJy&{g@X{`iYr-a!gE`xIuoJ@JsCfMg3eDbeW2vBPg$h7|$hs5AKJ{p!TNn zdI*DZ>3!F(EXT0y|6kj?x(K=v_BEM>1GFpNU7Z%UOif1dcLqENzd$^ByGwiqKECYr za}S>d!=aUEGaF(&x7QOe3z|N#`!F0PQ*R~iKi>%YybZn?awI(@AC?_m;<9{|C@xCs z&8jo$xB$$cKb-bLj+~bm``Yw@8;U2!^hWk9*EY25SB|9WD@og-zV22O{xPmAcpTKp z#k4j<7^UT;I?MXTy@ih#b?r*1?{3+0kN*bgc-DW0xlqfde;lA}6zzAzkTMm&VB$S` zb=r|pH|sG@xpYq5vEEnF7z0~j3wZ6Uf;fpcQ}=|Y;+OTM-~s6c2gniko|k$z=>LX^9Pt(_y_!e+U!_k2LXCT^9 zZbBD(nChT%D!op?y&e8O?7F0_eh#!0e+OvX2mT=PptzwMopgVlErKcF#_k5?aF_rd zuGb-*n6Y=jH1GjufWJS~KM5_x&!lU>iKXIy84S`1yt-0xqEo@Khb(3^CYSy!2wkf( z{j75z`|p&F$!SE232db9HQ`6;%ZWbv%caZba+@8OJNjo)@0i?5Bm;YQotQ+OUFhxG z2L878kgbN8$WrPdzPnvs`<92Y6XiI9_?-wdyE1NS#NUr=0(a#YPk=)BI zXYMmg386XH2)*7v;C(;O=lgs=&wubdx^7{_&x_;*008_Z#`=U~V~=s>h938xHRQ>$ zfOiQ-x`6Tl@s;Dj6JYFc7XXk}`!7I)bPO2);I=l=*Rc)(uG_==#;p*n98>GmmHg^6 z#6sgMb4FP&;5FzJ45do-T)jhSTBXcun@g=jg{bP4{WQ}`LlI}=XTf)SbT6fF4%hzu z`P%$8eVh8H_Sds@>i@+JI;}&Me}&uUP50I$bytcBG( z2s^=~696t;VWa`*pW^dtuFR{~!%7PZU-5rnVrb2!*`H&KrpiZ#D5(39DKFLnX-mX7 zp7|4T67R&^hpYcZK7iq#`{JDQ@LuN{S8%eLjf3bbsktTJ_SL^TFcq93u?nh}`igCc3^?C;8b8aWCikje|G$Q zgf}oF$%Xkxc1O5zKT=UBm#3J1ucqTw&|SpOQ?3V1adiZ9)Wa{@*E$CwEhQf1at&G+ z-q&#cu?02(O(j0ajb-di1v~q`KH23%i+O_C_Ompj;Mz0}0ga`-s9>FjL20+(d%Ce{ zp>hGDL)^@?R__FgpJSc?L;3d!GCZ}W3uPWk@TMs#hXd;#C2g{59n-g|HWfqoDVqmLew>9{HDYKaD1Z0s#?QGjt)vpQpNeLV zt?Z%$u(2a7={DsHiBu!)F^9;Ui$7Iz9H#|mn(xKh_6;wMd|HY=r~#C^!I&`ItvzIK ze8^D?xc!~)o_9i*w1@_eZHQ`_U}ETiB{bUpx_%-lMo2|TLm>7Z@FuC2zV(-`X4Ex{ zaT@F64_%qT@SIU-r&EQ}(c??_`baH^S2LNN8ufe@F3#1=MFeHNZwe83Thm_g5!Ff8 zS@@bO5ZhR+&DX@g%%OH<_Ani@ZL*BYLe=fZWWkVyXx3aQ{Hc$$FlIvUCHW+Mlh*C8 z+mfEs_d;7hwY|BzxwW$<@CgJ-bIHal#F8$1?rYBemMhVTnfisw+|=S%qabqcZ^t7-MYl~nA<;4j7yr=H zgRfX}!%O#>3u-6!h_09vT=0x~B2cES;kiMSD(8%kz&8_jbm3P)Z9zuGv-TTy52bT@ zR8%DJr_Z#69Y_6g!94o5-WKT@BpKgBLAzmbKu`ov_bA*ZyBYIJ;w*0ulOekBin#qwO;!&e7D)}%v`Zc&- ziHO}?v-f;|^UT?KL|X)2^SNck5>O&rZYrI{ypO{y91c*o9vyiscYVIyk>HktK?_$( zj-~6A3tgAGEsg&$aLsCHhvH|W8hWOlV_?;9X3h2uE5}d`g_p&5b|&0uziGh?5!%%N z?#6etq|670to%@|52*+2Ax+Ta`(_YJk1Wfeu6JW6IVe@O%Z- zvlokKh2()FV8FJ5bs0kp@JNpse&NtN(dZQqLt|LXSpTauDmGYCG7LFB3I!js2uHJh z@%CZDAPwkBOIC*9v}>qH+dF!`c%>Z5wko%PWZLhsGR(#@_A@r;91^^pHHZ>l;!b#g z)@cU#M8`6*WLV`zPIH+8N2J@jxZO)4DIyP3!F3G+O#y`{qu14EXN}~K1In}E?llf5F3!C+B&P?GN)!j^t zPhj^3c$e$+h^MC~EDZL{PLg@96O1h)c8<+Mp4Tzxj730&dQMM0Llg%ipWG0Cb2eb= zO{0iZ*E9i)08OH_VU6wWF}_eXxPUeSG!>w^M#=Anl=Fk!6vNPOPEB&5+18_c#3BRF zM21s|i067mV%$TxKLV2*dIzE~NyA^$;>|5-A*Ty%ix1biNX<_s0ZakSq+O?Bt@zJq za#Y)i!LT$dYRG6j)2+Go@q*8e9a0p^z)upG`15ZE z`g)T|bUX2N9~j4?n6O~k_LMQiUD1Z=0zi}{st!l7i|OjOqB|rpJ0|n87cin#_F4oE zxb1Ys>2S1qfe8fS1g?44&V}DVe%*99#XX90F7Iow=xFUoNn$XwpzwCE;LklgX kt7sO(7yUo;vYVlu#E!ojH3xM%J{ Bool { - temporaryPresetsManager.scheduleOverrideEnabled(at: date) + func isScheduleOverrideActive(at date: Date) -> Bool { + temporaryPresetsManager.isScheduleOverrideActive(at: date) } var defaultAbsorptionTimes: DefaultAbsorptionTimes { diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index cdf61c6bc1..f1dee4f5b6 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -99,6 +99,8 @@ class TemporaryPresetsManager { for observer in self.presetActivationObservers { observer.presetActivated(context: newPreset.context, duration: newPreset.duration) } + + scheduleClearOverride(override: newPreset) } } @@ -111,21 +113,42 @@ class TemporaryPresetsManager { guard oldValue != preMealOverride else { return } - + if let newValue = preMealOverride, newValue.context != .preMeal || newValue.settings.insulinNeedsScaleFactor != nil { preconditionFailure("The `preMealOverride` field should be used only for a pre-meal target range override") } - + if preMealOverride != nil { scheduleOverride = nil } - + overrideHistory.recordOverride(preMealOverride) - + notify(forChange: .preferences) } } + + public var activeOverride: TemporaryScheduleOverride? { + let override = (preMealOverride ?? scheduleOverride) + if override?.isActive() == true { + return override + } else { + return nil + } + } + var clearOverrideTimer: Timer? + public func scheduleClearOverride(override: TemporaryScheduleOverride) { + clearOverrideTimer?.invalidate() + clearOverrideTimer = Timer.scheduledTimer(withTimeInterval: override.scheduledEndDate.timeIntervalSince(Date()), repeats: false, block: { [weak self] _ in + if override == self?.scheduleOverride { + self?.clearOverride() + } else if override == self?.preMealOverride { + self?.clearOverride(matching: .preMeal) + } + }) + } + public var isScheduleOverrideInfiniteWorkout: Bool { guard let scheduleOverride = scheduleOverride else { return false } return scheduleOverride.context == .legacyWorkout && scheduleOverride.duration.isInfinite @@ -137,7 +160,7 @@ class TemporaryPresetsManager { return nil } - let preMealOverride = presumingMealEntry ? nil : self.preMealOverride + let preMealOverride = presumingMealEntry ? nil : (self.scheduleOverride?.context == .preMeal ? self.scheduleOverride : nil) let currentEffectiveOverride: TemporaryScheduleOverride? switch (preMealOverride, scheduleOverride) { @@ -160,16 +183,16 @@ class TemporaryPresetsManager { } } - public func scheduleOverrideEnabled(at date: Date = Date()) -> Bool { + public func isScheduleOverrideActive(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 isNonPreMealOverrideActive(at date: Date = Date()) -> Bool { + return isScheduleOverrideActive(at: date) == true && scheduleOverride?.context != .preMeal } - public func preMealTargetEnabled(at date: Date = Date()) -> Bool { - return preMealOverride?.isActive(at: date) == true + public func isPreMealTargetActive(at date: Date = Date()) -> Bool { + return isScheduleOverrideActive(at: date) == true && scheduleOverride?.context == .preMeal } public func futureOverrideEnabled(relativeTo date: Date = Date()) -> Bool { @@ -178,7 +201,7 @@ class TemporaryPresetsManager { } public func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { - preMealOverride = makePreMealOverride(beginningAt: date, for: duration) + scheduleOverride = makePreMealOverride(beginningAt: date, for: duration) } private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { @@ -275,6 +298,7 @@ class TemporaryPresetsManager { } self.scheduleOverride = scheduleOverride + self.scheduleClearOverride(override: scheduleOverride) } } } diff --git a/Loop/View Controllers/RootNavigationController.swift b/Loop/View Controllers/RootNavigationController.swift index 87b04d5e93..f4656aa3f8 100644 --- a/Loop/View Controllers/RootNavigationController.swift +++ b/Loop/View Controllers/RootNavigationController.swift @@ -22,11 +22,11 @@ class RootNavigationController: UINavigationController { case .carbEntry: statusTableViewController.presentCarbEntryScreen(nil) case .preMeal: - statusTableViewController.togglePreMealMode() + statusTableViewController.presentPresets() case .bolus: statusTableViewController.presentBolusScreen() case .customPresets: - statusTableViewController.presentCustomPresets() + statusTableViewController.presentPresets() } } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index f2c9709fe3..4feaeb8388 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -79,8 +79,7 @@ final class StatusTableViewController: LoopChartsTableViewController { override func viewDidLoad() { super.viewDidLoad() - - setupToolbarItems() + statusTableViewModel.settingsViewModel.delegate = self statusTableViewModel.settingsViewModel.servicesViewModel.delegate = self statusTableViewModel.settingsViewModel.pumpManagerSettingsViewModel.didTap = { [weak self] in @@ -148,14 +147,12 @@ final class StatusTableViewController: LoopChartsTableViewController { Task { @MainActor [weak self] in self?.registerPumpManager() self?.configurePumpManagerHUDViews() - self?.updateToolbarItems() } }, 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) { (notification: Notification) in @@ -227,6 +224,10 @@ final class StatusTableViewController: LoopChartsTableViewController { } private var appearedOnce = false + + func presentLegacyPresets() { + performSegue(withIdentifier: OverrideSelectionViewController.className, sender: view) + } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -234,8 +235,6 @@ final class StatusTableViewController: LoopChartsTableViewController { navigationController?.setNavigationBarHidden(true, animated: animated) navigationController?.setToolbarHidden(false, animated: animated) - updateToolbarItems() - alertPermissionsChecker.checkNow() updateBolusProgress() @@ -246,7 +245,6 @@ final class StatusTableViewController: LoopChartsTableViewController { Task { @MainActor in self?.refreshContext.update(with: .status) await self?.reloadData(animated: true) - self?.updateToolbarItems() } } .store(in: &cancellables) @@ -353,47 +351,6 @@ final class StatusTableViewController: LoopChartsTableViewController { deviceManager.pumpManagerHUDProvider?.visible = active && onscreen } - private func setupToolbarItems() { - let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil) - let carbs = UIBarButtonItem(image: UIImage(named: "carbs"), style: .plain, target: self, action: #selector(userTappedAddCarbs)) - let bolus = UIBarButtonItem(image: UIImage(named: "bolus"), style: .plain, target: self, action: #selector(presentBolusScreen)) - let settings = UIBarButtonItem(image: UIImage(named: "settings"), style: .plain, target: self, action: #selector(onSettingsTapped)) - - carbs.accessibilityIdentifier = "statusTableViewControllerCarbsButton" - bolus.accessibilityIdentifier = "statusTableViewControllerBolusButton" - settings.accessibilityIdentifier = "statusTableViewControllerSettingsButton" - - let preMeal = createPreMealButtonItem(selected: false, isEnabled: true) - let workout = createWorkoutButtonItem(selected: false, isEnabled: true) - toolbarItems = [ - carbs, - space, - preMeal, - space, - bolus, - space, - workout, - space, - settings - ] - } - - private func updateToolbarItems() { - let isPumpOnboarded = onboardingManager.isComplete || deviceManager.pumpManager?.isOnboarded == true - - toolbarItems![0].accessibilityLabel = NSLocalizedString("Add Meal", comment: "The label of the carb entry button") - toolbarItems![0].isEnabled = isPumpOnboarded - toolbarItems![0].tintColor = UIColor.carbTintColor - toolbarItems![4].accessibilityLabel = NSLocalizedString("Bolus", comment: "The label of the bolus entry button") - toolbarItems![4].isEnabled = isPumpOnboarded - toolbarItems![4].tintColor = UIColor.insulinTintColor - toolbarItems![8].accessibilityLabel = NSLocalizedString("Settings", comment: "The label of the settings button") - toolbarItems![8].tintColor = UIColor.secondaryLabel - - toolbarItems![2] = createPreMealButtonItem(selected: preMealMode == true && preMealModeAllowed, isEnabled: preMealModeAllowed) - toolbarItems![6] = createWorkoutButtonItem(selected: workoutMode == true && workoutModeAllowed, isEnabled: workoutModeAllowed) - } - public var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil { didSet { if oldValue != basalDeliveryState { @@ -563,20 +520,6 @@ final class StatusTableViewController: LoopChartsTableViewController { totalDelivery = await loopManager.totalDeliveredToday()?.value } - updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) - - if settingsManager.settings.preMealTargetRange == nil { - preMealMode = nil - } else { - preMealMode = temporaryPresetsManager.preMealTargetEnabled() - } - - if !FeatureFlags.sensitivityOverridesEnabled, settingsManager.settings.workoutTargetRange == nil { - workoutMode = nil - } else { - workoutMode = temporaryPresetsManager.nonPreMealOverrideEnabled() - } - /// Update the chart data // Glucose @@ -918,43 +861,6 @@ final class StatusTableViewController: LoopChartsTableViewController { tableView.endUpdates() } - // MARK: - Toolbar data - - private var preMealMode: Bool? = nil { - didSet { - guard oldValue != preMealMode else { - return - } - updatePresetModeAvailability(automaticDosingEnabled: automaticDosingStatus.automaticDosingEnabled) - } - } - private lazy var preMealModeAllowed: Bool = { - onboardingManager.isComplete && - (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) - && settingsManager.settings.preMealTargetRange != nil - }() - - private func updatePresetModeAvailability(automaticDosingEnabled: Bool) { - preMealModeAllowed = onboardingManager.isComplete && - (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) - && settingsManager.settings.preMealTargetRange != nil - workoutModeAllowed = onboardingManager.isComplete && workoutMode != nil - updateToolbarItems() - } - - private(set) var workoutMode: Bool? = nil { - didSet { - guard oldValue != workoutMode else { - return - } - workoutModeAllowed = workoutMode != nil && onboardingManager.isComplete - updateToolbarItems() - } - } - private lazy var workoutModeAllowed: Bool = { - workoutMode != nil && onboardingManager.isComplete - }() - // MARK: - Table view data source override func numberOfSections(in tableView: UITableView) -> Int { @@ -1282,7 +1188,7 @@ final class StatusTableViewController: LoopChartsTableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch Section(rawValue: indexPath.section)! { case .presets: - statusTableViewModel.pendingPreset = settingsViewModel.presetsViewModel.allPresets.first(where: { $0.id == (temporaryPresetsManager.scheduleOverride ?? temporaryPresetsManager.preMealOverride)?.presetId }) + statusTableViewModel.pendingPreset = settingsViewModel.presetsViewModel.activePreset case .alertWarning: if alertPermissionsChecker.showWarning { tableView.deselectRow(at: indexPath, animated: true) @@ -1527,116 +1433,6 @@ final class StatusTableViewController: LoopChartsTableViewController { present(navigationWrapper, animated: true) analyticsServicesManager?.didDisplayBolusScreen() } - - private func createPreMealButtonItem(selected: Bool, isEnabled: Bool) -> UIBarButtonItem { - let item = UIBarButtonItem(image: UIImage.preMealImage(selected: selected), style: .plain, target: self, action: #selector(premealButtonTapped(_:))) - item.accessibilityLabel = NSLocalizedString("Pre-Meal Targets", comment: "The label of the pre-meal mode toggle button") - - if selected { - item.accessibilityTraits.insert(.selected) - item.accessibilityHint = NSLocalizedString("Disables", comment: "The action hint of the workout mode toggle button when enabled") - } else { - item.accessibilityHint = NSLocalizedString("Enables", comment: "The action hint of the workout mode toggle button when disabled") - } - - item.tintColor = UIColor.carbTintColor - item.isEnabled = isEnabled - item.accessibilityIdentifier = isEnabled ? "statusTableViewPreMealButtonEnabled" : "statusTableViewPreMealButtonDisabled" - - return item - } - - private func createWorkoutButtonItem(selected: Bool, isEnabled: Bool) -> UIBarButtonItem { - let item = UIBarButtonItem(image: UIImage.workoutImage(selected: selected), style: .plain, target: self, action: #selector(toggleWorkoutMode(_:))) - item.accessibilityLabel = NSLocalizedString("Workout Targets", comment: "The label of the workout mode toggle button") - - if selected { - item.accessibilityTraits.insert(.selected) - item.accessibilityHint = NSLocalizedString("Disables", comment: "The action hint of the workout mode toggle button when enabled") - } else { - item.accessibilityHint = NSLocalizedString("Enables", comment: "The action hint of the workout mode toggle button when disabled") - } - - item.tintColor = UIColor.glucoseTintColor - item.isEnabled = isEnabled - - return item - } - - @IBAction func premealButtonTapped(_ sender: UIBarButtonItem? = nil) { - togglePreMealMode() - } - - func togglePreMealMode() { - if preMealMode == true { - 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() - } - } - - func presentPreMealModeAlertController() { - let vc = UIAlertController(premealDurationSelectionHandler: { duration in - let startDate = Date() - - guard self.workoutMode != true else { - // allow cell animation when switching between presets - self.temporaryPresetsManager.scheduleOverride = nil - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.temporaryPresetsManager.enablePreMealOverride(at: startDate, for: duration) - } - return - } - self.temporaryPresetsManager.enablePreMealOverride(at: startDate, for: duration) - }) - - present(vc, animated: true, completion: nil) - } - - func presentCustomPresets() { - if workoutMode == true { - 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]) - } else { - presentWorkoutModeAlertController() - } - } - } - - func presentWorkoutModeAlertController() { - let vc = UIAlertController(workoutDurationSelectionHandler: { duration in - let startDate = Date() - - guard self.preMealMode != true else { - // allow cell animation when switching between presets - self.temporaryPresetsManager.clearOverride(matching: .preMeal) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: .finite(duration)) - } - return - } - - self.temporaryPresetsManager.enableLegacyWorkoutOverride(at: startDate, for: .finite(duration)) - }) - - present(vc, animated: true, completion: nil) - } - - @IBAction func toggleWorkoutMode(_ sender: UIBarButtonItem) { - presentCustomPresets() - } private(set) var isShowingPresets: Bool = false @@ -1693,7 +1489,6 @@ 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 diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 2228a289b0..516741dc6d 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -15,7 +15,7 @@ import os.log protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate, FavoriteFoodInsightsViewModelDelegate { var defaultAbsorptionTimes: DefaultAbsorptionTimes { get } - func scheduleOverrideEnabled(at date: Date) -> Bool + func isScheduleOverrideActive(at date: Date) -> Bool func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] } @@ -347,7 +347,7 @@ final class CarbEntryViewModel: ObservableObject { return } - if delegate.scheduleOverrideEnabled(at: Date()), + if delegate.isScheduleOverrideActive(at: Date()), let overrideSettings = delegate.scheduleOverride?.settings, overrideSettings.effectiveInsulinNeedsScaleFactor != 1.0 { diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift index 2d291d62f6..f62dcc8f54 100644 --- a/Loop/View Models/PresetsViewModel.swift +++ b/Loop/View Models/PresetsViewModel.swift @@ -200,12 +200,8 @@ public class PresetsViewModel { private var presetHistory: TemporaryScheduleOverrideHistory - var activeOverride: TemporaryScheduleOverride? { - temporaryPresetsManager.preMealOverride ?? temporaryPresetsManager.scheduleOverride - } - var activePreset: SelectablePreset? { - return allPresets.first(where: { $0.id == activeOverride?.presetId }) + return allPresets.first(where: { $0.id == temporaryPresetsManager.activeOverride?.presetId }) } var allPresets: [SelectablePreset] { @@ -279,9 +275,9 @@ public class PresetsViewModel { func endPreset() { if case .preMeal(_, _) = activePreset { - temporaryPresetsManager.preMealOverride = nil + temporaryPresetsManager.clearOverride(matching: .preMeal) } else { - temporaryPresetsManager.scheduleOverride = nil + temporaryPresetsManager.clearOverride() } } diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 54b0df1412..2238cf6f11 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -28,10 +28,10 @@ struct PresetDetentView: View { init(viewModel: PresetsViewModel, preset: SelectablePreset) { self.viewModel = viewModel self.preset = preset - - self.activeOverride = viewModel.temporaryPresetsManager.preMealOverride ?? viewModel.temporaryPresetsManager.scheduleOverride + self.activeOverride = viewModel.temporaryPresetsManager.activeOverride } + init?(viewModel: PresetsViewModel) { guard let preset = viewModel.pendingPreset else { return nil } self.init(viewModel: viewModel, preset: preset) @@ -77,14 +77,12 @@ struct PresetDetentView: View { switch operation { case .start: Button("Start Preset") { - dismiss() viewModel.startPreset(preset) } .buttonStyle(ActionButtonStyle()) - .disabled(viewModel.activePreset != nil && preset != viewModel.activePreset) + .disabled(viewModel.activePreset != nil && preset.id != viewModel.activePreset?.id) case .end: Button("End Preset") { - dismiss() viewModel.endPreset() } .buttonStyle(ActionButtonStyle(.destructive)) @@ -145,5 +143,8 @@ struct PresetDetentView: View { .padding(16) .presentationHuggingDetent() } + .onChange(of: viewModel.activePreset) { _, _ in + dismiss() + } } } diff --git a/Loop/Views/Presets/PresetsHistoryView.swift b/Loop/Views/Presets/PresetsHistoryView.swift index 53c5f21985..63958e767a 100644 --- a/Loop/Views/Presets/PresetsHistoryView.swift +++ b/Loop/Views/Presets/PresetsHistoryView.swift @@ -26,21 +26,21 @@ struct PresetsHistoryView: View { return formatter }() - var overridesByDate: Dictionary { + var overridesByDate: Dictionary { Dictionary( grouping: history.recentEvents .map(\.override) .filter({ !$0.isActive() }) .sorted(by: { $0.actualEndDate > $1.actualEndDate }) ) { override in - override.startDate.formatted(date: .abbreviated, time: .omitted) + Calendar.current.startOfDay(for: override.startDate) } } var body: some View { List { - ForEach(Array(overridesByDate.keys)) { date in - Section(date) { + ForEach(Array(overridesByDate.keys.sorted(by: >)), id: \.self) { date in + Section(date.formatted(date: .abbreviated, time: .omitted)) { ForEach(overridesByDate[date] ?? [], id: \.self) { override in LabeledContent { VStack(alignment: .trailing, spacing: 8) { diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 4b8ac975bf..be1c68e256 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -47,7 +47,7 @@ struct PresetsView: View { var presetsSorted: [SelectablePreset] { viewModel.allPresets - .filter { $0.id != viewModel.activeOverride?.presetId } + .filter { $0.id != viewModel.temporaryPresetsManager.activeOverride?.presetId } .sorted(by: { switch (viewModel.selectedSortOption) { case .name: @@ -72,7 +72,7 @@ struct PresetsView: View { if let activePreset = viewModel.activePreset { PresetCard( activePreset, - expectedEndTime: viewModel.activeOverride?.expectedEndTime + expectedEndTime: viewModel.temporaryPresetsManager.activeOverride?.expectedEndTime ) .onTapGesture { viewModel.pendingPreset = activePreset @@ -159,7 +159,7 @@ struct PresetsView: View { } .padding() .animation(.default, value: viewModel.hasCompletedTraining) - .animation(.default, value: viewModel.activeOverride) + .animation(.default, value: viewModel.temporaryPresetsManager.activeOverride) } .background(Color(UIColor.secondarySystemBackground)) .navigationTitle(Text("Presets", comment: "Presets screen title")) diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index e2da6fae75..370d9c97d4 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -121,7 +121,9 @@ class StatusTableViewModel { } } - init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, settingsViewModel: SettingsViewModel) { + let legacyPresetsEnabled: Bool + + init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, settingsViewModel: SettingsViewModel, legacyPresetsEnabled: Bool = false) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter self.automaticDosingStatus = automaticDosingStatus @@ -141,6 +143,7 @@ class StatusTableViewModel { self.criticalEventLogExportManager = criticalEventLogExportManager self.bluetoothStateManager = bluetoothStateManager self.settingsViewModel = settingsViewModel + self.legacyPresetsEnabled = legacyPresetsEnabled } } @@ -189,7 +192,7 @@ struct StatusTableView: View { case .addCarbs, .bolus, .settings: // No active states for these actions return false case .presets: - return viewModel.settingsViewModel.presetsViewModel.activeOverride != nil + return viewModel.settingsViewModel.presetsViewModel.activePreset != nil } } @@ -211,7 +214,7 @@ struct StatusTableView: View { } .toolbar { ToolbarItem(placement: .bottomBar) { - HStack(alignment: .bottom) { + HStack(alignment: .bottom, spacing: 0) { ForEach(ToolbarAction.allCases) { action in action.button( showTitle: true, @@ -224,16 +227,17 @@ struct StatusTableView: View { case .bolus: viewController.presentBolusScreen() case .presets: - viewController.presentPresets() + if viewModel.legacyPresetsEnabled { + viewController.presentLegacyPresets() + } else { + viewController.presentPresets() + } case .settings: viewController.presentSettings() } } - .frame(maxWidth: .infinity) } } - .padding(.horizontal, 16) - .padding(.bottom, -8) } } } @@ -286,8 +290,8 @@ enum ToolbarAction: String, Identifiable, CaseIterable { .foregroundStyle(Color(UIColor.secondaryLabel)) } } - .frame(width: 32, height: 32) .aspectRatio(contentMode: .fit) + .frame(width: showCompactToolbar ? 24 : 32, height: showCompactToolbar ? 24 : 32) } @ViewBuilder @@ -304,26 +308,47 @@ enum ToolbarAction: String, Identifiable, CaseIterable { Text("Settings", comment: "The label of the settings button") } } + .frame(maxWidth: .infinity) .foregroundStyle(.secondary) .font(.footnote) } @ViewBuilder - func button(showTitle: Bool, isActive: Bool, disabled: Bool, action: @escaping () -> Void) -> some View { + func button( + showTitle: Bool, + isActive: Bool, + disabled: Bool, + action: @escaping () -> Void + ) -> some View { Button(action: action) { - VStack(spacing: 4) { + VStack(spacing: showCompactToolbar ? 2 : 4) { icon(isActive: isActive) if showTitle { title } } - .animation(.default, value: isActive) - .padding(.vertical) + .padding(.bottom, showCompactToolbar ? 0 : -12) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .animation(.default, value: isActive) .disabled(disabled) - .contentShape(Rectangle()) .accessibilityIdentifier(accessibilityIdentifier) } } + +private var showCompactToolbar: Bool { + let window = UIApplication + .shared + .connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + + guard let safeAreaBottom = window?.safeAreaInsets.bottom else { + return true + } + + return safeAreaBottom <= 0 +} diff --git a/WatchApp/DerivedAssets.xcassets/Contents.json b/WatchApp/DerivedAssets.xcassets/Contents.json index da4a164c91..73c00596a7 100644 --- a/WatchApp/DerivedAssets.xcassets/Contents.json +++ b/WatchApp/DerivedAssets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} From 47c8d418dd65cf3a82192e0c9f9c94bb324b5513 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 20 Dec 2024 14:03:36 -0800 Subject: [PATCH 196/421] [LOOP-5056] Presets Homepage Bugs (#738) --- Loop/Managers/TemporaryPresetsManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index f1dee4f5b6..d496ebe112 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -201,7 +201,7 @@ class TemporaryPresetsManager { } public func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { - scheduleOverride = makePreMealOverride(beginningAt: date, for: duration) + preMealOverride = makePreMealOverride(beginningAt: date, for: duration) } private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { From f2f82b2dc92f795689f2abf865b23689b6b7b552 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 20 Dec 2024 15:14:10 -0800 Subject: [PATCH 197/421] [LOOP-5056] Presets Homepage Bugs (#738) (#739) --- Loop/Managers/TemporaryPresetsManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index d496ebe112..352bdf925b 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -160,7 +160,7 @@ class TemporaryPresetsManager { return nil } - let preMealOverride = presumingMealEntry ? nil : (self.scheduleOverride?.context == .preMeal ? self.scheduleOverride : nil) + let preMealOverride = presumingMealEntry ? nil : self.preMealOverride let currentEffectiveOverride: TemporaryScheduleOverride? switch (preMealOverride, scheduleOverride) { From 39e7d86eeab522718c5306ea06cd5854fadbf8df Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 8 Jan 2025 15:49:03 -0400 Subject: [PATCH 198/421] [LOOP-5188] optional VC (#741) --- Loop/View Controllers/RootNavigationController.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Loop/View Controllers/RootNavigationController.swift b/Loop/View Controllers/RootNavigationController.swift index f4656aa3f8..b003162776 100644 --- a/Loop/View Controllers/RootNavigationController.swift +++ b/Loop/View Controllers/RootNavigationController.swift @@ -13,20 +13,20 @@ import LoopKitUI class RootNavigationController: UINavigationController { /// Its root view controller is always StatusTableViewController after loading - var statusTableViewController: StatusTableViewController! { + var statusTableViewController: StatusTableViewController? { return viewControllers.first as? StatusTableViewController } func navigate(to deeplink: Deeplink) { switch deeplink { case .carbEntry: - statusTableViewController.presentCarbEntryScreen(nil) + statusTableViewController?.presentCarbEntryScreen(nil) case .preMeal: - statusTableViewController.presentPresets() + statusTableViewController?.presentPresets() case .bolus: - statusTableViewController.presentBolusScreen() + statusTableViewController?.presentBolusScreen() case .customPresets: - statusTableViewController.presentPresets() + statusTableViewController?.presentPresets() } } @@ -41,7 +41,7 @@ class RootNavigationController: UINavigationController { popToRootViewController(animated: false) } default: - statusTableViewController.restoreUserActivityState(activity) + statusTableViewController?.restoreUserActivityState(activity) } } From c963f83aede4b7727fff8f0b5979b5e1450cdfd2 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 8 Jan 2025 11:56:42 -0800 Subject: [PATCH 199/421] [LOOP-5055] Presets Training QA Feedback (#740) --- Loop.xcodeproj/project.pbxproj | 8 +-- Loop/Extensions/Image+Exists.swift | 21 -------- Loop/Extensions/Image+Optional.swift | 20 ++++++++ Loop/View Models/PresetsViewModel.swift | 50 +++++++++++++++++-- .../Presets/Components/PresetDetentView.swift | 4 +- .../PresetsTrainingContentContainerView.swift | 2 +- Loop/Views/Presets/PresetsView.swift | 5 +- .../CreatingYourOwnPresetsContentView.swift | 8 +-- .../HowTheyWorkContentView.swift | 8 +-- .../PresetsAndIllnessContentView.swift | 3 ++ 10 files changed, 86 insertions(+), 43 deletions(-) delete mode 100644 Loop/Extensions/Image+Exists.swift create mode 100644 Loop/Extensions/Image+Optional.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 64152d89d5..d0bfc0bbea 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -244,6 +244,7 @@ 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 */; }; + 84A7B5502D2D972C00B6D202 /* Image+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */; }; 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */; }; 84AA81D82A4A2910000B658B /* StatusWidgetTimelimeEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */; }; @@ -269,7 +270,6 @@ 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC92CCA16290078E6CF /* PresetsView.swift */; }; 84E8BBCC2CCA16B30078E6CF /* PresetsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCB2CCA16B30078E6CF /* PresetsViewModel.swift */; }; 84E8BBCE2CCA1E070078E6CF /* PresetsTrainingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */; }; - 84E8BBD02CCA279B0078E6CF /* Image+Exists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */; }; 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */; }; 84F20DFD2D0B9C3A0089DF02 /* EditOverrideDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFC2D0B9C3A0089DF02 /* EditOverrideDurationView.swift */; }; @@ -1125,6 +1125,7 @@ 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 = ""; }; + 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Optional.swift"; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; 84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelimeEntry.swift; sourceTree = ""; }; @@ -1150,7 +1151,6 @@ 84E8BBC92CCA16290078E6CF /* PresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsView.swift; sourceTree = ""; }; 84E8BBCB2CCA16B30078E6CF /* PresetsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsViewModel.swift; sourceTree = ""; }; 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingCard.swift; sourceTree = ""; }; - 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Exists.swift"; sourceTree = ""; }; 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetStatsView.swift; sourceTree = ""; }; 84F20DFC2D0B9C3A0089DF02 /* EditOverrideDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditOverrideDurationView.swift; sourceTree = ""; }; @@ -2124,7 +2124,6 @@ 43E344A01B9E144300C85C07 /* Extensions */ = { isa = PBXGroup; children = ( - 84E8BBCF2CCA27960078E6CF /* Image+Exists.swift */, A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */, C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */, C1F2CAAB2B7A980600D7F581 /* BasalRelativeDose.swift */, @@ -2165,6 +2164,7 @@ C13DA2AF24F6C7690098BB29 /* UIViewController.swift */, 430B29922041F5B200BA9F93 /* UserDefaults+Loop.swift */, A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */, + 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */, ); path = Extensions; sourceTree = ""; @@ -3593,7 +3593,6 @@ E98A55EF24EDD6E60008715D /* DosingDecisionStoreProtocol.swift in Sources */, B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */, E9C00EF524C623EF00628F35 /* LoopSettings+Loop.swift in Sources */, - 84E8BBD02CCA279B0078E6CF /* Image+Exists.swift in Sources */, 4389916B1E91B689000EEF90 /* ChartSettings+Loop.swift in Sources */, C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, C1F2CAAC2B7A980600D7F581 /* BasalRelativeDose.swift in Sources */, @@ -3702,6 +3701,7 @@ 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, + 84A7B5502D2D972C00B6D202 /* Image+Optional.swift in Sources */, 8968B1122408B3520074BB48 /* UIFont.swift in Sources */, 84E8BBC42CC9B9890078E6CF /* AdjustedGlucoseRangeView.swift in Sources */, 84C170EF2CCA37680098E52F /* PresetCard.swift in Sources */, diff --git a/Loop/Extensions/Image+Exists.swift b/Loop/Extensions/Image+Exists.swift deleted file mode 100644 index 1ce5478b39..0000000000 --- a/Loop/Extensions/Image+Exists.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Image+Exists.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import UIKit - -// Since this `Image` initializer provides the parent view with an `EmptyView` if the asset is not found in the bundle, it can double the spacing between adjacent elements in a `VStack`, `HStack`, etc. -extension Image { - static func imageExists( - _ name: String, - in bundle: Bundle? = nil, - with configuration: UIImage.Configuration? = nil - ) -> Bool { - UIImage(named: name, in: bundle, with: configuration) != nil - } -} diff --git a/Loop/Extensions/Image+Optional.swift b/Loop/Extensions/Image+Optional.swift new file mode 100644 index 0000000000..775196265e --- /dev/null +++ b/Loop/Extensions/Image+Optional.swift @@ -0,0 +1,20 @@ +// +// Image+Optional.swift +// Loop +// +// Created by Cameron Ingham on 1/7/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +// Since this `Image` initializer provides a view even if the asset is not found in the bundle, it can double the spacing between adjacent elements in a `VStack`, `HStack`, etc. +extension Image { + init?(_ name: String, bundle: Bundle? = nil) { + if let _ = UIImage(named: name, in: bundle, with: nil) { + self = Image(name, bundle: bundle) + } else { + return nil + } + } +} diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift index f62dcc8f54..5d1f281c2f 100644 --- a/Loop/View Models/PresetsViewModel.swift +++ b/Loop/View Models/PresetsViewModel.swift @@ -184,11 +184,53 @@ enum SelectablePreset: Hashable, Identifiable { public class PresetsViewModel { // MARK: Training - @ObservationIgnored @AppStorage("hasCompletedPresetsTraining") var hasCompletedTraining: Bool = false - @ObservationIgnored @AppStorage("presetsSortOrder") var selectedSortOption: PresetSortOption = .name - @ObservationIgnored @AppStorage("presetsSortDirectionReversed") var presetsSortAscending: Bool = true + + // This double property is needed to allow AppStorage to be observed + @ObservationIgnored @AppStorage("hasCompletedPresetsTraining") private var _hasCompletedTraining: Bool = false + @ObservationIgnored + var hasCompletedTraining: Bool { + get { + access(keyPath: \.hasCompletedTraining) + return _hasCompletedTraining + } + set { + withMutation(keyPath: \.hasCompletedTraining) { + _hasCompletedTraining = newValue + } + } + } + + // This double property is needed to allow AppStorage to be observed + @ObservationIgnored @AppStorage("presetsSortOrder") private var _selectedSortOption: PresetSortOption = .name + @ObservationIgnored + var selectedSortOption: PresetSortOption { + get { + access(keyPath: \.selectedSortOption) + return _selectedSortOption + } + set { + withMutation(keyPath: \.selectedSortOption) { + _selectedSortOption = newValue + } + } + } + + // This double property is needed to allow AppStorage to be observed + @ObservationIgnored @AppStorage("presetsSortDirectionReversed") private var _presetsSortAscending: Bool = true + @ObservationIgnored + var presetsSortAscending: Bool { + get { + access(keyPath: \.selectedSortOption) + return _presetsSortAscending + } + set { + withMutation(keyPath: \.selectedSortOption) { + _presetsSortAscending = newValue + } + } + } - @ObservationIgnored var correctionRangeOverrides: CorrectionRangeOverrides? + var correctionRangeOverrides: CorrectionRangeOverrides? let temporaryPresetsManager: TemporaryPresetsManager diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 2238cf6f11..a954aaff90 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -84,6 +84,7 @@ struct PresetDetentView: View { case .end: Button("End Preset") { viewModel.endPreset() + dismiss() } .buttonStyle(ActionButtonStyle(.destructive)) @@ -143,8 +144,5 @@ struct PresetDetentView: View { .padding(16) .presentationHuggingDetent() } - .onChange(of: viewModel.activePreset) { _, _ in - dismiss() - } } } diff --git a/Loop/Views/Presets/Components/PresetsTrainingContentContainerView.swift b/Loop/Views/Presets/Components/PresetsTrainingContentContainerView.swift index 2504248be9..aae40ee05a 100644 --- a/Loop/Views/Presets/Components/PresetsTrainingContentContainerView.swift +++ b/Loop/Views/Presets/Components/PresetsTrainingContentContainerView.swift @@ -55,7 +55,6 @@ struct PresetsTrainingContentContainerView: View { } message: { Text("Ending now will require you to restart training before creating new presets.\n\nDo you want to end training?", comment: "End presets training alert message") } - } private func content(withSpacer: Bool = false) -> some View { @@ -101,5 +100,6 @@ struct PresetsTrainingContentContainerView: View { } } .padding(.horizontal, 16) + .padding(.bottom) } } diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index be1c68e256..0c65562af5 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -64,7 +64,6 @@ struct PresetsView: View { NavigationView { ScrollView { VStack(spacing: 20) { - if !viewModel.hasCompletedTraining { PresetsTrainingCard(showTraining: $showTraining) } @@ -95,7 +94,9 @@ struct PresetsView: View { Button(action: {}) { Image(systemName: "plus") - }.disabled(!viewModel.hasCompletedTraining) + } +// .disabled(!viewModel.hasCompletedTraining) + .disabled(true) // [LOOP-5055] Revert this after phase 1 of presets 2.0. https://tidepool.atlassian.net/browse/LOOP-5055?focusedCommentId=61276 } LazyVStack(spacing: 12) { diff --git a/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift b/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift index d3f8f345fe..84c104d230 100644 --- a/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift +++ b/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift @@ -36,8 +36,8 @@ struct CreatingYourOwnPresetsContentView: View { Text("You can manage all presets by tapping the Presets button on the toolbar.", comment: "Creating your own presets training content, managing presets, paragraph 1") - if Image.imageExists("PresetsTraining1") { - Image("PresetsTraining1") + if let image = Image("PresetsTraining1") { + image .resizable() .aspectRatio(contentMode: .fill) .accessibilityHidden(true) @@ -53,8 +53,8 @@ struct CreatingYourOwnPresetsContentView: View { Text("(if applicable) the glucose chart will show your adjusted correction range", comment: "Creating your own presets training content, managing presets, paragraph 2, bullet 3") } - if Image.imageExists("PresetsTraining2") { - Image("PresetsTraining2") + if let image = Image("PresetsTraining2") { + image .resizable() .aspectRatio(contentMode: .fill) .accessibilityHidden(true) diff --git a/Loop/Views/Presets/Training Content/HowTheyWorkContentView.swift b/Loop/Views/Presets/Training Content/HowTheyWorkContentView.swift index dfa5a965c0..3fd3247255 100644 --- a/Loop/Views/Presets/Training Content/HowTheyWorkContentView.swift +++ b/Loop/Views/Presets/Training Content/HowTheyWorkContentView.swift @@ -47,8 +47,8 @@ struct HowTheyWorkContentView: View { Text("Adjusting Overall Insulin", comment: "How presets work training content, adjusting overall insulin, subtitle 1") .font(.title2.bold()) - if Image.imageExists("PresetsTraining3") { - Image("PresetsTraining3") + if let image = Image("PresetsTraining3") { + image .resizable() .aspectRatio(contentMode: .fill) .accessibilityHidden(true) @@ -93,8 +93,8 @@ struct HowTheyWorkContentView: View { Text("Adjusting the Correction Range", comment: "How presets work training content, adjusting correction range, subtitle 1") .font(.title2.bold()) - if Image.imageExists("PresetsTraining4") { - Image("PresetsTraining4") + if let image = Image("PresetsTraining4") { + image .resizable() .aspectRatio(contentMode: .fill) .accessibilityHidden(true) diff --git a/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift b/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift index 7dbf13ffa3..d51c474e14 100644 --- a/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift +++ b/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift @@ -46,9 +46,11 @@ struct PresetsAndIllnessContentView: View { @ViewBuilder var stepOneView: some View { Text("Physical stressors can cause your glucose to rise and sickness is a common example. Your healthcare provider can help you make a personal plan for sickness. The following is one example of using presets to manage an illness.", comment: "Presets and illness training content, paragraph 1") + .fixedSize(horizontal: false, vertical: true) .bold() Text("Let’s imagine Paloma Porpoise notices her glucose is higher than usual and wants to create a preset to help keep her glucose in range while she is sick.", comment: "Presets and illness training content, paragraph 2") + .fixedSize(horizontal: false, vertical: true) TherapySettingsExampleView( title: NSLocalizedString("Paloma’s Therapy Settings", comment: "Presets and illness training content, therapy settings example, title"), @@ -115,6 +117,7 @@ struct PresetsAndIllnessContentView: View { .font(.title2.bold()) Text("Let’s imagine Paloma decides to eat a meal of 31g carbs. How will her preset impact her bolus recommendation?", comment: "Presets and illness training content, impact on bolusing, paragraph 1") + .fixedSize(horizontal: false, vertical: true) } Text("While a preset is ON, the modified basal rates, carb ratio and insulin sensitivity factor (ISF) are applied for every bolus.", comment: "Presets and illness training content, impact on bolusing, paragraph 2") From f1f01ed6a9a77efc89e0b74737b28815d0e7a1c8 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 10 Jan 2025 09:27:24 -0400 Subject: [PATCH 200/421] reduce to single SettingsViewModel reference (#742) --- .../View Controllers/StatusTableViewController.swift | 12 ++++++------ Loop/View Models/SettingsViewModel.swift | 2 +- Loop/Views/StatusTableView.swift | 6 +----- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 4feaeb8388..63be0c9424 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -69,7 +69,6 @@ final class StatusTableViewController: LoopChartsTableViewController { var criticalEventLogExportManager: CriticalEventLogExportManager! - var settingsViewModel: SettingsViewModel! var statusTableViewModel: StatusTableViewModel! lazy private var cancellables = Set() @@ -241,8 +240,9 @@ final class StatusTableViewController: LoopChartsTableViewController { onboardingManager.$isComplete .merge(with: onboardingManager.$isSuspended) - .sink { [weak self] _ in + .sink { [weak self] isComplete in Task { @MainActor in + self?.statusTableViewModel.settingsViewModel.isOnboardingComplete = isComplete self?.refreshContext.update(with: .status) await self?.reloadData(animated: true) } @@ -932,7 +932,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } if override.isActive() { - if let preset = settingsViewModel.presetsViewModel.allPresets.first(where: { $0.id == override.presetId }), case .preMeal(_, _) = preset { + if let preset = statusTableViewModel.settingsViewModel.presetsViewModel.allPresets.first(where: { $0.id == override.presetId }), case .preMeal(_, _) = preset { cell.subtitleLabel.text = NSLocalizedString("on until carbs added", comment: "The format for the description of a premeal preset end date") } else { switch override.duration { @@ -1188,7 +1188,7 @@ final class StatusTableViewController: LoopChartsTableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch Section(rawValue: indexPath.section)! { case .presets: - statusTableViewModel.pendingPreset = settingsViewModel.presetsViewModel.activePreset + statusTableViewModel.pendingPreset = statusTableViewModel.settingsViewModel.presetsViewModel.activePreset case .alertWarning: if alertPermissionsChecker.showWarning { tableView.deselectRow(at: indexPath, animated: true) @@ -1438,7 +1438,7 @@ final class StatusTableViewController: LoopChartsTableViewController { func presentPresets() { let hostingController = DismissibleHostingController( - rootView: PresetsView(viewModel: settingsViewModel.presetsViewModel) + rootView: PresetsView(viewModel: statusTableViewModel.settingsViewModel.presetsViewModel) .onAppear { self.isShowingPresets = true } .onDisappear { self.isShowingPresets = false } .environmentObject(deviceManager.displayGlucosePreference) @@ -1455,7 +1455,7 @@ final class StatusTableViewController: LoopChartsTableViewController { func presentSettings() { let hostingController = DismissibleHostingController( - rootView: SettingsView(viewModel: settingsViewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) + rootView: SettingsView(viewModel: statusTableViewModel.settingsViewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucosePreference) .environment(\.appName, Bundle.main.bundleDisplayName) .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 0521909082..d579aff06c 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -78,7 +78,7 @@ class SettingsViewModel { let criticalEventLogExportViewModel: CriticalEventLogExportViewModel let therapySettings: () -> TherapySettings let sensitivityOverridesEnabled: Bool - let isOnboardingComplete: Bool + var isOnboardingComplete: Bool let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? let presetHistory: TemporaryScheduleOverrideHistory diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 370d9c97d4..52684b9df1 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -32,12 +32,11 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { private let doseStore: DoseStore private let criticalEventLogExportManager: CriticalEventLogExportManager private let bluetoothStateManager: BluetoothStateManager - private let settingsViewModel: SettingsViewModel private let statusTableViewModel: StatusTableViewModel let viewController: StatusTableViewController - init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, settingsViewModel: SettingsViewModel, statusTableViewModel: StatusTableViewModel) { + init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, statusTableViewModel: StatusTableViewModel) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter self.automaticDosingStatus = automaticDosingStatus @@ -56,7 +55,6 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { self.doseStore = doseStore self.criticalEventLogExportManager = criticalEventLogExportManager self.bluetoothStateManager = bluetoothStateManager - self.settingsViewModel = settingsViewModel self.statusTableViewModel = statusTableViewModel let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: StatusTableViewController.self)) @@ -78,7 +76,6 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { statusTableViewController.carbStore = carbStore statusTableViewController.doseStore = doseStore statusTableViewController.criticalEventLogExportManager = criticalEventLogExportManager - statusTableViewController.settingsViewModel = settingsViewModel statusTableViewController.statusTableViewModel = statusTableViewModel bluetoothStateManager.addBluetoothObserver(statusTableViewController) @@ -182,7 +179,6 @@ struct StatusTableView: View { doseStore: viewModel.doseStore, criticalEventLogExportManager: viewModel.criticalEventLogExportManager, bluetoothStateManager: viewModel.bluetoothStateManager, - settingsViewModel: viewModel.settingsViewModel, statusTableViewModel: viewModel ) } From 44ec303e6d6864ee090e620c0e301cd87542ad63 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 14 Jan 2025 12:14:27 -0400 Subject: [PATCH 201/421] [LOOP-5184] fixing saving manual glucose entry (#743) --- Loop/View Models/SimpleBolusViewModel.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift index 45593378d3..ce5512ed66 100644 --- a/Loop/View Models/SimpleBolusViewModel.swift +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -368,7 +368,8 @@ class SimpleBolusViewModel: ObservableObject { wasUserEntered: true, syncIdentifier: UUID().uuidString) do { - self.dosingDecision?.manualGlucoseSample = try await delegate.saveGlucose(sample: manualGlucoseSample) + let storedManualGlucoseSample = try await delegate.saveGlucose(sample: manualGlucoseSample) + self.dosingDecision?.manualGlucoseSample = storedManualGlucoseSample } catch { self.presentAlert(.manualGlucoseEntryPersistenceFailure) self.log.error("Failed to add manual glucose entry: %{public}@", String(describing: error)) From 3d070708f7b3bde187eee8443ac9bcb07277ab74 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 14 Jan 2025 09:44:43 -0800 Subject: [PATCH 202/421] [LOOP-5069] Presets Bugs (#744) --- Loop.xcodeproj/project.pbxproj | 6 +++--- Loop/Managers/TemporaryPresetsManager.swift | 8 ++++++++ .../Presets/Components/EditOverrideDurationView.swift | 5 ++--- Loop/Views/Presets/Components/PresetDetentView.swift | 6 +++++- Loop/Views/Presets/PresetsView.swift | 3 +-- Loop/Views/StatusTableView.swift | 9 ++++++--- 6 files changed, 25 insertions(+), 12 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index d0bfc0bbea..8b687c6893 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -4752,7 +4752,7 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, CFLocalizedString, @@ -4862,7 +4862,7 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, CFLocalizedString, @@ -5298,7 +5298,7 @@ GCC_WARN_UNUSED_LABEL = YES; GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.6; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZED_STRING_MACRO_NAMES = ( NSLocalizedString, CFLocalizedString, diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 352bdf925b..b914f0e2b5 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -123,6 +123,14 @@ class TemporaryPresetsManager { } overrideHistory.recordOverride(preMealOverride) + + if let newPreset = preMealOverride { + for observer in self.presetActivationObservers { + observer.presetActivated(context: newPreset.context, duration: newPreset.duration) + } + + scheduleClearOverride(override: newPreset) + } notify(forChange: .preferences) } diff --git a/Loop/Views/Presets/Components/EditOverrideDurationView.swift b/Loop/Views/Presets/Components/EditOverrideDurationView.swift index 52011b3bfe..e763519495 100644 --- a/Loop/Views/Presets/Components/EditOverrideDurationView.swift +++ b/Loop/Views/Presets/Components/EditOverrideDurationView.swift @@ -61,7 +61,7 @@ struct EditOverrideDurationView: View { DatePicker("On until", selection: $dateSelection, displayedComponents: .hourAndMinute) .padding(6) .padding(.leading, 10) - .background(Color.white.cornerRadius(10)) + .background(Color(UIColor.systemBackground).cornerRadius(10)) Spacer() } @@ -73,8 +73,7 @@ struct EditOverrideDurationView: View { } .buttonStyle(ActionButtonStyle()) .padding([.top, .horizontal]) - .background(Color.white) - .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: -4) + .background(Color(UIColor.secondarySystemBackground)) .disabled(buttonDisabled) } } diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index a954aaff90..2d7d9d1323 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -78,6 +78,7 @@ struct PresetDetentView: View { case .start: Button("Start Preset") { viewModel.startPreset(preset) + dismiss() } .buttonStyle(ActionButtonStyle()) .disabled(viewModel.activePreset != nil && preset.id != viewModel.activePreset?.id) @@ -106,6 +107,8 @@ struct PresetDetentView: View { } } + @State var sheetContentHeight: Double = 0 + var body: some View { NavigationStack { VStack(spacing: 24) { @@ -142,7 +145,8 @@ struct PresetDetentView: View { .toolbar(.hidden) .padding(.top) .padding(16) - .presentationHuggingDetent() + .readContentHeight(to: $sheetContentHeight) } + .sheetDetent(height: sheetContentHeight) } } diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 0c65562af5..e93f7e4d40 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -95,8 +95,7 @@ struct PresetsView: View { Button(action: {}) { Image(systemName: "plus") } -// .disabled(!viewModel.hasCompletedTraining) - .disabled(true) // [LOOP-5055] Revert this after phase 1 of presets 2.0. https://tidepool.atlassian.net/browse/LOOP-5055?focusedCommentId=61276 + .disabled(!viewModel.hasCompletedTraining) } LazyVStack(spacing: 12) { diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 52684b9df1..066a3e74af 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -194,15 +194,18 @@ struct StatusTableView: View { func isDisabled(action: ToolbarAction) -> Bool { switch action { - case .addCarbs, .bolus, .settings: + case .addCarbs, .bolus, .settings, .presets: false - case .presets: - !viewModel.onboardingManager.isComplete } } var body: some View { wrappedView + .onChange(of: viewModel.settingsViewModel.presetsViewModel.activePreset) { _, _ in + Task { + await viewController.reloadData(animated: true) + } + } .sheet(item: $viewModel.pendingPreset) { _ in PresetDetentView( viewModel: viewModel.settingsViewModel.presetsViewModel From 1c0bfcc641ca0e11bccc75684ae418238db25017 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 15 Jan 2025 13:06:42 -0400 Subject: [PATCH 203/421] [LOOP-5218] correctly assign isOnboardingCompleted (#745) --- Loop/View Controllers/StatusTableViewController.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 63be0c9424..6eae63ec47 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -240,11 +240,12 @@ final class StatusTableViewController: LoopChartsTableViewController { onboardingManager.$isComplete .merge(with: onboardingManager.$isSuspended) - .sink { [weak self] isComplete in + .sink { [weak self] _ in + guard let self else { return } Task { @MainActor in - self?.statusTableViewModel.settingsViewModel.isOnboardingComplete = isComplete - self?.refreshContext.update(with: .status) - await self?.reloadData(animated: true) + self.statusTableViewModel.settingsViewModel.isOnboardingComplete = self.onboardingManager.isComplete + self.refreshContext.update(with: .status) + await self.reloadData(animated: true) } } .store(in: &cancellables) From 206018bfbbbea320406f2020fae2d990d55cae7c Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 16 Jan 2025 13:49:17 -0400 Subject: [PATCH 204/421] [LOOP-5195] explicitly set font and color (#746) --- Loop/View Controllers/StatusTableViewController.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 6eae63ec47..d5af53f383 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1052,7 +1052,11 @@ final class StatusTableViewController: LoopChartsTableViewController { cell.selectionStyle = .none cell.backgroundColor = .secondarySystemBackground cell.titleLabel.text = nil + cell.titleLabel.textColor = .label + cell.titleLabel.font = .systemFont(ofSize: 15, weight: .bold) cell.subtitleLabel.text = nil + cell.subtitleLabel.textColor = .secondaryLabel + cell.subtitleLabel.font = .systemFont(ofSize: 15, weight: .bold) cell.accessoryView = nil return cell } @@ -1107,7 +1111,11 @@ final class StatusTableViewController: LoopChartsTableViewController { cell.iconImageView.contentMode = .scaleAspectFit cell.iconImageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 28) cell.titleLabel.text = NSLocalizedString("Setup Incomplete", comment: "The title of the cell indicating that onboarding is suspended") + cell.titleLabel.textColor = .label + cell.titleLabel.font = .systemFont(ofSize: 15, weight: .bold) cell.subtitleLabel.text = NSLocalizedString("Tap to Resume", comment: "The subtitle of the cell displaying an action to resume onboarding") + cell.subtitleLabel.textColor = .secondaryLabel + cell.subtitleLabel.font = .systemFont(ofSize: 15, weight: .bold) cell.accessoryView = nil return cell case .recommendManualGlucoseEntry: From 61daa83165f21ed9369dfd2b3a2671d0cb3f5626 Mon Sep 17 00:00:00 2001 From: Petr Zywczok Date: Wed, 8 Jan 2025 10:30:47 +0100 Subject: [PATCH 205/421] [QAE-446] Add identifiers for new tests --- Loop/Views/BolusEntryView.swift | 1 + Loop/Views/Presets/Components/PresetDetentView.swift | 4 ++++ Loop/Views/SettingsView.swift | 11 ++++++----- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 7aa7bc0f9f..980521b84f 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -371,6 +371,7 @@ struct BolusEntryView: View { .buttonStyle(ActionButtonStyle(viewModel.primaryButton == .actionButton ? .primary : .secondary)) .disabled(viewModel.enacting) .padding() + .accessibilityIdentifier("button_bolusAction") } private func alert(for alert: BolusEntryViewModel.Alert) -> SwiftUI.Alert { diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 2d7d9d1323..e263f581aa 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -82,12 +82,14 @@ struct PresetDetentView: View { } .buttonStyle(ActionButtonStyle()) .disabled(viewModel.activePreset != nil && preset.id != viewModel.activePreset?.id) + .accessibilityIdentifier("button_startPreset") case .end: Button("End Preset") { viewModel.endPreset() dismiss() } .buttonStyle(ActionButtonStyle(.destructive)) + .accessibilityIdentifier("button_endPreset") if preset.duration != .untilCarbsEntered { NavigationLink("Adjust Preset Duration") { @@ -96,6 +98,7 @@ struct PresetDetentView: View { } } .buttonStyle(ActionButtonStyle(.tertiary)) + .accessibilityIdentifier("button_adjustPresetDuration") } } @@ -104,6 +107,7 @@ struct PresetDetentView: View { } .tint(.accentColor) .fontWeight(.semibold) + .accessibilityIdentifier("button_close") } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index c2d9e4a407..40e38d824c 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -337,15 +337,16 @@ extension SettingsView { private var therapySection: some View { Section { LargeButton(action: { sheet = .therapySettings }, - includeArrow: true, - imageView: Image("Therapy Icon"), - label: NSLocalizedString("Therapy Settings", comment: "Title text for button to Therapy Settings"), - descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + includeArrow: true, + imageView: Image("Therapy Icon"), + label: NSLocalizedString("Therapy Settings", comment: "Title text for button to Therapy Settings"), + descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + .accessibilityIdentifier("button_TherapySettings") ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in item.view } - + if FeatureFlags.allowAlgorithmExperiments { algorithmExperimentsSection } From 61b3f3dc001fdfeb320d3197d99c54e0da5753d8 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 22 Jan 2025 15:02:45 -0400 Subject: [PATCH 206/421] [COASTAL-1449-LOOP-5216] if delivery states are not changed, still update UI (#749) * if delivery states are not changed, still update UI * set refresh context before reloading --- .../StatusTableViewController.swift | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index d5af53f383..b275fc2241 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1924,8 +1924,19 @@ extension StatusTableViewController: CompletionDelegate { extension StatusTableViewController: PumpManagerStatusObserver { func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { log.default("PumpManager:%{public}@ did update status", String(describing: type(of: pumpManager))) - basalDeliveryState = status.basalDeliveryState - bolusState = status.bolusState + + if basalDeliveryState == status.basalDeliveryState, + bolusState == status.bolusState + { + // if the basal and bolus states have not changed, still update UI + Task { @MainActor in + refreshContext.update(with: .status) + await self.reloadData(animated: true) + } + } else { + basalDeliveryState = status.basalDeliveryState + bolusState = status.bolusState + } } } @@ -1946,7 +1957,10 @@ extension StatusTableViewController: DoseProgressObserver { self.bolusProgressReporter = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { self.bolusState = .noBolus - Task { await self.reloadData(animated: true) } + Task { + self.refreshContext.update(with: .insulin) + await self.reloadData(animated: true) + } }) } } From 349ae49b6b4f3440896a75cb0dce03ddcf738020 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 22 Jan 2025 15:32:13 -0400 Subject: [PATCH 207/421] [COASTAL-900] correcting inset for margin (#750) --- Loop/Views/CarbEntryTableViewCell.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Loop/Views/CarbEntryTableViewCell.swift b/Loop/Views/CarbEntryTableViewCell.swift index 1d332808c9..b6f26dfff2 100644 --- a/Loop/Views/CarbEntryTableViewCell.swift +++ b/Loop/Views/CarbEntryTableViewCell.swift @@ -91,13 +91,6 @@ class CarbEntryTableViewCell: UITableViewCell { } } - override func layoutSubviews() { - super.layoutSubviews() - - contentView.layoutMargins.left = separatorInset.left - contentView.layoutMargins.right = separatorInset.left - } - override func awakeFromNib() { super.awakeFromNib() From 26e3f1d284f23c03cb032e0ea1159467e6bdfb29 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 23 Jan 2025 13:36:54 -0800 Subject: [PATCH 208/421] [LOOP-5161] Remove Pre-Meal Correction Range from Meal Bolus Recommendation (#748) --- Loop/Managers/LoopDataManager.swift | 15 +++++++-------- Loop/Managers/TemporaryPresetsManager.swift | 5 +++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 278ba9fb19..71850b16de 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -397,13 +397,12 @@ final class LoopDataManager: ObservableObject { } var overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: neededSensitivityTimeline.start, endDate: forecastEndTime) - - // Bug (https://tidepool.atlassian.net/browse/LOOP-4759) pre-meal is not recorded in override history - // So currently we handle automatic forecast by manually adding it in, and when meal bolusing, we do not do this. - // Eventually, when pre-meal is stored in override history, during meal bolusing we should scan for it and adjust the end time - if !disablingPreMeal, let preMeal = temporaryPresetsManager.preMealOverride { - overrides.append(preMeal) - overrides.sort { $0.startDate < $1.startDate } + + if disablingPreMeal, + let activeOverride = temporaryPresetsManager.activeOverride, + activeOverride.context == .preMeal, + let index = overrides.lastIndex(of: activeOverride) { + overrides[index].scheduledEndDate = baseTime } guard !sensitivity.isEmpty else { @@ -797,7 +796,7 @@ extension LoopDataManager { } else { storedCarbEntry = try await carbStore.addCarbEntry(carbEntry) } - self.temporaryPresetsManager.clearOverride(matching: .preMeal) + self.temporaryPresetsManager.endPreMealOverride() return storedCarbEntry } diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index b914f0e2b5..1b98f9fcaa 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -245,6 +245,11 @@ class TemporaryPresetsManager { syncIdentifier: UUID() ) } + + public func endPreMealOverride() { + preMealOverride?.scheduledEndDate = .now + clearOverride(matching: .preMeal) + } public func clearOverride(matching context: TemporaryScheduleOverride.Context? = nil) { if context == .preMeal { From a36443732772f2629363c6e491affd0a6a4eca43 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 29 Jan 2025 09:55:40 -0800 Subject: [PATCH 209/421] [LOOP-5069] Fix Adjust Preset Duration Value When Already Adjusted (#751) --- Loop/Views/Presets/Components/EditOverrideDurationView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/Presets/Components/EditOverrideDurationView.swift b/Loop/Views/Presets/Components/EditOverrideDurationView.swift index e763519495..b5f6314c42 100644 --- a/Loop/Views/Presets/Components/EditOverrideDurationView.swift +++ b/Loop/Views/Presets/Components/EditOverrideDurationView.swift @@ -26,7 +26,7 @@ struct EditOverrideDurationView: View { self.viewModel = viewModel self.currentDate = Date() - if case let .duration(timeInterval) = viewModel.activePreset?.duration { + if case let .finite(timeInterval) = viewModel.temporaryPresetsManager.activeOverride?.duration { dateSelection = override.startDate.addingTimeInterval(timeInterval) } else { dateSelection = currentDate From 802506aefaa716ca78ceccdb9721b7168ea06170 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 4 Feb 2025 11:36:44 -0500 Subject: [PATCH 210/421] Loop-5057 Preset Editing (#752) * Preset editing * Preset editing updates * Add duration editing * Add duration for legacy workout override --- Loop.xcodeproj/project.pbxproj | 12 + Loop/Managers/DeviceDataManager.swift | 1 + Loop/Managers/SettingsManager.swift | 27 +- Loop/View Models/PresetsViewModel.swift | 186 ++++++++++--- Loop/View Models/SettingsViewModel.swift | 48 ++-- .../Presets/Components/PresetDetentView.swift | 3 +- .../Presets/Components/PresetStatsView.swift | 2 +- Loop/Views/Presets/DurationPickerView.swift | 163 +++++++++++ Loop/Views/Presets/EditPresetRangeView.swift | 178 ++++++++++++ Loop/Views/Presets/EditPresetView.swift | 261 ++++++++++++++++++ Loop/Views/Presets/PresetsView.swift | 7 +- LoopCore/LoopSettings.swift | 11 + WatchApp/DerivedAssets.xcassets/Contents.json | 6 +- 13 files changed, 836 insertions(+), 69 deletions(-) create mode 100644 Loop/Views/Presets/DurationPickerView.swift create mode 100644 Loop/Views/Presets/EditPresetRangeView.swift create mode 100644 Loop/Views/Presets/EditPresetView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 8b687c6893..2b4a2a3c7b 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -436,6 +436,7 @@ 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 */; }; + C14F68C92D4AC54300BC3B8D /* DurationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14F68C82D4AC54000BC3B8D /* DurationPickerView.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 */; }; @@ -445,6 +446,8 @@ C16575732538AFF6004AE16E /* CGMStalenessMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */; }; C16575752539FD60004AE16E /* LoopCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575742539FD60004AE16E /* LoopCoreConstants.swift */; }; C16575762539FEF3004AE16E /* LoopCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575742539FD60004AE16E /* LoopCoreConstants.swift */; }; + C16971DF2D10C21C001B7DF6 /* EditPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16971DE2D10C216001B7DF6 /* EditPresetView.swift */; }; + C16971F92D1231B5001B7DF6 /* EditPresetRangeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16971F82D1231AB001B7DF6 /* EditPresetRangeView.swift */; }; 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 */; }; @@ -1391,6 +1394,7 @@ 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 = ""; }; + C14F68C82D4AC54000BC3B8D /* DurationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationPickerView.swift; sourceTree = ""; }; C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntry.swift; sourceTree = ""; }; C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntryTests.swift; sourceTree = ""; }; C155A8F32986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1407,6 +1411,8 @@ C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitor.swift; sourceTree = ""; }; C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitorTests.swift; sourceTree = ""; }; C16575742539FD60004AE16E /* LoopCoreConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCoreConstants.swift; sourceTree = ""; }; + C16971DE2D10C216001B7DF6 /* EditPresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPresetView.swift; sourceTree = ""; }; + C16971F82D1231AB001B7DF6 /* EditPresetRangeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPresetRangeView.swift; sourceTree = ""; }; 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 = ""; }; @@ -2494,6 +2500,9 @@ 84E8BBAF2CC979300078E6CF /* Presets */ = { isa = PBXGroup; children = ( + C14F68C82D4AC54000BC3B8D /* DurationPickerView.swift */, + C16971F82D1231AB001B7DF6 /* EditPresetRangeView.swift */, + C16971DE2D10C216001B7DF6 /* EditPresetView.swift */, 84E8BBC92CCA16290078E6CF /* PresetsView.swift */, 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */, 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */, @@ -3496,6 +3505,7 @@ C1F2075C26D6F9B0007AB7EB /* AppExpirationAlerter.swift in Sources */, B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */, 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */, + C14F68C92D4AC54300BC3B8D /* DurationPickerView.swift in Sources */, 14C9706E2C5A83AF00E8A01B /* IOBChartView.swift in Sources */, E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */, 84E8BBCE2CCA1E070078E6CF /* PresetsTrainingCard.swift in Sources */, @@ -3530,6 +3540,7 @@ 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */, 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, + C16971F92D1231B5001B7DF6 /* EditPresetRangeView.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, @@ -3695,6 +3706,7 @@ DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */, A9C62D842331700E00535612 /* DiagnosticLog+Subsystem.swift in Sources */, 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */, + C16971DF2D10C21C001B7DF6 /* EditPresetView.swift in Sources */, C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */, A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */, 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */, diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 9ac0d5fdc5..a8f2d6da58 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1290,6 +1290,7 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout + settings.legacyWorkoutDuration = therapySettings.correctionRangeOverrides?.workoutDuration settings.suspendThreshold = therapySettings.suspendThreshold settings.basalRateSchedule = therapySettings.basalRateSchedule settings.maximumBasalRatePerHour = therapySettings.maximumBasalRatePerHour diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index 13c4e0ea91..bba10ac297 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -121,6 +121,7 @@ class SettingsManager { glucoseTargetRangeSchedule: newLoopSettings.glucoseTargetRangeSchedule, preMealTargetRange: newLoopSettings.preMealTargetRange, workoutTargetRange: newLoopSettings.legacyWorkoutTargetRange, + workoutDefaultDuration: newLoopSettings.legacyWorkoutDuration, overridePresets: newLoopSettings.overridePresets, maximumBasalRatePerHour: newLoopSettings.maximumBasalRatePerHour, maximumBolus: newLoopSettings.maximumBolus, @@ -295,16 +296,22 @@ 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) + return TherapySettings( + glucoseTargetRangeSchedule: settings.glucoseTargetRangeSchedule, + correctionRangeOverrides: CorrectionRangeOverrides( + preMeal: settings.preMealTargetRange, + workout: settings.workoutTargetRange, + workoutDuration: settings.workoutDefaultDuration + ), + 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 { diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift index 5d1f281c2f..ca7a167d70 100644 --- a/Loop/View Models/PresetsViewModel.swift +++ b/Loop/View Models/PresetsViewModel.swift @@ -14,6 +14,14 @@ enum PresetDurationType: Equatable { case untilCarbsEntered case duration(TimeInterval) case indefinite + + var presetDuration: TemporaryScheduleOverride.Duration { + switch self { + case .indefinite: return .indefinite + case .duration(let duration): return .finite(duration) + case .untilCarbsEntered: return .indefinite + } + } } enum PresetExpectedEndTime { @@ -22,6 +30,17 @@ enum PresetExpectedEndTime { case indefinite } +extension TemporaryScheduleOverride.Duration { + var presetDurationType: PresetDurationType { + switch self { + case .finite(let interval): + return .duration(interval) + case .indefinite: + return .indefinite + } + } +} + extension TemporaryScheduleOverride { var expectedEndTime: PresetExpectedEndTime? { switch context { @@ -51,15 +70,30 @@ enum PresetIcon { typealias RangeSafetyClassification = (lower: SafetyClassification, upper: SafetyClassification) +extension PresetDurationType: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .indefinite: + hasher.combine("indefinite") + case .untilCarbsEntered: + hasher.combine("untilCarbsEntered") + case .duration(let interval): + hasher.combine("duration") + hasher.combine(interval) + } + } +} + enum SelectablePreset: Hashable, Identifiable { func hash(into hasher: inout Hasher) { switch self { case .custom(let preset): hasher.combine(preset) - case .legacyWorkout(let range, _): + case .legacyWorkout(let range, let duration, _): hasher.combine("legacyWorkout") hasher.combine(range) + hasher.combine(duration) case .preMeal(let range, _): hasher.combine("preMeal") hasher.combine(range) @@ -70,9 +104,9 @@ enum SelectablePreset: Hashable, Identifiable { switch (lhs, rhs) { case (.custom(let lhsPreset), .custom(let rhsPreset)): return lhsPreset == rhsPreset - case (.legacyWorkout(let lhsRange, _), .legacyWorkout(let rhsRange, _)): - return lhsRange == rhsRange - case (.preMeal(let lhsRange, _), .legacyWorkout(let rhsRange, _)): + case (.legacyWorkout(let lhsRange, let lhsDuration, _), .legacyWorkout(let rhsRange, let rhsDuration, _)): + return lhsRange == rhsRange && lhsDuration == rhsDuration + case (.preMeal(let lhsRange, _), .preMeal(let rhsRange, _)): return lhsRange == rhsRange default: return false @@ -88,8 +122,8 @@ enum SelectablePreset: Hashable, Identifiable { } case custom(TemporaryScheduleOverridePreset) - case preMeal(range: ClosedRange, guardrail: Guardrail?) - case legacyWorkout(range: ClosedRange, guardrail: Guardrail?) + case preMeal(range: ClosedRange, guardrail: Guardrail) + case legacyWorkout(range: ClosedRange, duration: PresetDurationType, guardrail: Guardrail) var icon: PresetIcon { switch self { @@ -100,16 +134,29 @@ enum SelectablePreset: Hashable, Identifiable { } var duration: PresetDurationType { - switch self { - case .custom(let preset): - switch preset.duration { - case .indefinite: - return .indefinite - case .finite(let duration): - return .duration(duration) + get { + switch self { + case .custom(let preset): + switch preset.duration { + case .indefinite: + return .indefinite + case .finite(let duration): + return .duration(duration) + } + case .preMeal: return .untilCarbsEntered + case .legacyWorkout(_, let duration, _): + return duration + } + } + set { + switch self { + case .preMeal(let range, let guardrail): + self = .preMeal(range: range, guardrail: guardrail) + case .legacyWorkout(let range, _, let guardrail): + self = .legacyWorkout(range: range, duration: newValue, guardrail: guardrail) + case .custom(var preset): + preset.settings = TemporaryScheduleOverrideSettings(targetRange: preset.settings.targetRange, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) } - case .preMeal: return .untilCarbsEntered - case .legacyWorkout: return .indefinite } } @@ -122,10 +169,23 @@ enum SelectablePreset: Hashable, Identifiable { } var correctionRange: ClosedRange? { - switch self { - case .custom(let preset): return preset.settings.targetRange - case .preMeal(let range, _): return range - case .legacyWorkout(let range, _): return range + get { + switch self { + case .custom(let preset): return preset.settings.targetRange + case .preMeal(let range, _): return range + case .legacyWorkout(let range, _, _): return range + } + } + + set { + switch self { + case .preMeal(_, let guardrail): + self = .preMeal(range: newValue!, guardrail: guardrail) + case .legacyWorkout(_, let duration, let guardrail): + self = .legacyWorkout(range: newValue!, duration: duration, guardrail: guardrail) + case .custom(var preset): + preset.settings = TemporaryScheduleOverrideSettings(targetRange: newValue, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) + } } } @@ -137,13 +197,35 @@ enum SelectablePreset: Hashable, Identifiable { } } - var guardrail: Guardrail? { + var canAdjustSensitivity: Bool { switch self { case .custom: - return nil + return true + case .preMeal: + return false + case .legacyWorkout: + return false + } + } + + var canAdjustDuration: Bool { + switch self { + case .custom: + return true; + case .preMeal: + return false; + case .legacyWorkout: + return true; + } + } + + var guardrail: Guardrail { + switch self { + case .custom: + return Guardrail.correctionRange case .preMeal(_, let guardrail): return guardrail - case .legacyWorkout(_, let guardrail): + case .legacyWorkout(_, _, let guardrail): return guardrail } } @@ -230,18 +312,27 @@ public class PresetsViewModel { } } - var correctionRangeOverrides: CorrectionRangeOverrides? - + @ObservationIgnored var premealRange: ClosedRange? + @ObservationIgnored var workoutRange: ClosedRange? + @ObservationIgnored var workoutDuration: TemporaryScheduleOverride.Duration + let temporaryPresetsManager: TemporaryPresetsManager var customPresets: [TemporaryScheduleOverridePreset] var pendingPreset: SelectablePreset? + var editPreset: [String] = [] - public private(set) var preMealGuardrail: Guardrail? - public private(set) var legacyWorkoutGuardrail: Guardrail? + public private(set) var preMealGuardrail: Guardrail + public private(set) var legacyWorkoutGuardrail: Guardrail private var presetHistory: TemporaryScheduleOverrideHistory + var scheduledRange: ClosedRange + + var activeOverride: TemporaryScheduleOverride? { + temporaryPresetsManager.preMealOverride ?? temporaryPresetsManager.scheduleOverride + } + var activePreset: SelectablePreset? { return allPresets.first(where: { $0.id == temporaryPresetsManager.activeOverride?.presetId }) } @@ -249,16 +340,17 @@ public class PresetsViewModel { var allPresets: [SelectablePreset] { var presets: [SelectablePreset] = [] - if let preMealTargetRange = correctionRangeOverrides?.preMeal { + if let preMealTargetRange = premealRange { presets.append(.preMeal( range: preMealTargetRange, guardrail: preMealGuardrail )) } - if let legacyWorkoutTargetRange = correctionRangeOverrides?.workout { + if let legacyWorkoutTargetRange = workoutRange { presets.append(.legacyWorkout( range: legacyWorkoutTargetRange, + duration: workoutDuration.presetDurationType, guardrail: legacyWorkoutGuardrail )) } @@ -288,30 +380,52 @@ public class PresetsViewModel { return lastUsed![id] } + var presetWasEdited: ((SelectablePreset) throws -> Void)?; + init( customPresets: [TemporaryScheduleOverridePreset], - correctionRangeOverrides: CorrectionRangeOverrides?, + premealRange: ClosedRange?, + workoutRange: ClosedRange?, + workoutDuration: TemporaryScheduleOverride.Duration, presetsHistory: TemporaryScheduleOverrideHistory, - preMealGuardrail: Guardrail?, - legacyWorkoutGuardrail: Guardrail?, - temporaryPresetsManager: TemporaryPresetsManager + preMealGuardrail: Guardrail, + legacyWorkoutGuardrail: Guardrail, + temporaryPresetsManager: TemporaryPresetsManager, + scheduledRange: ClosedRange ) { self.customPresets = customPresets - self.correctionRangeOverrides = correctionRangeOverrides + self.premealRange = premealRange + self.workoutRange = workoutRange + self.workoutDuration = workoutDuration self.presetHistory = presetsHistory self.preMealGuardrail = preMealGuardrail self.legacyWorkoutGuardrail = legacyWorkoutGuardrail self.temporaryPresetsManager = temporaryPresetsManager + self.scheduledRange = scheduledRange } - + + func savePreset(_ preset: SelectablePreset) { + try? presetWasEdited?(preset); + + switch preset { + case .preMeal(let range, _): + self.premealRange = range; + case .legacyWorkout(let range, let duration, _): + self.workoutRange = range; + self.workoutDuration = duration.presetDuration; + default: + break + } + } + func startPreset(_ preset: SelectablePreset) { switch preset { case .custom(let temporaryScheduleOverridePreset): temporaryPresetsManager.scheduleOverride = temporaryScheduleOverridePreset.createOverride(enactTrigger: .local) case .preMeal: temporaryPresetsManager.enablePreMealOverride(for: .hours(1)) - case .legacyWorkout: - temporaryPresetsManager.enableLegacyWorkoutOverride(for: .indefinite) + case .legacyWorkout(_, let duration, _): + temporaryPresetsManager.enableLegacyWorkoutOverride(for: duration.presetDuration) } } diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index d579aff06c..309fcad47b 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -175,28 +175,22 @@ class SettingsViewModel { self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate self.presetHistory = presetHistory - - var preMealGuardrail: Guardrail? - var legacyWorkoutPresetGuardrail: Guardrail? - if let scheduleRange = therapySettings().glucoseTargetRangeSchedule?.scheduleRange() { - preMealGuardrail = Guardrail.correctionRangeOverride( - for: .preMeal, - correctionRangeScheduleRange: scheduleRange, - suspendThreshold: therapySettings().suspendThreshold - ) - self.preMealGuardrail = preMealGuardrail - self.legacyWorkoutPresetGuardrail = legacyWorkoutPresetGuardrail - } - + + self.presetsViewModel = PresetsViewModel( customPresets: therapySettings().overridePresets ?? [], - correctionRangeOverrides: therapySettings().correctionRangeOverrides, + premealRange: therapySettings().correctionRangeOverrides?.preMeal, + workoutRange:therapySettings().correctionRangeOverrides?.workout, + workoutDuration: therapySettings().correctionRangeOverrides?.workoutDuration ?? .indefinite, presetsHistory: presetHistory, - preMealGuardrail: preMealGuardrail, - legacyWorkoutGuardrail: legacyWorkoutPresetGuardrail, - temporaryPresetsManager: temporaryPresetsManager + preMealGuardrail: therapySettings().preMealGuardrail, + legacyWorkoutGuardrail: therapySettings().legacyWorkoutPresetGuardrail, + temporaryPresetsManager: temporaryPresetsManager, + scheduledRange: therapySettings().glucoseTargetRangeSchedule!.quantityRange(at: Date()) ) + self.presetsViewModel.presetWasEdited = savePreset + // This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject) lastLoopCompletion .assign(to: \.lastLoopCompletion, on: self) @@ -208,6 +202,26 @@ class SettingsViewModel { .assign(to: \.mostRecentPumpDataDate, on: self) .store(in: &cancellables) } + + func savePreset(_ preset: SelectablePreset) throws { + var therapySettings = therapySettings() + var preMealRange = therapySettings.correctionRangeOverrides?.ranges[.preMeal] + var workoutRange = therapySettings.correctionRangeOverrides?.ranges[.workout] + var workoutDuration = therapySettings.correctionRangeOverrides?.workoutDuration + + switch(preset) { + case .preMeal(let range, _): + preMealRange = range + case .legacyWorkout(let range, let duration, _): + workoutRange = range + workoutDuration = duration.presetDuration + default: + // TODO: editing of custom presets + break + } + therapySettings.correctionRangeOverrides = CorrectionRangeOverrides(preMeal: preMealRange, workout: workoutRange, workoutDuration: workoutDuration) + therapySettingsViewModelDelegate?.saveCompletion(therapySettings: therapySettings) + } } // For previews only diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index e263f581aa..5c8657dc39 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -124,7 +124,8 @@ struct PresetDetentView: View { if operation == .start { Button { - print("Edit \(preset.name)") + dismiss() + viewModel.editPreset.append(preset.id) } label: { Group { Text(Image(systemName: "pencil")) + Text(" ") + Text("Edit Preset") diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift index 6cb5558e64..a71de4fbe9 100644 --- a/Loop/Views/Presets/Components/PresetStatsView.swift +++ b/Loop/Views/Presets/Components/PresetStatsView.swift @@ -32,7 +32,7 @@ struct PresetStatsView: View { .foregroundColor(.secondary) .accessibilitySortPriority(2) - let percent = numberFormatter.string(from: insulinSensitivityMultiplier ?? 1)! + let percent = numberFormatter.string(from: 1.0/(insulinSensitivityMultiplier ?? 1))! Group { Text(percent).bold() + Text(" of scheduled") } .font(.subheadline) .accessibilitySortPriority(1) diff --git a/Loop/Views/Presets/DurationPickerView.swift b/Loop/Views/Presets/DurationPickerView.swift new file mode 100644 index 0000000000..e4edee92b6 --- /dev/null +++ b/Loop/Views/Presets/DurationPickerView.swift @@ -0,0 +1,163 @@ +// +// DurationPickerView.swift +// Loop +// +// Created by Pete Schwamb on 1/29/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct DurationPickerView: View { + @Binding var durationType: PresetDurationType + @State private var lastUsedDuration: TimeInterval + + // Available values (respecting min 5min and max 8hr constraints) + private let availableHours = Array(0...8) + private let availableMinutes = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55] + + init(durationType: Binding) { + self._durationType = durationType + + // Initialize lastUsedDuration based on current durationType or default to 1 hour + let initialDuration: TimeInterval + switch durationType.wrappedValue { + case .duration(let interval): + initialDuration = interval + case .indefinite, .untilCarbsEntered: + initialDuration = 3600 // 1 hour default + } + self._lastUsedDuration = State(initialValue: initialDuration) + } + + private var hours: Binding { + Binding( + get: { + Int(lastUsedDuration / 3600) + }, + set: { newHours in + let existingMinutes = minutes.wrappedValue + let newInterval = TimeInterval(newHours * 3600 + existingMinutes * 60) + lastUsedDuration = newInterval + if !isIndefinite.wrappedValue { + durationType = .duration(newInterval) + } + } + ) + } + + private var minutes: Binding { + Binding( + get: { + Int((lastUsedDuration.truncatingRemainder(dividingBy: 3600)) / 60) + }, + set: { newMinutes in + let existingHours = hours.wrappedValue + let newInterval = TimeInterval(existingHours * 3600 + newMinutes * 60) + lastUsedDuration = newInterval + if !isIndefinite.wrappedValue { + durationType = .duration(newInterval) + } + } + ) + } + + private var isIndefinite: Binding { + Binding( + get: { + if case .indefinite = durationType { + return true + } + return false + }, + set: { isOn in + if isOn { + durationType = .indefinite + } else { + durationType = .duration(lastUsedDuration) + } + } + ) + } + + var body: some View { + VStack(alignment: .center, spacing: 24) { + HStack { + Text("Duration") + .font(.system(size: 17, weight: .regular)) + Spacer() + Text("Required") + .font(.system(size: 17, weight: .regular)) + .foregroundColor(.gray) + } + + HStack(spacing: 16) { + HStack(spacing: 8) { + Picker("Hours", selection: hours) { + ForEach(availableHours, id: \.self) { hour in + Text("\(hour)") + .tag(hour) + } + } + .pickerStyle(.wheel) + .frame(width: 60) + .clipped() + .disabled(isIndefinite.wrappedValue) + .opacity(isIndefinite.wrappedValue ? 0.5 : 1) + + Text("hour") + .foregroundColor(isIndefinite.wrappedValue ? .gray.opacity(0.5) : .gray) + } + + HStack(spacing: 8) { + Picker("Minutes", selection: minutes) { + ForEach(availableMinutes, id: \.self) { minute in + Text("\(minute)") + .tag(minute) + } + } + .pickerStyle(.wheel) + .frame(width: 60) + .clipped() + .disabled(isIndefinite.wrappedValue) + .opacity(isIndefinite.wrappedValue ? 0.5 : 1) + + Text("min") + .foregroundColor(isIndefinite.wrappedValue ? .gray.opacity(0.5) : .gray) + } + } + .padding(.horizontal) + .onChange(of: hours.wrappedValue) { _ in + enforceConstraints() + } + .onChange(of: minutes.wrappedValue) { _ in + enforceConstraints() + } + + HStack { + Text("Until I turn off") + .font(.system(size: 17, weight: .regular)) + Spacer() + Toggle("", isOn: isIndefinite) + .labelsHidden() + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(10) + } + + private func enforceConstraints() { + if !isIndefinite.wrappedValue { + if lastUsedDuration < 300 { // Less than 5 minutes + lastUsedDuration = 300 + durationType = .duration(300) + } else if lastUsedDuration > 28800 { // More than 8 hours + lastUsedDuration = 28800 + durationType = .duration(28800) + } else { + durationType = .duration(lastUsedDuration) + } + } + } +} diff --git a/Loop/Views/Presets/EditPresetRangeView.swift b/Loop/Views/Presets/EditPresetRangeView.swift new file mode 100644 index 0000000000..f6b10e2aac --- /dev/null +++ b/Loop/Views/Presets/EditPresetRangeView.swift @@ -0,0 +1,178 @@ +// +// EditPressRangeView.swift +// Loop +// +// Created by Pete Schwamb on 12/17/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// +import SwiftUI +import LoopAlgorithm +import LoopKit +import LoopKitUI + +struct EditPresetRangeView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.guidanceColors) private var guidanceColors + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + + @Binding var range: ClosedRange? + var guardrail: Guardrail + private var scheduledRange: ClosedRange + + init(range: Binding?>, guardrail: Guardrail, scheduledRange: ClosedRange) { + self._range = range + self.guardrail = guardrail + self.scheduledRange = scheduledRange + } + + func boundText(for bound: LoopQuantity) -> Text { + let color = guardrail.color(for: bound, guidanceColors: guidanceColors) + let text = displayGlucosePreference.format(bound, includeUnit: false) + switch guardrail.classification(for: bound) { + case .withinRecommendedRange: + return Text(text) + .foregroundColor(.accentColor) + .font(.system(size: 42, weight: .semibold)) + case .outsideRecommendedRange: + return ( + Text(Image(systemName: "exclamationmark.triangle.fill")) + .font(.system(size: 29, weight: .regular)) + .baselineOffset(3.0) + .foregroundColor(color) + + Text(text) + .foregroundColor(color) + .font(.system(size: 42, weight: .semibold)) + ) + } + } + + + var body: some View { + List { + VStack(spacing: 24) { + VStack(spacing: 8) { + HStack { + Text("Correction Range") + .foregroundColor(.secondary) + .font(.system(size: 14)) + Image(systemName: "info.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.accentColor) + .frame(width: UIFontMetrics.default.scaledValue(for: 14), height: UIFontMetrics.default.scaledValue(for: 14)) + } + .padding(.top, 10) + + + Text("Set your correction range") + .font(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .padding(.top, 10) + + Text("To reduce the risk of highs or lows, you may want to set an adjusted range if you think your glucose will vary more than usual.") + .multilineTextAlignment(.center) + } + + VStack(spacing: 0) { + Text("Adjusted Range") + + ( + boundText(for: (range ?? scheduledRange).lowerBound) + + Text("-").foregroundColor(.secondary) + .font(.system(size: 42, weight: .light)) + + + boundText(for: (range ?? scheduledRange).upperBound) + ) + + + Text("mg/dL") + .foregroundColor(.secondary) + } + + Divider() + + GlucoseRangePicker(range: Binding( + get: { range ?? scheduledRange }, + set: { range = $0 }), + unit: displayGlucosePreference.unit, + minValue: nil, + guardrail: guardrail) + .padding(.vertical, -20) + + guardrailWarningIfNecessary + + HStack(spacing: 8) { + Image(systemName: "info.circle") + .foregroundColor(.accentColor) + + (Text("To help avoid lows, set a range ") + + Text("higher") + .italic() + .bold() + + Text(" than your typical correction range.")) + .font(.system(size: 14)) + } + .padding() + .overlay( /// apply a rounded border + RoundedRectangle(cornerRadius: 8) + .stroke(.gray, lineWidth: 1) + ) + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Edit Preset") + } + + var crossedThresholds: [SafetyClassification.Threshold] { + if let range { + let lowerBound = range.lowerBound + let upperBound = range.upperBound + return [lowerBound, upperBound].compactMap { (bound) -> SafetyClassification.Threshold? in + switch guardrail.classification(for: bound) { + case .withinRecommendedRange: + return nil + case .outsideRecommendedRange(let threshold): + return threshold + } + } + } else { + return [] + } + } + + var guardrailWarningIfNecessary: some View { + let crossedThresholds = self.crossedThresholds + return Group { + if !crossedThresholds.isEmpty { + CorrectionRangeGuardrailWarning(crossedThresholds: crossedThresholds) + } + } + } +} + +private struct CorrectionRangeGuardrailWarning: View { + var crossedThresholds: [SafetyClassification.Threshold] + + var body: some View { + assert(!crossedThresholds.isEmpty) + return GuardrailWarning( + therapySetting: .glucoseTargetRange, + title: crossedThresholds.count == 1 ? singularWarningTitle(for: crossedThresholds.first!) : multipleWarningTitle, + thresholds: crossedThresholds + ) + } + + private func singularWarningTitle(for threshold: SafetyClassification.Threshold) -> Text { + switch threshold { + case .minimum, .belowRecommended: + return Text("Low Correction Value", comment: "Title text for the low correction value warning") + case .aboveRecommended, .maximum: + return Text("High Correction Value", comment: "Title text for the high correction value warning") + } + } + + private var multipleWarningTitle: Text { + Text("Correction Values", comment: "Title text for multi-value correction value warning") + } +} diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift new file mode 100644 index 0000000000..7a7c19536d --- /dev/null +++ b/Loop/Views/Presets/EditPresetView.swift @@ -0,0 +1,261 @@ +// +// EditPresetView.swift +// Loop +// +// Created by Pete Schwamb on 12/09/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import SwiftUI +import LoopKitUI +import LoopAlgorithm + +struct CompactSection: View { + let header: Header? + let content: Content + + // Initializer for custom view header + init(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> Header) { + self.content = content() + self.header = header() + } + + // Initializer for string header + init(_ headerText: String?, @ViewBuilder content: () -> Content) where Header == Text { + self.content = content() + self.header = headerText.map { Text($0) } + } + + // Initializer for no header + init(@ViewBuilder content: () -> Content) where Header == Text { + self.content = content() + self.header = nil + } + + var body: some View { + Section { + content + } header: { + if let header { + header + .padding([.leading, .trailing], -10) + } + } + .listRowInsets(EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)) + } +} + + +struct EditPresetView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.guidanceColors) private var guidanceColors + @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + + @State private var duration: TimeInterval = 3600 // 1 hour in seconds + @State private var presetName: String + @State private var preset: SelectablePreset + + private var originalPreset: SelectablePreset + private var scheduledRange: ClosedRange + private var onSave: (SelectablePreset) throws -> Void + + @State private var showingPicker = false + + init(preset: SelectablePreset, scheduledRange: ClosedRange, onSave: @escaping ((SelectablePreset) throws -> Void)) { + self.preset = preset + self.originalPreset = preset + self.presetName = preset.name + self.scheduledRange = scheduledRange + self.onSave = onSave + } + + func boundText(for bound: LoopQuantity) -> Text { + let color = preset.guardrail.color(for: bound, guidanceColors: guidanceColors) + let text = displayGlucosePreference.format(bound, includeUnit: false) + switch preset.guardrail.classification(for: bound) { + case .withinRecommendedRange: + return Text(text) + .foregroundColor(.accentColor) + .font(.system(size: 34, weight: .semibold)) + case .outsideRecommendedRange: + return ( + Text(Image(systemName: "exclamationmark.triangle.fill")) + .font(.system(size: 23, weight: .regular)) + .baselineOffset(3.0) + .foregroundColor(color) + + Text(text) + .foregroundColor(color) + .font(.system(size: 34, weight: .semibold)) + ) + } + } + + func correctionRangeLabel(range: ClosedRange) -> Text { + boundText(for: (preset.correctionRange ?? scheduledRange).lowerBound) + + Text("-").foregroundColor(.secondary) + .font(.system(size: 34, weight: .light)) + + + boundText(for: (preset.correctionRange ?? scheduledRange).upperBound) + + Text(" ") + + Text(displayGlucosePreference.unit.localizedShortUnitString) + .font(.system(.body)) + .foregroundColor(.secondary) + .baselineOffset(5) + } + + var sensitivitySection: some View { + CompactSection("Temporary Settings Adjustments") { + VStack(alignment: .leading, spacing: 8) { + Text("Overall Insulin") + .font(.system(.title3, weight: .semibold)) + + HStack { + Spacer() + VStack(alignment: .center) { + Text("\(Int((1.0 / (preset.insulinSensitivityMultiplier ?? 1)) * 100))%") + .font(.system(size: 48, weight: .semibold)) + .foregroundColor(.accentColor) + Text("of scheduled") + .foregroundColor(.primary) + } + Spacer() + } + + if (!preset.canAdjustSensitivity) { + (Text(Image(systemName: "info.circle")) + Text(" Overall insulin cannot be adjusted for this preset")) + .foregroundColor(.secondary) + .font(.footnote) + .italic() + .padding(.top, 4) + } + } + .listRowInsets(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)) + } + } + + var correctionSection: some View { + CompactSection { + NavigationLink { + EditPresetRangeView( + range: $preset.correctionRange, + guardrail: preset.guardrail, + scheduledRange: scheduledRange + ) + } label: { + VStack(alignment: .leading, spacing: 8) { + Text("Correction Range") + .font(.system(.title3, weight: .semibold)) + HStack { + Spacer() + VStack(alignment: .center) { + if let range = preset.correctionRange { + correctionRangeLabel(range: range) + Text("Adjusted Range") + .foregroundColor(.primary) + } else { + correctionRangeLabel(range: scheduledRange) + Text("Scheduled Range") + .foregroundColor(.primary) + } + } + Spacer() + } + } + } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Form { + Section {} header: { + presetTitle + } + .listRowInsets(EdgeInsets(top: 5, leading: 0, bottom: 0, trailing: 0)) + .textCase(nil) + + sensitivitySection + + correctionSection + + CompactSection("PRESET DETAILS") { + HStack { + Text("Name") + Spacer() + Text(presetName) + .foregroundColor(.secondary) + } + } + + CompactSection() { + Button(action: { + showingPicker = true + }) { + HStack { + Text("Duration") + .foregroundColor(.primary) + Spacer() + Text(preset.duration.localizedTitle) + .foregroundColor(.secondary) + if preset.canAdjustDuration { + Image(systemName: "chevron.right") + .foregroundColor(.gray) + } + } + .background(Color(.systemBackground)) + }.disabled(!preset.canAdjustDuration) + } + + CompactSection {} header: { + Button("Save Preset") { + do { + try onSave(preset) + } catch { + print(error) + } + dismiss() + } + .disabled(preset == originalPreset) + .buttonStyle(ActionButtonStyle(.primary)) + .textCase(nil) + } + } + .listSectionSpacing(16) + } + .navigationBarBackButtonHidden(true) + .navigationBarItems( + trailing: Button("Cancel") { + dismiss() + } + .foregroundColor(.blue) + ) + .sheet(isPresented: $showingPicker) { + DurationPickerView(durationType: $preset.duration) + .presentationDetents([.height(300)]) + } + } + + var presetTitle: some View { + HStack(spacing: 6) { + switch preset.icon { + case .emoji(let emoji): + Text(emoji) + .font(.system(size: 48, weight: .semibold)) + .foregroundColor(.primary) + case .image(let name, let iconColor): + Image(name) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(iconColor) + .frame(width: UIFontMetrics.default.scaledValue(for: 48), height: UIFontMetrics.default.scaledValue(for: 48)) + } + + Text(presetName) + .font(.system(size: 48, weight: .semibold)) + .foregroundColor(.primary) + } + } + +} diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index e93f7e4d40..e7d6889334 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -61,7 +61,7 @@ struct PresetsView: View { } var body: some View { - NavigationView { + NavigationStack(path: $viewModel.editPreset) { ScrollView { VStack(spacing: 20) { if !viewModel.hasCompletedTraining { @@ -164,6 +164,11 @@ struct PresetsView: View { .background(Color(UIColor.secondarySystemBackground)) .navigationTitle(Text("Presets", comment: "Presets screen title")) .navigationBarItems(trailing: dismissButton) + .navigationDestination(for: String.self) { presetId in + EditPresetView(preset: viewModel.allPresets.first { $0.id == presetId }!, scheduledRange: viewModel.scheduledRange) { preset in + viewModel.savePreset(preset) + } + } } .sheet(item: $viewModel.pendingPreset) { preset in PresetDetentView( diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index ea68ce4ad5..5723859809 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -34,6 +34,8 @@ public struct LoopSettings: Equatable { public var legacyWorkoutTargetRange: ClosedRange? + public var legacyWorkoutDuration: TemporaryScheduleOverride.Duration? + public var overridePresets: [TemporaryScheduleOverridePreset] = [] public var maximumBasalRatePerHour: Double? @@ -58,6 +60,7 @@ public struct LoopSettings: Equatable { carbRatioSchedule: CarbRatioSchedule? = nil, preMealTargetRange: ClosedRange? = nil, legacyWorkoutTargetRange: ClosedRange? = nil, + legacyWorkoutDuration: TemporaryScheduleOverride.Duration = .indefinite, overridePresets: [TemporaryScheduleOverridePreset]? = nil, maximumBasalRatePerHour: Double? = nil, maximumBolus: Double? = nil, @@ -72,6 +75,7 @@ public struct LoopSettings: Equatable { self.carbRatioSchedule = carbRatioSchedule self.preMealTargetRange = preMealTargetRange self.legacyWorkoutTargetRange = legacyWorkoutTargetRange + self.legacyWorkoutDuration = legacyWorkoutDuration self.overridePresets = overridePresets ?? [] self.maximumBasalRatePerHour = maximumBasalRatePerHour self.maximumBolus = maximumBolus @@ -120,6 +124,10 @@ extension LoopSettings: RawRepresentable { self.legacyWorkoutTargetRange = DoubleRange(rawValue: rawLegacyWorkoutTargetRange)?.quantityRange(for: LoopSettings.codingGlucoseUnit) } + if let rawLegacyWorkoutDuration = rawValue["legacyWorkoutDuration"] as? Double { + self.legacyWorkoutDuration = .finite(rawLegacyWorkoutDuration) + } + if let rawPresets = rawValue["overridePresets"] as? [TemporaryScheduleOverridePreset.RawValue] { self.overridePresets = rawPresets.compactMap(TemporaryScheduleOverridePreset.init(rawValue:)) } @@ -149,6 +157,9 @@ extension LoopSettings: RawRepresentable { raw["glucoseTargetRangeSchedule"] = glucoseTargetRangeSchedule?.rawValue raw["preMealTargetRange"] = preMealTargetRange?.doubleRange(for: LoopSettings.codingGlucoseUnit).rawValue raw["legacyWorkoutTargetRange"] = legacyWorkoutTargetRange?.doubleRange(for: LoopSettings.codingGlucoseUnit).rawValue + if case .finite(let duration) = legacyWorkoutDuration { + raw["legacyWorkoutDuration"] = duration + } raw["maximumBasalRatePerHour"] = maximumBasalRatePerHour raw["maximumBolus"] = maximumBolus raw["minimumBGGuard"] = suspendThreshold?.rawValue diff --git a/WatchApp/DerivedAssets.xcassets/Contents.json b/WatchApp/DerivedAssets.xcassets/Contents.json index 73c00596a7..da4a164c91 100644 --- a/WatchApp/DerivedAssets.xcassets/Contents.json +++ b/WatchApp/DerivedAssets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "author" : "xcode", - "version" : 1 + "version" : 1, + "author" : "xcode" } -} +} \ No newline at end of file From df37176175214651f9d2ec9d8e9817f5946b29d6 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 6 Feb 2025 13:07:50 -0400 Subject: [PATCH 211/421] [LOOP-5232] zero out the bolus amount (#753) --- Loop/Views/BolusEntryView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 980521b84f..9cca3cf1b4 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -67,6 +67,9 @@ struct BolusEntryView: View { newEnteredBolusString = viewModel.formatBolusAmount(amount) } enteredBolusStringBinding.wrappedValue = newEnteredBolusString + } else { + // If the recommendation changes, and the user has edited the bolus amount, set the bolus amount to 0 + enteredBolusStringBinding.wrappedValue = "0" } } .task { From 65caad7148df405fec0a0cfe14b18bd898e240e7 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Sat, 8 Feb 2025 09:20:31 -0400 Subject: [PATCH 212/421] [LOOP-4760] vibrate for none critical alerts (#754) --- Loop/Managers/Alerts/UserNotificationAlertScheduler.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift b/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift index a1eb654209..e9381886d7 100644 --- a/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift +++ b/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift @@ -8,6 +8,7 @@ import LoopKit import UIKit +import AudioToolbox public protocol UserNotificationCenter { func add(_ request: UNNotificationRequest, withCompletionHandler: ((Error?) -> Void)?) @@ -84,8 +85,12 @@ fileprivate extension Alert { switch sound { case .vibrate: + guard interruptionLevel == .critical else { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + return nil + } // setting the audio volume of critical alert to 0 only vibrates - return interruptionLevel == .critical ? .defaultCriticalSound(withAudioVolume: 0) : nil + return .defaultCriticalSound(withAudioVolume: 0) default: if let actualFileName = AlertManager.soundURL(for: self)?.lastPathComponent { let unname = UNNotificationSoundName(rawValue: actualFileName) From e53d236022f75900b9fa7d11240b34742c206e02 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 10 Feb 2025 06:42:49 -0400 Subject: [PATCH 213/421] [LOOP-5247] match insulin layout (#755) --- Loop Widget Extension/Widgets/SystemStatusWidget.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop Widget Extension/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift index 2cb9f7fc91..67f211ebc1 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -44,8 +44,8 @@ struct SystemStatusWidgetEntryView: View { GlucoseView(entry: entry) .frame(maxWidth: .infinity, alignment: .center) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .padding(5) + .frame(maxHeight: .infinity, alignment: .center) + .padding(.vertical, 5) .containerRelativeBackground() HStack(alignment: .center, spacing: 0) { From a333777d9610399d49328f4e4e20b013b57ee4e4 Mon Sep 17 00:00:00 2001 From: Petr Zywczok Date: Wed, 29 Jan 2025 07:01:22 +0100 Subject: [PATCH 214/421] [QAE-451] Add identifier for Presets button --- .../Presets/Components/PresetStatsView.swift | 20 ++++++++++++++++++- Loop/Views/SettingsView.swift | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift index a71de4fbe9..e4038218fe 100644 --- a/Loop/Views/Presets/Components/PresetStatsView.swift +++ b/Loop/Views/Presets/Components/PresetStatsView.swift @@ -73,6 +73,24 @@ struct PresetStatsView: View { let lowerClassification = guardrail?.classification(for: target.lowerBound) ?? .withinRecommendedRange let upperClassification = guardrail?.classification(for: target.upperBound) ?? .withinRecommendedRange + var accessibilityId = "text_PresetCorrectionRange_" + + switch (lowerClassification, upperClassification) { + case (.withinRecommendedRange, .withinRecommendedRange): + accessibilityId += "WithinRange" + case (.withinRecommendedRange, .outsideRecommendedRange): + accessibilityId += "UpperWarning" + accessibilityId += upperColor == .red ? "Red" : "Orange" + case (.outsideRecommendedRange, .outsideRecommendedRange): + accessibilityId += "LowerWarning" + accessibilityId += lowerColor == .red ? "Red" : "Orange" + accessibilityId += "UpperWarning" + accessibilityId += upperColor == .red ? "Red" : "Orange" + case (.outsideRecommendedRange, .withinRecommendedRange): + accessibilityId += "LowerWarning" + accessibilityId += lowerColor == .red ? "Red" : "Orange" + } + return Group { switch (lowerClassification, upperClassification) { case (.withinRecommendedRange, .withinRecommendedRange): @@ -84,7 +102,7 @@ struct PresetStatsView: View { case (.outsideRecommendedRange, .withinRecommendedRange): warningSymbol.foregroundStyle(lowerColor) + lower + Text("-") + upper + units } - } + }.accessibilityIdentifier(accessibilityId) } var correctionRangeView: some View { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 40e38d824c..bc719f1fa9 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -361,7 +361,7 @@ extension SettingsView { imageView: Image("Presets Icon"), label: NSLocalizedString("Presets", comment: "Title text for button to Preset Settings"), descriptiveText: NSLocalizedString("Temporary Settings Adjustments", comment: "Descriptive text for Preset Settings") - ) + ).accessibilityIdentifier("button_Presets") } } From a9af9fadb0bea97d962307e10bc10a8fdb6c8a1f Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 12 Feb 2025 12:19:55 -0400 Subject: [PATCH 215/421] [LOOP-4922] use the canceled bolus if provided (#758) --- Loop/View Controllers/StatusTableViewController.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index b275fc2241..d1490c8c3e 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1242,8 +1242,10 @@ final class StatusTableViewController: LoopChartsTableViewController { deviceManager.pumpManager?.cancelBolus() { (result) in DispatchQueue.main.async { switch result { - case .success: - self.updateBannerAndHUDandStatusRows(statusRowMode: .canceledBolus(dose: dose), newSize: nil, animated: true) + case .success(let canceledDose): + let doseToReport = canceledDose ?? dose + self.canceledDose = doseToReport + self.updateBannerAndHUDandStatusRows(statusRowMode: .canceledBolus(dose: doseToReport), newSize: nil, animated: true) self.bolusState = .noBolus Task { try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 10) From fa87178c0254d901c93adbcbc5a893f44a0c6981 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 12 Feb 2025 14:40:30 -0400 Subject: [PATCH 216/421] [LOOP-5232] reset to 0 after recommendation updates and bolus amount was manually edited (#757) --- Loop/View Models/SimpleBolusViewModel.swift | 7 ++++++- Loop/Views/SimpleBolusView.swift | 12 +++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift index ce5512ed66..9955f0addd 100644 --- a/Loop/View Models/SimpleBolusViewModel.swift +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -185,6 +185,7 @@ class SimpleBolusViewModel: ObservableObject { updateNotice() } } + var didEditBolusAmount: Bool = false private var carbQuantity: LoopQuantity? = nil @@ -213,7 +214,11 @@ class SimpleBolusViewModel: ObservableObject { didSet { if let recommendation = recommendation, let maxBolus = delegate.maximumBolus { recommendedBolus = Self.doseAmountFormatter.string(from: recommendation)! - enteredBolusString = Self.doseAmountFormatter.string(from: min(recommendation, maxBolus))! + if didEditBolusAmount { + enteredBolusString = "0" + } else { + 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 = "" diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index de4cec2a07..6cb8f33de8 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -28,6 +28,16 @@ struct SimpleBolusView: View { set: { newValue in viewModel.manualGlucoseString = newValue } ) } + + private var enteredBolusString: Binding { + Binding( + get: { return viewModel.enteredBolusString }, + set: { newValue in + viewModel.enteredBolusString = newValue + viewModel.didEditBolusAmount = true + } + ) + } init(viewModel: SimpleBolusViewModel) { self.viewModel = viewModel @@ -192,7 +202,7 @@ struct SimpleBolusView: View { Spacer() HStack(alignment: .firstTextBaseline) { DismissibleKeyboardTextField( - text: $viewModel.enteredBolusString, + text: enteredBolusString, placeholder: "0", font: .preferredFont(forTextStyle: .title1), textColor: .loopAccent, From c020f875030cc49b2128ad5093d4f006805e4b37 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 14 Feb 2025 09:41:01 -0600 Subject: [PATCH 217/421] Updates from design review (#759) --- Loop/Views/Presets/EditPresetRangeView.swift | 161 +++++++++++------- Loop/Views/Presets/EditPresetView.swift | 92 +++++----- WatchApp/DerivedAssets.xcassets/Contents.json | 6 +- 3 files changed, 147 insertions(+), 112 deletions(-) diff --git a/Loop/Views/Presets/EditPresetRangeView.swift b/Loop/Views/Presets/EditPresetRangeView.swift index f6b10e2aac..7c4dd3e1c5 100644 --- a/Loop/Views/Presets/EditPresetRangeView.swift +++ b/Loop/Views/Presets/EditPresetRangeView.swift @@ -18,6 +18,7 @@ struct EditPresetRangeView: View { @Binding var range: ClosedRange? var guardrail: Guardrail private var scheduledRange: ClosedRange + @State private var editedRange: ClosedRange? init(range: Binding?>, guardrail: Guardrail, scheduledRange: ClosedRange) { self._range = range @@ -25,6 +26,10 @@ struct EditPresetRangeView: View { self.scheduledRange = scheduledRange } + var displayedRange: ClosedRange { + return editedRange ?? range ?? scheduledRange + } + func boundText(for bound: LoopQuantity) -> Text { let color = guardrail.color(for: bound, guidanceColors: guidanceColors) let text = displayGlucosePreference.format(bound, includeUnit: false) @@ -46,86 +51,122 @@ struct EditPresetRangeView: View { } } - var body: some View { - List { - VStack(spacing: 24) { - VStack(spacing: 8) { - HStack { - Text("Correction Range") - .foregroundColor(.secondary) - .font(.system(size: 14)) - Image(systemName: "info.circle") - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(.accentColor) - .frame(width: UIFontMetrics.default.scaledValue(for: 14), height: UIFontMetrics.default.scaledValue(for: 14)) - } - .padding(.top, 10) + VStack(spacing: 0) { + List { + VStack(spacing: 24) { + VStack(spacing: 8) { + HStack { + Text("Correction Range") + .foregroundColor(.secondary) + .font(.system(size: 14)) + Image(systemName: "info.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.accentColor) + .frame(width: UIFontMetrics.default.scaledValue(for: 14), height: UIFontMetrics.default.scaledValue(for: 14)) + } + .padding(.top, 10) - Text("Set your correction range") - .font(.title2) - .fontWeight(.bold) - .multilineTextAlignment(.center) - .padding(.top, 10) + Text("Set your correction range") + .font(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .padding(.top, 10) - Text("To reduce the risk of highs or lows, you may want to set an adjusted range if you think your glucose will vary more than usual.") - .multilineTextAlignment(.center) - } + Text("To reduce the risk of highs or lows, you may want to set an adjusted range if you think your glucose will vary more than usual.") + .multilineTextAlignment(.center) + } - VStack(spacing: 0) { - Text("Adjusted Range") + VStack(spacing: 0) { + Text("Adjusted Range") - ( - boundText(for: (range ?? scheduledRange).lowerBound) + - Text("-").foregroundColor(.secondary) - .font(.system(size: 42, weight: .light)) - + - boundText(for: (range ?? scheduledRange).upperBound) - ) + ( + boundText(for: (displayedRange).lowerBound) + + Text("-").foregroundColor(.secondary) + .font(.system(size: 42, weight: .light)) + + + boundText(for: (displayedRange).upperBound) + ) - Text("mg/dL") - .foregroundColor(.secondary) - } + Text("mg/dL") + .foregroundColor(.secondary) + } - Divider() + Divider() - GlucoseRangePicker(range: Binding( - get: { range ?? scheduledRange }, - set: { range = $0 }), - unit: displayGlucosePreference.unit, - minValue: nil, - guardrail: guardrail) + GlucoseRangePicker(range: Binding( + get: { displayedRange }, + set: { editedRange = $0 }), + unit: displayGlucosePreference.unit, + minValue: nil, + guardrail: guardrail) .padding(.vertical, -20) - guardrailWarningIfNecessary - - HStack(spacing: 8) { - Image(systemName: "info.circle") - .foregroundColor(.accentColor) + HStack(spacing: 8) { + Image(systemName: "info.circle") + .foregroundColor(.accentColor) - (Text("To help avoid lows, set a range ") - + Text("higher") - .italic() - .bold() - + Text(" than your typical correction range.")) - .font(.system(size: 14)) + (Text("To help avoid lows, set a range ") + + Text("higher") + .italic() + .bold() + + Text(" than your typical correction range.")) + .font(.system(size: 14)) + } + .padding() + .overlay( /// apply a rounded border + RoundedRectangle(cornerRadius: 8) + .stroke(.gray, lineWidth: 1) + ) } - .padding() - .overlay( /// apply a rounded border - RoundedRectangle(cornerRadius: 8) - .stroke(.gray, lineWidth: 1) - ) } + actionArea } + .navigationBarBackButtonHidden(editedRange != nil) + .navigationBarItems( + trailing: cancelButton + ) .navigationBarTitleDisplayMode(.inline) .navigationTitle("Edit Preset") + .edgesIgnoringSafeArea(.bottom) + } + + private var cancelButton: some View { + Group { + if editedRange != nil { + Button("Cancel") { + dismiss() + } + .foregroundColor(.blue) + } + } } + + private var actionArea: some View { + VStack(spacing: 0) { + guardrailWarningIfNecessary + actionButton + } + .background(Color(.secondarySystemGroupedBackground).shadow(radius: 5)) + } + + private var actionButton: some View { + Button("Save") { + range = editedRange + dismiss() + } + .disabled(editedRange == nil) + .buttonStyle(ActionButtonStyle(.primary)) + .padding() + } + + var crossedThresholds: [SafetyClassification.Threshold] { - if let range { + if let range = editedRange ?? range { let lowerBound = range.lowerBound let upperBound = range.upperBound return [lowerBound, upperBound].compactMap { (bound) -> SafetyClassification.Threshold? in @@ -147,7 +188,7 @@ struct EditPresetRangeView: View { if !crossedThresholds.isEmpty { CorrectionRangeGuardrailWarning(crossedThresholds: crossedThresholds) } - } + }.padding() } } diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 7a7c19536d..4be266325c 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -12,26 +12,30 @@ import SwiftUI import LoopKitUI import LoopAlgorithm -struct CompactSection: View { +struct CompactSection: View { let header: Header? + let footer: Footer? let content: Content // Initializer for custom view header - init(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> Header) { + init(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> Header, @ViewBuilder footer: () -> Footer) { self.content = content() self.header = header() + self.footer = footer() } // Initializer for string header - init(_ headerText: String?, @ViewBuilder content: () -> Content) where Header == Text { + init(_ headerText: String? = nil, @ViewBuilder content: () -> Content, footerText: String? = nil) where Header == Text, Footer == Text { self.content = content() self.header = headerText.map { Text($0) } + self.footer = footerText.map { Text($0) } } // Initializer for no header - init(@ViewBuilder content: () -> Content) where Header == Text { + init(@ViewBuilder content: () -> Content) where Header == Text, Footer == Text { self.content = content() self.header = nil + self.footer = nil } var body: some View { @@ -42,6 +46,10 @@ struct CompactSection: View { header .padding([.leading, .trailing], -10) } + } footer: { + if let footer { + footer + } } .listRowInsets(EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)) } @@ -53,7 +61,6 @@ struct EditPresetView: View { @Environment(\.guidanceColors) private var guidanceColors @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - @State private var duration: TimeInterval = 3600 // 1 hour in seconds @State private var presetName: String @State private var preset: SelectablePreset @@ -78,7 +85,7 @@ struct EditPresetView: View { case .withinRecommendedRange: return Text(text) .foregroundColor(.accentColor) - .font(.system(size: 34, weight: .semibold)) + .font(.system(size: 34, weight: .bold)) case .outsideRecommendedRange: return ( Text(Image(systemName: "exclamationmark.triangle.fill")) @@ -87,7 +94,7 @@ struct EditPresetView: View { .foregroundColor(color) + Text(text) .foregroundColor(color) - .font(.system(size: 34, weight: .semibold)) + .font(.system(size: 34, weight: .bold)) ) } } @@ -115,7 +122,7 @@ struct EditPresetView: View { Spacer() VStack(alignment: .center) { Text("\(Int((1.0 / (preset.insulinSensitivityMultiplier ?? 1)) * 100))%") - .font(.system(size: 48, weight: .semibold)) + .font(.system(size: 34, weight: .semibold)) .foregroundColor(.accentColor) Text("of scheduled") .foregroundColor(.primary) @@ -189,52 +196,39 @@ struct EditPresetView: View { } } - CompactSection() { - Button(action: { - showingPicker = true - }) { - HStack { - Text("Duration") - .foregroundColor(.primary) - Spacer() - Text(preset.duration.localizedTitle) - .foregroundColor(.secondary) - if preset.canAdjustDuration { - Image(systemName: "chevron.right") - .foregroundColor(.gray) + CompactSection( + content: { + Button(action: { + showingPicker = true + }) { + HStack { + Text("Duration") + .foregroundColor(.primary) + Spacer() + Text(preset.duration.localizedTitle) + .foregroundColor(.secondary) + if preset.canAdjustDuration { + Image(systemName: "chevron.right") + .foregroundColor(.gray) + } } - } - .background(Color(.systemBackground)) - }.disabled(!preset.canAdjustDuration) - } - - CompactSection {} header: { - Button("Save Preset") { - do { - try onSave(preset) - } catch { - print(error) - } - dismiss() - } - .disabled(preset == originalPreset) - .buttonStyle(ActionButtonStyle(.primary)) - .textCase(nil) - } + }.disabled(!preset.canAdjustDuration) + }, + footerText: preset.canAdjustDuration ? nil : "Duration and Name not configurable for this preset.") } .listSectionSpacing(16) } - .navigationBarBackButtonHidden(true) - .navigationBarItems( - trailing: Button("Cancel") { - dismiss() - } - .foregroundColor(.blue) - ) .sheet(isPresented: $showingPicker) { DurationPickerView(durationType: $preset.duration) .presentationDetents([.height(300)]) } + .onChange(of: preset, { + do { + try onSave(preset) + } catch { + print(error) + } + }) } var presetTitle: some View { @@ -242,18 +236,18 @@ struct EditPresetView: View { switch preset.icon { case .emoji(let emoji): Text(emoji) - .font(.system(size: 48, weight: .semibold)) + .font(.system(size: 34, weight: .semibold)) .foregroundColor(.primary) case .image(let name, let iconColor): Image(name) .resizable() .aspectRatio(contentMode: .fit) .foregroundColor(iconColor) - .frame(width: UIFontMetrics.default.scaledValue(for: 48), height: UIFontMetrics.default.scaledValue(for: 48)) + .frame(width: UIFontMetrics.default.scaledValue(for: 34), height: UIFontMetrics.default.scaledValue(for: 34)) } Text(presetName) - .font(.system(size: 48, weight: .semibold)) + .font(.system(size: 34, weight: .semibold)) .foregroundColor(.primary) } } diff --git a/WatchApp/DerivedAssets.xcassets/Contents.json b/WatchApp/DerivedAssets.xcassets/Contents.json index da4a164c91..73c00596a7 100644 --- a/WatchApp/DerivedAssets.xcassets/Contents.json +++ b/WatchApp/DerivedAssets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} From db151ebe1abefb1f49975117878fcbea832066b3 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 19 Feb 2025 15:28:48 -0400 Subject: [PATCH 218/421] [LOOP-5167] cannot convert .infinity to string (#760) --- Loop/Views/Presets/PresetsHistoryView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Loop/Views/Presets/PresetsHistoryView.swift b/Loop/Views/Presets/PresetsHistoryView.swift index 63958e767a..73cebef132 100644 --- a/Loop/Views/Presets/PresetsHistoryView.swift +++ b/Loop/Views/Presets/PresetsHistoryView.swift @@ -100,7 +100,10 @@ struct PresetsHistoryView: View { } } case .indefinite: - if let durationString = formatter.string(from: override.actualDuration.timeInterval) { + let actualDuration = override.actualDuration.timeInterval + if actualDuration != .infinity, + let durationString = formatter.string(from: actualDuration) + { Text(durationString) .foregroundStyle(.primary) .fontWeight(.semibold) From 4c8837548f940f9885ed54c753cf20e5015756d5 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 20 Feb 2025 13:08:23 -0400 Subject: [PATCH 219/421] [LOOP-5271] display both warning and critical icons (#761) --- Loop/Views/Presets/Components/PresetStatsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift index e4038218fe..b11ff93304 100644 --- a/Loop/Views/Presets/Components/PresetStatsView.swift +++ b/Loop/Views/Presets/Components/PresetStatsView.swift @@ -98,7 +98,7 @@ struct PresetStatsView: View { case (.withinRecommendedRange, .outsideRecommendedRange): lower + Text(" - ") + warningSymbol.foregroundStyle(upperColor) + upper + units case (.outsideRecommendedRange, .outsideRecommendedRange): - warningSymbol.foregroundStyle(lowerColor) + lower + Text("-").foregroundStyle(lowerColor) + upper + units + warningSymbol.foregroundStyle(lowerColor) + lower + Text("-").foregroundStyle(lowerColor) + warningSymbol.foregroundStyle(upperColor) + upper + units case (.outsideRecommendedRange, .withinRecommendedRange): warningSymbol.foregroundStyle(lowerColor) + lower + Text("-") + upper + units } From 322acea96f1027db97252941702dbcb033e999a1 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 21 Feb 2025 15:52:06 -0600 Subject: [PATCH 220/421] Add guardrail excursion caption for correction range to preset edit view (#762) --- Loop/Views/Presets/EditPresetView.swift | 92 ++++++++++++++++++------- 1 file changed, 67 insertions(+), 25 deletions(-) diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 4be266325c..c8ad3ad1d2 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -69,6 +69,7 @@ struct EditPresetView: View { private var onSave: (SelectablePreset) throws -> Void @State private var showingPicker = false + @State private var navigateToCorrectionRangeEditor = false init(preset: SelectablePreset, scheduledRange: ClosedRange, onSave: @escaping ((SelectablePreset) throws -> Void)) { self.preset = preset @@ -88,10 +89,6 @@ struct EditPresetView: View { .font(.system(size: 34, weight: .bold)) case .outsideRecommendedRange: return ( - Text(Image(systemName: "exclamationmark.triangle.fill")) - .font(.system(size: 23, weight: .regular)) - .baselineOffset(3.0) - .foregroundColor(color) + Text(text) .foregroundColor(color) .font(.system(size: 34, weight: .bold)) @@ -109,7 +106,7 @@ struct EditPresetView: View { Text(displayGlucosePreference.unit.localizedShortUnitString) .font(.system(.body)) .foregroundColor(.secondary) - .baselineOffset(5) + .baselineOffset(12) } var sensitivitySection: some View { @@ -142,38 +139,83 @@ struct EditPresetView: View { } } + private var correctionRangeCrossedThresholds: [SafetyClassification.Threshold] { + guard let range = preset.correctionRange else { return [] } + + let guardrail = preset.guardrail + let thresholds: [SafetyClassification.Threshold] = [range.lowerBound, range.upperBound].compactMap { bound in + switch guardrail.classification(for: bound) { + case .withinRecommendedRange: + return nil + case .outsideRecommendedRange(let threshold): + return threshold + } + } + + return thresholds + } + + private var guardrailWarningIfNecessary: some View { + let crossedThresholds = self.correctionRangeCrossedThresholds + let severity = crossedThresholds.map { $0.severity }.max() + + return Group { + if let severity, !crossedThresholds.isEmpty { + let color = severity > .default ? Color.red : .orange + HStack(alignment: .top, spacing: 12) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + .foregroundColor(color) + Text(SafetyClassification.captionForCrossedThresholds(crossedThresholds, isRange: true)); + } + .padding(12) + .background(color.opacity(0.1)) + .cornerRadius(12) + } + } + } + + var correctionSection: some View { CompactSection { - NavigationLink { - EditPresetRangeView( - range: $preset.correctionRange, - guardrail: preset.guardrail, - scheduledRange: scheduledRange - ) + Button { + navigateToCorrectionRangeEditor = true; } label: { - VStack(alignment: .leading, spacing: 8) { - Text("Correction Range") - .font(.system(.title3, weight: .semibold)) + VStack(alignment: .center, spacing: 12) { HStack { + Text("Correction Range") + .font(.system(size: 17, weight: .semibold)) Spacer() - VStack(alignment: .center) { - if let range = preset.correctionRange { - correctionRangeLabel(range: range) - Text("Adjusted Range") - .foregroundColor(.primary) - } else { - correctionRangeLabel(range: scheduledRange) - Text("Scheduled Range") - .foregroundColor(.primary) - } + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + }.padding(.bottom, 10) + VStack(spacing: 4) { + if let range = preset.correctionRange { + correctionRangeLabel(range: range) + Text("Adjusted Range") + } else { + correctionRangeLabel(range: scheduledRange) + Text("Scheduled Range") } - Spacer() } + guardrailWarningIfNecessary + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.primary) + .padding(.bottom, 5) + .padding(.horizontal, 2) } + .foregroundColor(.primary) } } + .navigationDestination(isPresented: $navigateToCorrectionRangeEditor) { + EditPresetRangeView( + range: $preset.correctionRange, + guardrail: preset.guardrail, + scheduledRange: scheduledRange + ) + } } + var body: some View { VStack(alignment: .leading, spacing: 0) { Form { From 536d873028c549f75773c5d1ca04c56831f56d71 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 26 Feb 2025 09:05:04 -0400 Subject: [PATCH 221/421] ensure all copy displays (#763) --- .../Presets/Training Content/PresetsAndIllnessContentView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift b/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift index d51c474e14..77d5f8883b 100644 --- a/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift +++ b/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift @@ -70,6 +70,7 @@ struct PresetsAndIllnessContentView: View { if let string = try? AttributedString(markdown: String(format: NSLocalizedString("Paloma wants to tell the %1$@ system that she needs more insulin than usual since her glucose has been elevated. She will adjust her overall insulin **above** her scheduled delivery.", comment: "Presets and illness training content, overall insulin, paragraph 1"), appName)) { Text(string) + .fixedSize(horizontal: false, vertical: true) } } @@ -107,6 +108,7 @@ struct PresetsAndIllnessContentView: View { .font(.title2.bold()) Text(String(format: NSLocalizedString("Paloma will set her preset duration to “Until I Turn Off” since she is not sure when her illness will pass. %1$@ will remind her every 8 hours that the preset is running. ", comment: "Presets and illness training content, duration, paragraph 1"), appName)) + .fixedSize(horizontal: false, vertical: true) } } From 183a0dd40a194d02ddaca413158aa07b3778add6 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 3 Mar 2025 15:54:12 -0600 Subject: [PATCH 222/421] Fix warning icon color on preset edit page (#764) --- Loop/Views/Presets/EditPresetView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index c8ad3ad1d2..744dd8a897 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -161,7 +161,7 @@ struct EditPresetView: View { return Group { if let severity, !crossedThresholds.isEmpty { - let color = severity > .default ? Color.red : .orange + let color: Color = severity > .default ? guidanceColors.critical : guidanceColors.warning HStack(alignment: .top, spacing: 12) { Text(Image(systemName: "exclamationmark.triangle.fill")) .foregroundColor(color) From 4fc38187723d619367e794692cb5d38be537b6d0 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 7 Mar 2025 16:54:36 -0400 Subject: [PATCH 223/421] [LOOP-5281] update to basal state display in pump pill (#766) --- LoopUI/StatusBarHUDView.xib | 37 ++---- LoopUI/Views/BasalRateHUDView.swift | 15 ++- LoopUI/Views/BasalStateView.swift | 187 ++++++++++++++++++---------- 3 files changed, 145 insertions(+), 94 deletions(-) diff --git a/LoopUI/StatusBarHUDView.xib b/LoopUI/StatusBarHUDView.xib index f603d7cf91..539cc0d962 100644 --- a/LoopUI/StatusBarHUDView.xib +++ b/LoopUI/StatusBarHUDView.xib @@ -1,9 +1,8 @@ - + - - + @@ -35,13 +34,13 @@ diff --git a/LoopUI/Views/BasalRateHUDView.swift b/LoopUI/Views/BasalRateHUDView.swift index d8992f0169..8608081f0a 100644 --- a/LoopUI/Views/BasalRateHUDView.swift +++ b/LoopUI/Views/BasalRateHUDView.swift @@ -28,6 +28,7 @@ public final class BasalRateHUDView: BaseHUDView { public override func tintColorDidChange() { super.tintColorDidChange() + basalStateView.tintColor = tintColor } private lazy var basalRateFormatString = LocalizedString("%@ U", comment: "The format string describing the basal rate.") @@ -43,8 +44,18 @@ public final class BasalRateHUDView: BaseHUDView { basalRateLabel?.text = nil accessibilityValue = nil } - - basalStateView.netBasalPercent = percent + + switch percent { + // still needs to handle manual temp basal + case let x where x == -1.0: + basalStateView.basalDisplayState = .basalTempAutoNoDelivery + case let x where x < 0.0: + basalStateView.basalDisplayState = .basalTempAutoBelow + case let x where x > 0.0: + basalStateView.basalDisplayState = .basalTempAutoAbove + default: + basalStateView.basalDisplayState = .basalScheduled + } } private lazy var decimalFormatter: NumberFormatter = { diff --git a/LoopUI/Views/BasalStateView.swift b/LoopUI/Views/BasalStateView.swift index 15d51c7bf5..6e941f9f2e 100644 --- a/LoopUI/Views/BasalStateView.swift +++ b/LoopUI/Views/BasalStateView.swift @@ -6,88 +6,139 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // -import UIKit - - -public final class BasalStateView: UIView { - - var netBasalPercent: Double = 0 { - didSet { - animateToPath(drawPath()) +import SwiftUI +import LoopKitUI + +class WrappedBasalRateViewModel: ObservableObject { + + private lazy var basalRateUnitString = LocalizedString("U/hr", comment: "The format string describing the basal rate unit.") + private lazy var basalRateFormatString = "%1$d %2$@" + + @Published var basalDisplayState: BasalDisplayState + @Published var tintColor: Color + + var basalStateImageName: String? { + basalDisplayState.imageName + } + var manualTempBasalAmount: Double? { + switch basalDisplayState { + case .basalTempManual(let double): + return double + default: + return nil } } - - override public class var layerClass : AnyClass { - return CAShapeLayer.self + var manualTempBasalAmountString: String? { + guard let manualTempBasalAmount = manualTempBasalAmount else { return nil } + return "\(manualTempBasalAmount)" } - - private var shapeLayer: CAShapeLayer { - return layer as! CAShapeLayer + var basalStateCaptionString: String? { + switch basalDisplayState { + case .basalTempManual: return basalRateUnitString + case .basalTempAutoNoDelivery: return String(format: basalRateFormatString, 0, basalRateUnitString) + default: return nil + } } - - override init(frame: CGRect) { - super.init(frame: frame) - - shapeLayer.lineWidth = 2 - updateTintColor() + var isBasalTempManual: Bool { + switch basalDisplayState { + case .basalTempManual: return true + default: return false + } } - - required public init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - - shapeLayer.lineWidth = 2 - updateTintColor() + + init(basalDisplayState: BasalDisplayState = .basalScheduled, + tintColor: Color = .insulinTintColor + ) { + self.basalDisplayState = basalDisplayState + self.tintColor = tintColor } +} - override public func layoutSubviews() { - super.layoutSubviews() +struct WrappedBasalRateView: View { + + @StateObject var viewModel: WrappedBasalRateViewModel + + var body: some View { + VStack { + if let basalStateImageName = viewModel.basalStateImageName { + Image(systemName: basalStateImageName) + .font(.title) + .foregroundStyle(viewModel.tintColor) + } + if let manualTempBasalAmountString = viewModel.manualTempBasalAmountString { + Text(manualTempBasalAmountString) + .font(.system(size: 24)) + .fontWeight(.heavy) + .bold() + .fixedSize(horizontal: true, vertical: false) + .foregroundStyle(viewModel.tintColor) + } + if let basalStateCaptionString = viewModel.basalStateCaptionString { + Text(basalStateCaptionString) + .font(.caption2) + .foregroundStyle(viewModel.isBasalTempManual ? .secondary : .primary) + } + } + .animation(.default, value: viewModel.basalDisplayState) } +} - public override func tintColorDidChange() { - super.tintColorDidChange() - updateTintColor() +class BasalRateHostingController: UIHostingController { + init(viewModel: WrappedBasalRateViewModel) { + super.init( + rootView: WrappedBasalRateView( + viewModel: viewModel + ) + ) } - - private func updateTintColor() { - shapeLayer.fillColor = tintColor.withAlphaComponent(0.5).cgColor - shapeLayer.strokeColor = tintColor.cgColor + + required init?(coder aDecoder: NSCoder) { + fatalError() } +} - private func drawPath() -> CGPath { - let startX = bounds.minX - let endX = bounds.maxX - let midY = bounds.midY - - let path = UIBezierPath() - path.move(to: CGPoint(x: startX, y: midY)) - - let leftAnchor = startX + 1/6 * bounds.size.width - let rightAnchor = startX + 5/6 * bounds.size.width - - let yAnchor = bounds.midY - CGFloat(netBasalPercent) * (bounds.size.height - shapeLayer.lineWidth) / 2 - - path.addLine(to: CGPoint(x: leftAnchor, y: midY)) - path.addLine(to: CGPoint(x: leftAnchor, y: yAnchor)) - path.addLine(to: CGPoint(x: rightAnchor, y: yAnchor)) - path.addLine(to: CGPoint(x: rightAnchor, y: midY)) - path.addLine(to: CGPoint(x: endX, y: midY)) - return path.cgPath +public final class BasalStateView: UIView { + + override init(frame: CGRect) { + super.init(frame: frame) + + setupViews() } - - private static let AnimationKey = "com.loudnate.Naterade.shapePathAnimation" - - private func animateToPath(_ path: CGPath) { - if shapeLayer.path != nil { - let animation = CABasicAnimation(keyPath: "path") - animation.fromValue = shapeLayer.path ?? drawPath() - animation.toValue = path - animation.duration = 1 - animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - - shapeLayer.add(animation, forKey: type(of: self).AnimationKey) + + required init?(coder: NSCoder) { + super.init(coder: coder) + + setupViews() + } + + var basalDisplayState: BasalDisplayState = .basalScheduled { + didSet { + viewModel.basalDisplayState = basalDisplayState } - - shapeLayer.path = path + } + + public override func tintColorDidChange() { + super.tintColorDidChange() + viewModel.tintColor = Color(uiColor: tintColor) + } + + private let viewModel = WrappedBasalRateViewModel() + + private func setupViews() { + let hostingController = BasalRateHostingController(viewModel: viewModel) + + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + hostingController.view.frame = CGRect(x: 0, y: 0, width: frame.size.width, height: frame.size.height) + + addSubview(hostingController.view) + + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) } } From 51a84842886ef0bf79eed92d9b2c05a9af05c991 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 11 Mar 2025 12:16:04 -0500 Subject: [PATCH 224/421] Update tip text for pre-meal range (#765) --- Loop/View Models/PresetsViewModel.swift | 7 +++++ Loop/Views/Presets/EditPresetRangeView.swift | 29 +++++++++++++++----- Loop/Views/Presets/EditPresetView.swift | 3 +- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift index ca7a167d70..a9df3bdf58 100644 --- a/Loop/View Models/PresetsViewModel.swift +++ b/Loop/View Models/PresetsViewModel.swift @@ -219,6 +219,13 @@ enum SelectablePreset: Hashable, Identifiable { } } + var isPreMeal: Bool { + if case .preMeal = self { + return true + } + return false + } + var guardrail: Guardrail { switch self { case .custom: diff --git a/Loop/Views/Presets/EditPresetRangeView.swift b/Loop/Views/Presets/EditPresetRangeView.swift index 7c4dd3e1c5..1953793356 100644 --- a/Loop/Views/Presets/EditPresetRangeView.swift +++ b/Loop/Views/Presets/EditPresetRangeView.swift @@ -19,11 +19,13 @@ struct EditPresetRangeView: View { var guardrail: Guardrail private var scheduledRange: ClosedRange @State private var editedRange: ClosedRange? + private var isPreMeal: Bool - init(range: Binding?>, guardrail: Guardrail, scheduledRange: ClosedRange) { + init(range: Binding?>, guardrail: Guardrail, scheduledRange: ClosedRange, isPreMeal: Bool) { self._range = range self.guardrail = guardrail self.scheduledRange = scheduledRange + self.isPreMeal = isPreMeal } var displayedRange: ClosedRange { @@ -109,12 +111,7 @@ struct EditPresetRangeView: View { Image(systemName: "info.circle") .foregroundColor(.accentColor) - (Text("To help avoid lows, set a range ") - + Text("higher") - .italic() - .bold() - + Text(" than your typical correction range.")) - .font(.system(size: 14)) + tipText.font(.system(size: 14)) } .padding() .overlay( /// apply a rounded border @@ -134,6 +131,24 @@ struct EditPresetRangeView: View { .edgesIgnoringSafeArea(.bottom) } + private var tipText: some View { + Group { + if isPreMeal { + Text("To help avoid post-meal highs, set a range ") + + Text("lower") + .italic() + .bold() + + Text(" than your typical correction range.") + } else { + Text("To help avoid lows, set a range ") + + Text("higher") + .italic() + .bold() + + Text(" than your typical correction range.") + } + } + } + private var cancelButton: some View { Group { if editedRange != nil { diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 744dd8a897..284f068fbc 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -210,7 +210,8 @@ struct EditPresetView: View { EditPresetRangeView( range: $preset.correctionRange, guardrail: preset.guardrail, - scheduledRange: scheduledRange + scheduledRange: scheduledRange, + isPreMeal: preset.isPreMeal ) } } From 7195b5d88b5bd4a6ed2a3ae291fc0a0689bfda7e Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 12 Mar 2025 11:09:33 -0700 Subject: [PATCH 225/421] [LOOP-5280] Preset Impact View in Sheet Detent (#768) --- Loop/View Models/PresetsViewModel.swift | 25 +++- Loop/View Models/SettingsViewModel.swift | 11 +- .../Views/Presets/Components/PresetCard.swift | 3 +- .../Presets/Components/PresetDetentView.swift | 3 +- .../Presets/Components/PresetStatsView.swift | 133 ++++++++++++++++-- 5 files changed, 153 insertions(+), 22 deletions(-) diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift index a9df3bdf58..e2d0a87a34 100644 --- a/Loop/View Models/PresetsViewModel.swift +++ b/Loop/View Models/PresetsViewModel.swift @@ -388,6 +388,8 @@ public class PresetsViewModel { } var presetWasEdited: ((SelectablePreset) throws -> Void)?; + + var impactForPreset: (SelectablePreset) -> TherapySettings.InsulinMultiplierImpact init( customPresets: [TemporaryScheduleOverridePreset], @@ -398,7 +400,8 @@ public class PresetsViewModel { preMealGuardrail: Guardrail, legacyWorkoutGuardrail: Guardrail, temporaryPresetsManager: TemporaryPresetsManager, - scheduledRange: ClosedRange + scheduledRange: ClosedRange, + impactForPreset: @escaping (SelectablePreset) -> TherapySettings.InsulinMultiplierImpact ) { self.customPresets = customPresets self.premealRange = premealRange @@ -409,6 +412,26 @@ public class PresetsViewModel { self.legacyWorkoutGuardrail = legacyWorkoutGuardrail self.temporaryPresetsManager = temporaryPresetsManager self.scheduledRange = scheduledRange + self.impactForPreset = impactForPreset + } + + convenience init( + therapySettings: TherapySettings, + temporaryPresetsManager: TemporaryPresetsManager, + presetsHistory: TemporaryScheduleOverrideHistory + ) { + self.init( + customPresets: therapySettings.overridePresets ?? [], + premealRange: therapySettings.correctionRangeOverrides?.preMeal, + workoutRange: therapySettings.correctionRangeOverrides?.workout, + workoutDuration: therapySettings.correctionRangeOverrides?.workoutDuration ?? .indefinite, + presetsHistory: presetsHistory, + preMealGuardrail: therapySettings.preMealGuardrail, + legacyWorkoutGuardrail: therapySettings.legacyWorkoutPresetGuardrail, + temporaryPresetsManager: temporaryPresetsManager, + scheduledRange: therapySettings.glucoseTargetRangeSchedule!.quantityRange(at: Date()), + impactForPreset: { therapySettings.impact(for: Double( 1 / ($0.insulinSensitivityMultiplier ?? 1))) } + ) } func savePreset(_ preset: SelectablePreset) { diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 309fcad47b..e626d5e249 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -176,17 +176,10 @@ class SettingsViewModel { self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate self.presetHistory = presetHistory - self.presetsViewModel = PresetsViewModel( - customPresets: therapySettings().overridePresets ?? [], - premealRange: therapySettings().correctionRangeOverrides?.preMeal, - workoutRange:therapySettings().correctionRangeOverrides?.workout, - workoutDuration: therapySettings().correctionRangeOverrides?.workoutDuration ?? .indefinite, - presetsHistory: presetHistory, - preMealGuardrail: therapySettings().preMealGuardrail, - legacyWorkoutGuardrail: therapySettings().legacyWorkoutPresetGuardrail, + therapySettings: therapySettings(), temporaryPresetsManager: temporaryPresetsManager, - scheduledRange: therapySettings().glucoseTargetRangeSchedule!.quantityRange(at: Date()) + presetsHistory: presetHistory ) self.presetsViewModel.presetWasEdited = savePreset diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index 2836be779a..d122418aea 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -97,7 +97,8 @@ struct PresetCard: View { PresetStatsView( insulinSensitivityMultiplier: insulinSensitivityMultiplier, correctionRange: correctionRange, - guardrail: guardrail + guardrail: guardrail, + therapySettingsImpactDisplayState: .hide ) } .padding(10) diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 5c8657dc39..432e30b7d4 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -142,7 +142,8 @@ struct PresetDetentView: View { PresetStatsView( insulinSensitivityMultiplier: preset.insulinSensitivityMultiplier, correctionRange: preset.correctionRange, - guardrail: preset.guardrail + guardrail: preset.guardrail, + therapySettingsImpactDisplayState: operation == .end ? .show(viewModel.impactForPreset(preset)) : .hide ) actionArea diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift index b11ff93304..55ab3ecce0 100644 --- a/Loop/Views/Presets/Components/PresetStatsView.swift +++ b/Loop/Views/Presets/Components/PresetStatsView.swift @@ -12,12 +12,19 @@ import LoopKitUI import SwiftUI struct PresetStatsView: View { + + enum TherapySettingsImpactDisplayState { + case hide + case show(TherapySettings.InsulinMultiplierImpact) + } + @Environment(\.guidanceColors) private var guidanceColors @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference let insulinSensitivityMultiplier: Double? let correctionRange: ClosedRange? let guardrail: Guardrail? + let therapySettingsImpactDisplayState: TherapySettingsImpactDisplayState private var numberFormatter: NumberFormatter { let formatter = NumberFormatter() @@ -126,21 +133,127 @@ struct PresetStatsView: View { .accessibilityElement(children: .contain) } + @ViewBuilder + func basalRateView(basalRateValue: String, condensed: Bool) -> some View { + let label = Text("Basal Rate").font(.subheadline) + let value = Group { + Text(basalRateValue).bold() + + Text(" \(LoopUnit.internationalUnitsPerHour.unitString)") + }.font(.subheadline) + + if condensed { + HStack { + label + Text(": ") + value + } + } else { + VStack(alignment: .leading, spacing: 4) { + value + label + } + } + } + + @ViewBuilder + func carbRatioView(carbRatioValue: String, condensed: Bool) -> some View { + let label = Text("Carb Ratio").font(.subheadline) + let value = Group { + Text(carbRatioValue).bold() + + Text(" \(LoopUnit.gram.unitString)") + }.font(.subheadline) + + if condensed { + HStack { + label + Text(": ") + value + } + } else { + VStack(alignment: .leading, spacing: 4) { + value + label + } + } + } + + @ViewBuilder + func isfView(isfValue: String, condensed: Bool) -> some View { + let label = Text("ISF").font(.subheadline) + let value = Group { + Text(isfValue).bold() + + Text(" \(displayGlucosePreference.unit.unitString)") + }.font(.subheadline) + + if condensed { + HStack { + label + Text(": ") + value + } + } else { + VStack(alignment: .leading, spacing: 4) { + value + label + } + } + } + + private let basalRateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) + private let carbRatioFormatter = QuantityFormatter(for: .gram) + var body: some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 0) { - overallInsulinView - - Spacer() + VStack(alignment: .leading, spacing: 24) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 0) { + overallInsulinView + Spacer() + correctionRangeView + } - correctionRangeView + VStack(alignment: .leading, spacing: 16) { + overallInsulinView + correctionRangeView + } } - VStack(alignment: .leading, spacing: 16) { - overallInsulinView - - correctionRangeView + if case let .show(insulinMultiplierImpact) = therapySettingsImpactDisplayState, (insulinSensitivityMultiplier ?? 1) != 1 { + VStack(alignment: .leading, spacing: 8) { + Text("Settings Impact") + .font(.subheadline) + .foregroundColor(.secondary) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 0) { + if let basalRate = insulinMultiplierImpact.basalRate, let basalRateValue = basalRateFormatter.string(from: basalRate, includeUnit: false) { + basalRateView(basalRateValue: basalRateValue, condensed: false) + Spacer() + } + + if let carbRatio = insulinMultiplierImpact.carbRatio, let carbRatioValue = carbRatioFormatter.string(from: carbRatio, includeUnit: false) { + carbRatioView(carbRatioValue: carbRatioValue, condensed: false) + Spacer() + } + + if let isf = insulinMultiplierImpact.isf, let isfValue = displayGlucosePreference.formatter.string(from: isf, includeUnit: false) { + isfView(isfValue: isfValue, condensed: false) + } + } + + VStack(alignment: .leading, spacing: 8) { + if let basalRate = insulinMultiplierImpact.basalRate, let basalRateValue = basalRateFormatter.string(from: basalRate, includeUnit: false) { + basalRateView(basalRateValue: basalRateValue, condensed: true) + } + + if let carbRatio = insulinMultiplierImpact.carbRatio, let carbRatioValue = carbRatioFormatter.string(from: carbRatio, includeUnit: false) { + carbRatioView(carbRatioValue: carbRatioValue, condensed: true) + } + + if let isf = insulinMultiplierImpact.isf, let isfValue = displayGlucosePreference.formatter.string(from: isf, includeUnit: false) { + isfView(isfValue: isfValue, condensed: true) + } + } + } + } } } + .frame(maxWidth: .infinity, alignment: .leading) } } From 00f06f9f50182058897b9a85a78c271610a35721 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 12 Mar 2025 11:17:22 -0700 Subject: [PATCH 226/421] [LOOP-5259] Homescreen Charts Update (#767) --- Loop/Base.lproj/Main.storyboard | 99 +++++++------ Loop/Managers/LoopDataManager.swift | 4 +- Loop/Managers/StatusChartsManager.swift | 18 +-- .../PredictionTableViewController.swift | 19 ++- .../StatusTableViewController.swift | 137 +++++++++++++----- .../FavoriteFoodInsightsViewModel.swift | 2 +- Loop/Views/Charts/DoseChartView.swift | 2 +- 7 files changed, 188 insertions(+), 93 deletions(-) diff --git a/Loop/Base.lproj/Main.storyboard b/Loop/Base.lproj/Main.storyboard index dac14ecfb4..ae893d031f 100644 --- a/Loop/Base.lproj/Main.storyboard +++ b/Loop/Base.lproj/Main.storyboard @@ -1,9 +1,8 @@ - + - - + @@ -415,7 +414,7 @@ - + @@ -509,13 +508,13 @@ diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 71850b16de..4519ce0cd3 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1264,7 +1264,7 @@ extension LoopDataManager: FavoriteFoodInsightsViewModelDelegate { 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 @@ -1301,7 +1301,7 @@ extension LoopDataManager: FavoriteFoodInsightsViewModelDelegate { 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 annotatedDoses = doses.map({ $0.simpleDose(with: insulinModel(for: $0.insulinType)) }).annotated(with: basalWithOverrides) let insulinEffects = annotatedDoses.glucoseEffects( insulinSensitivityHistory: sensitivityWithOverrides, diff --git a/Loop/Managers/StatusChartsManager.swift b/Loop/Managers/StatusChartsManager.swift index f047a27575..b72a4fd031 100644 --- a/Loop/Managers/StatusChartsManager.swift +++ b/Loop/Managers/StatusChartsManager.swift @@ -16,35 +16,35 @@ import LoopAlgorithm class StatusChartsManager: ChartsManager { enum ChartIndex: Int, CaseIterable { case glucose - case iob case dose + case iob case cob } let glucose: PredictedGlucoseChart - let iob: IOBChart let dose: DoseChart + let iob: IOBChart let cob: COBChart init(colors: ChartColorPalette, settings: ChartSettings, traitCollection: UITraitCollection) { let glucose = PredictedGlucoseChart(predictedGlucoseBounds: FeatureFlags.predictedGlucoseChartClampEnabled ? .default : nil, yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil) - let iob = IOBChart() let dose = DoseChart() + let iob = IOBChart() let cob = COBChart() self.glucose = glucose - self.iob = iob self.dose = dose + self.iob = iob self.cob = cob super.init(colors: colors, settings: settings, charts: ChartIndex.allCases.map({ (index) -> ChartProviding in switch index { case .glucose: return glucose - case .iob: - return iob case .dose: return dose + case .iob: + return iob case .cob: return cob } @@ -109,14 +109,14 @@ extension StatusChartsManager { invalidateChart(atIndex: ChartIndex.iob.rawValue) } - func iobChart(withFrame frame: CGRect) -> Chart? { - return chart(atIndex: ChartIndex.iob.rawValue, frame: frame) + func iobChart(withFrame frame: CGRect, highlightLabelOffsetY: CGFloat) -> Chart? { + return chart(atIndex: ChartIndex.iob.rawValue, frame: frame, highlightLabelOffsetY: highlightLabelOffsetY) } } extension StatusChartsManager { - func setDoseEntries(_ doseEntries: [BasalRelativeDose]) { + func setDoseEntries(_ doseEntries: [DoseEntry]) { dose.doseEntries = doseEntries invalidateChart(atIndex: ChartIndex.dose.rawValue) } diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index ffaffe0070..948f956cb0 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -138,7 +138,14 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable } if let lastPoint = self.glucoseChart.alternatePredictedGlucosePoints?.last?.y { - self.eventualGlucoseDescription = String(describing: lastPoint) + let valueAttributedString = NSMutableAttributedString(string: String(describing: lastPoint.copy), attributes: [.font: UIFont.systemFont(ofSize: 22, weight: .semibold), .foregroundColor: ChartColorPalette.primary.glucoseTint]) + let spacer = NSAttributedString(string: "\u{00a0}") + let unitAttributedString = NSAttributedString(string: String(describing: lastPoint).replacingOccurrences(of: String(describing: lastPoint.copy), with: "").trimmingCharacters(in: .whitespacesAndNewlines), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular), .foregroundColor: ChartColorPalette.primary.glucoseTint]) + + valueAttributedString.append(spacer) + valueAttributedString.append(unitAttributedString) + + self.eventualGlucoseDescription = valueAttributedString } else { self.eventualGlucoseDescription = nil } @@ -185,7 +192,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable case inputs } - private var eventualGlucoseDescription: String? + private var eventualGlucoseDescription: NSAttributedString? // 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 @@ -235,7 +242,13 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable } if let eventualGlucose = eventualGlucoseDescription { - cell.setTitleLabelText(label: String(format: NSLocalizedString("Eventually %@", comment: "The subtitle format describing eventual glucose. (1: localized glucose value description)"), eventualGlucose)) + let title = NSMutableAttributedString(string: NSLocalizedString("Eventually", comment: ""), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular)]) + let spacer = NSAttributedString(string: "\u{00a0}") + + title.append(spacer) + title.append(eventualGlucose) + + cell.setTitleLabelText(label: title) } else { cell.setTitleLabelText(label: SettingsTableViewCell.NoValueString) } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index d1490c8c3e..9929ee02c5 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -425,6 +425,8 @@ final class StatusTableViewController: LoopChartsTableViewController { charts.maxEndDate = chartStartDate.addingTimeInterval(.hours(totalHours)) charts.updateEndDate(charts.maxEndDate) } + + private var lastDoseEntry: DoseEntry? override func reloadData(animated: Bool = false) async { dispatchPrecondition(condition: .onQueue(.main)) @@ -464,7 +466,7 @@ final class StatusTableViewController: LoopChartsTableViewController { var glucoseSamples: [StoredGlucoseSample]? var predictedGlucoseValues: [GlucoseValue]? var iobValues: [InsulinValue]? - var doseEntries: [BasalRelativeDose]? + var doseEntries: [DoseEntry]? var totalDelivery: Double? var cobValues: [CarbValue]? var carbsOnBoard: LoopQuantity? @@ -515,7 +517,8 @@ final class StatusTableViewController: LoopChartsTableViewController { } if currentContext.contains(.insulin) { - doseEntries = loopManager.dosesRelativeToBasal.trimmed(from: startDate) + doseEntries = try? await loopManager.doseStore.getNormalizedDoseEntries(start: startDate, end: nil) + lastDoseEntry = try? await loopManager.doseStore.getNormalizedDoseEntries(start: Date().addingTimeInterval(.days(-1)), end: nil).filter({ $0.automatic == false }).last iobValues = loopManager.iobValues.filterDateRange(startDate, nil) totalDelivery = await loopManager.totalDeliveredToday()?.value @@ -535,7 +538,14 @@ final class StatusTableViewController: LoopChartsTableViewController { if !FeatureFlags.predictedGlucoseChartClampEnabled, let lastPoint = self.statusCharts.glucose.predictedGlucosePoints.last?.y { - self.eventualGlucoseDescription = String(describing: lastPoint) + let valueAttributedString = NSMutableAttributedString(string: String(describing: lastPoint.copy), attributes: [.font: UIFont.systemFont(ofSize: 22, weight: .semibold), .foregroundColor: ChartColorPalette.primary.glucoseTint]) + let spacer = NSAttributedString(string: "\u{00a0}") + let unitAttributedString = NSAttributedString(string: String(describing: lastPoint).replacingOccurrences(of: String(describing: lastPoint.copy), with: "").trimmingCharacters(in: .whitespacesAndNewlines), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular), .foregroundColor: ChartColorPalette.primary.glucoseTint]) + + valueAttributedString.append(spacer) + valueAttributedString.append(unitAttributedString) + + self.eventualGlucoseDescription = valueAttributedString } else { // 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 @@ -557,8 +567,15 @@ 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: true) + if let activeInsulin = loopManager.activeInsulin, let valueString = insulinFormatter.string(from: activeInsulin.quantity, includeUnit: false) { + let valueAttributedString = NSMutableAttributedString(string: valueString, attributes: [.font: UIFont.systemFont(ofSize: 22, weight: .semibold), .foregroundColor: ChartColorPalette.primary.insulinTint]) + let spacer = NSAttributedString(string: "\u{00a0}") + let unitAttributedString = NSMutableAttributedString(string: insulinFormatter.localizedUnitStringWithPlurality(forQuantity: activeInsulin.quantity, avoidLineBreaking: true), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular), .foregroundColor: ChartColorPalette.primary.insulinTint]) + + valueAttributedString.append(spacer) + valueAttributedString.append(unitAttributedString) + + self.currentIOBDescription = valueAttributedString } else { self.currentIOBDescription = nil } @@ -576,9 +593,23 @@ final class StatusTableViewController: LoopChartsTableViewController { 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) + let valueAttributedString = NSMutableAttributedString(string: String(describing: charts.cob.cobPoints[index].y.copy), attributes: [.font: UIFont.systemFont(ofSize: 22, weight: .semibold), .foregroundColor: ChartColorPalette.primary.carbTint]) + let spacer = NSAttributedString(string: "\u{00a0}") + let unitAttributedString = NSAttributedString(string: String(describing: charts.cob.cobPoints[index].y).replacingOccurrences(of: String(describing: charts.cob.cobPoints[index].y.copy), with: "").trimmingCharacters(in: .whitespacesAndNewlines), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular), .foregroundColor: ChartColorPalette.primary.carbTint]) + + valueAttributedString.append(spacer) + valueAttributedString.append(unitAttributedString) + + self.currentCOBDescription = valueAttributedString + } else if let carbsOnBoard = carbsOnBoard, let valueString = carbFormatter.string(from: carbsOnBoard, includeUnit: false) { + let valueAttributedString = NSMutableAttributedString(string: valueString, attributes: [.font: UIFont.systemFont(ofSize: 22, weight: .semibold), .foregroundColor: ChartColorPalette.primary.carbTint]) + let spacer = NSAttributedString(string: "\u{00a0}") + let unitAttributedString = NSAttributedString(string: carbFormatter.localizedUnitStringWithPlurality(forQuantity: carbsOnBoard, avoidLineBreaking: true), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular), .foregroundColor: ChartColorPalette.primary.carbTint]) + + valueAttributedString.append(spacer) + valueAttributedString.append(unitAttributedString) + + self.currentCOBDescription = valueAttributedString } else { self.currentCOBDescription = nil } @@ -635,17 +666,16 @@ final class StatusTableViewController: LoopChartsTableViewController { private enum ChartRow: Int, CaseIterable { case glucose case iob - case dose case cob } // MARK: Glucose - private var eventualGlucoseDescription: String? + private var eventualGlucoseDescription: NSAttributedString? // MARK: IOB - private var currentIOBDescription: String? + private var currentIOBDescription: NSAttributedString? // MARK: Dose @@ -653,7 +683,7 @@ final class StatusTableViewController: LoopChartsTableViewController { // MARK: COB - private var currentCOBDescription: String? + private var currentCOBDescription: NSAttributedString? // MARK: - Loop Status Section Data @@ -857,6 +887,9 @@ final class StatusTableViewController: LoopChartsTableViewController { if let indexPath = tableView.indexPath(for: cell) { self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) + if Section(rawValue: indexPath.section)! == .charts && ChartRow(rawValue: indexPath.row)! == .iob { + cell.setFooterView(content: iobFooterViewContent) + } } } tableView.endUpdates() @@ -1020,22 +1053,25 @@ final class StatusTableViewController: LoopChartsTableViewController { return self?.statusCharts.glucoseChart(withFrame: frame)?.view }) cell.setTitleLabelText(label: NSLocalizedString("Glucose", comment: "The title of the glucose and prediction graph")) + cell.setTitleTextColor(color: ChartColorPalette.primary.glucoseTint) cell.doesNavigate = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled case .iob: - cell.setChartGenerator(generator: { [weak self] (frame) in - return self?.statusCharts.iobChart(withFrame: frame)?.view + cell.setSupplementalChartGenerator(generator: { [weak self] (frame) in + return self?.statusCharts.doseChart(withFrame: frame)?.view }) - cell.setTitleLabelText(label: NSLocalizedString("Active Insulin", comment: "The title of the Insulin On-Board graph")) - case .dose: + cell.setChartGenerator(generator: { [weak self] (frame) in - return self?.statusCharts.doseChart(withFrame: frame)?.view + return self?.statusCharts.iobChart(withFrame: frame, highlightLabelOffsetY: cell.supplementalChartContentView.bounds.height)?.view }) - cell.setTitleLabelText(label: NSLocalizedString("Insulin Delivery", comment: "The title of the insulin delivery graph")) + cell.setTitleLabelText(label: NSLocalizedString("Active Insulin", comment: "The title of the Insulin On-Board graph")) + cell.setTitleTextColor(color: ChartColorPalette.primary.insulinTint) + cell.setFooterView(content: iobFooterViewContent) case .cob: cell.setChartGenerator(generator: { [weak self] (frame) in return self?.statusCharts.cobChart(withFrame: frame)?.view }) cell.setTitleLabelText(label: NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph")) + cell.setTitleTextColor(color: ChartColorPalette.primary.carbTint) } self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) @@ -1131,6 +1167,43 @@ final class StatusTableViewController: LoopChartsTableViewController { } } } + + @ViewBuilder + private func iobFooterViewContent() -> some View { + let formatter = QuantityFormatter(for: .internationalUnit) + + if let lastManualDose = lastDoseEntry, let formattedBolusValue = formatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: lastManualDose.deliveredUnits ?? lastManualDose.value)) { + let hoursDifference = Date().timeIntervalSince(lastManualDose.endDate) / 3600 + + let lastBolusLabel = Text("Last Bolus: ") + let lastBolusValue = Text("\(formattedBolusValue) ").fontWeight(.semibold) + let icon = Text(Image(systemName: "hourglass.bottomhalf.filled")).foregroundStyle(.secondary) + let exactTime = Text("at \(lastManualDose.endDate.formatted(date: .omitted, time: .shortened))").foregroundStyle(.secondary) + let roundedTime = Text(" \(Int(hoursDifference.rounded())) hours ago").foregroundStyle(.secondary) + + Group { + switch hoursDifference { + case ..<6: + lastBolusLabel + + lastBolusValue + + exactTime + case 6..<12: + lastBolusLabel + + lastBolusValue + .foregroundStyle(.secondary) + + icon + + roundedTime + default: + lastBolusLabel + + icon + + roundedTime + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 36) + .padding(.vertical) + } + } private func tableView(_ tableView: UITableView, updateSubtitleFor cell: ChartTableViewCell, at indexPath: IndexPath) { switch Section(rawValue: indexPath.section)! { @@ -1138,7 +1211,13 @@ final class StatusTableViewController: LoopChartsTableViewController { switch ChartRow(rawValue: indexPath.row)! { case .glucose: if let eventualGlucose = eventualGlucoseDescription { - cell.setSubtitleLabel(label: String(format: NSLocalizedString("Eventually %@", comment: "The subtitle format describing eventual glucose. (1: localized glucose value description)"), eventualGlucose)) + let subtitle = NSMutableAttributedString(string: NSLocalizedString("Eventually", comment: ""), attributes: [.font: UIFont.systemFont(ofSize: 15, weight: .regular)]) + let spacer = NSAttributedString(string: "\u{00a0}") + + subtitle.append(spacer) + subtitle.append(eventualGlucose) + + cell.setSubtitleLabel(label: subtitle) } else { cell.setSubtitleLabel(label: nil) } @@ -1149,16 +1228,6 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { cell.setSubtitleLabel(label: nil) } - case .dose: - let integerFormatter = NumberFormatter() - integerFormatter.maximumFractionDigits = 0 - - if let total = totalDelivery, - let totalString = integerFormatter.string(from: total) { - cell.setSubtitleLabel(label: String(format: NSLocalizedString("%@ U Total", comment: "The subtitle format describing total insulin. (1: localized insulin total)"), totalString)) - } else { - cell.setSubtitleLabel(label: nil) - } case .cob: if let currentCOB = currentCOBDescription { cell.setSubtitleLabel(label: currentCOB) @@ -1183,9 +1252,11 @@ final class StatusTableViewController: LoopChartsTableViewController { switch ChartRow(rawValue: indexPath.row)! { case .glucose: - return max(106, 0.37 * availableSize) - case .iob, .dose, .cob: - return max(106, 0.21 * availableSize) + return max(106, 0.30 * availableSize) + case .iob: + return max(106, 0.45 * availableSize) + case .cob: + return max(106, 0.25 * availableSize) } case .alertWarning: return UITableView.automaticDimension @@ -1278,7 +1349,7 @@ final class StatusTableViewController: LoopChartsTableViewController { if automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled { performSegue(withIdentifier: PredictionTableViewController.className, sender: indexPath) } - case .iob, .dose: + case .iob: performSegue(withIdentifier: InsulinDeliveryTableViewController.className, sender: indexPath) case .cob: performSegue(withIdentifier: CarbAbsorptionViewController.className, sender: indexPath) diff --git a/Loop/View Models/FavoriteFoodInsightsViewModel.swift b/Loop/View Models/FavoriteFoodInsightsViewModel.swift index d649fe844c..502b3e0915 100644 --- a/Loop/View Models/FavoriteFoodInsightsViewModel.swift +++ b/Loop/View Models/FavoriteFoodInsightsViewModel.swift @@ -87,7 +87,7 @@ class FavoriteFoodInsightsViewModel: ObservableObject { let glucoseChart = GlucoseCarbChart(yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil) glucoseChart.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayRangeWide let iobChart = IOBChart() - let doseChart = DoseChart() + let doseChart = LegacyDoseChart() let carbEffectChart = CarbEffectChart() carbEffectChart.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayBound return ChartsManager(colors: .primary, settings: .default, charts: [glucoseChart, iobChart, doseChart, carbEffectChart], traitCollection: .current) diff --git a/Loop/Views/Charts/DoseChartView.swift b/Loop/Views/Charts/DoseChartView.swift index 44f4de087e..9161436813 100644 --- a/Loop/Views/Charts/DoseChartView.swift +++ b/Loop/Views/Charts/DoseChartView.swift @@ -19,7 +19,7 @@ struct DoseChartView: View { @Binding var isInteractingWithChart: Bool var body: some View { - LoopChartView(chartManager: chartManager, dateInterval: dateInterval, isInteractingWithChart: $isInteractingWithChart) { doseChart in + LoopChartView(chartManager: chartManager, dateInterval: dateInterval, isInteractingWithChart: $isInteractingWithChart) { doseChart in doseChart.doseEntries = doses } } From 3fadc00c57da08d58a06732553cdc0877c62b8d7 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 19 Mar 2025 11:22:19 -0700 Subject: [PATCH 227/421] [LOOP-5290 & LOOP-5294] Fix Crashed When Tapping Glucose and COB Charts (#769) --- 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 9929ee02c5..17688d78f3 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1061,7 +1061,7 @@ final class StatusTableViewController: LoopChartsTableViewController { }) cell.setChartGenerator(generator: { [weak self] (frame) in - return self?.statusCharts.iobChart(withFrame: frame, highlightLabelOffsetY: cell.supplementalChartContentView.bounds.height)?.view + return self?.statusCharts.iobChart(withFrame: frame, highlightLabelOffsetY: cell.supplementalChartContentView?.bounds.height ?? 0)?.view }) cell.setTitleLabelText(label: NSLocalizedString("Active Insulin", comment: "The title of the Insulin On-Board graph")) cell.setTitleTextColor(color: ChartColorPalette.primary.insulinTint) From ae3206784f84eab8e0647800aeae13c52e62cf46 Mon Sep 17 00:00:00 2001 From: Petr Zywczok Date: Fri, 21 Mar 2025 10:02:29 +0100 Subject: [PATCH 228/421] [QAE-459] Add accessibility identifiers --- LoopUI/Views/CGMStatusHUDView.swift | 6 ++++++ LoopUI/Views/DeviceStatusHUDView.swift | 8 +++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/LoopUI/Views/CGMStatusHUDView.swift b/LoopUI/Views/CGMStatusHUDView.swift index 2f53c0f89d..47af3dd61e 100644 --- a/LoopUI/Views/CGMStatusHUDView.swift +++ b/LoopUI/Views/CGMStatusHUDView.swift @@ -140,6 +140,12 @@ public final class CGMStatusHUDView: DeviceStatusHUDView, NibLoadable { presentStatusHighlight(viewModel.statusHighlight) accessibilityValue = viewModel.accessibilityString + accessibilityIdentifier = + if viewModel.glucoseValueString == "LOW" || viewModel.glucoseValueString == "HIGH" { + "glucoseHUDView_\(viewModel.glucoseValueString)" + } else { + "glucoseHUDView" + } } func updateTrendIcon() { diff --git a/LoopUI/Views/DeviceStatusHUDView.swift b/LoopUI/Views/DeviceStatusHUDView.swift index 32d7aea767..af0a9bfd99 100644 --- a/LoopUI/Views/DeviceStatusHUDView.swift +++ b/LoopUI/Views/DeviceStatusHUDView.swift @@ -51,10 +51,12 @@ import LoopKitUI resetProgress() return } - + progressView.isHidden = false progressView.progress = Float(lifecycleProgress.percentComplete.clamped(to: 0...1)) progressView.tintColor = lifecycleProgress.progressState.color + progressView.accessibilityIdentifier = + "progressBar_State_\(lifecycleProgress.progressState.rawValue)" } } @@ -97,8 +99,8 @@ import LoopKitUI } private func presentStatusHighlight(withMessage message: String, - image: UIImage?, - color: UIColor) + image: UIImage?, + color: UIColor) { statusHighlightView.messageLabel.text = message statusHighlightView.messageLabel.tintColor = .label From c6907fd1c32219220248e29f700b46f07d1cc2be Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 21 Mar 2025 15:54:04 -0500 Subject: [PATCH 229/421] LOOP-5123 Create preset (#771) * Create preset ui * Example values updating * Add info view for insulin scale * range editing for new/existing preset * Create new preset is in its own navigation stack * Updates for editing range overrides * Navigation path * Editing name and duration * Adding CardSectionScrollView * Update other pages to use CardSectionScrollView * Disallow setting override range to scheduled for workout and preset * wip * Repeating schedule options * Action button for preset review * Schedule creation updates * Validate start time is at least 1 minute in the future * Update custom views to use accent color instead of blue * Update date for validation error * Creating new presets * Rename * TherapySettingsProvider updates * Small refactor * Single Use Preset updates * Updates from PR review --- Loop.xcodeproj/project.pbxproj | 102 +++- .../Environment+SettingsManager.swift | 24 + .../Environment+TemporaryPresetsManager.swift | 23 + Loop/Extensions/Publisher.swift | 41 ++ Loop/Managers/LoopAppManager.swift | 26 +- .../LoopDataManager+CarbAbsorption.swift | 2 +- Loop/Managers/LoopDataManager.swift | 6 +- Loop/Managers/OnboardingManager.swift | 6 +- Loop/Managers/SettingsManager.swift | 188 +++++-- Loop/Managers/TemporaryPresetsManager.swift | 146 +++++- Loop/Models/SelectablePreset.swift | 272 ++++++++++ .../StatusTableViewController.swift | 20 +- Loop/View Models/PresetsViewModel.swift | 473 ------------------ Loop/View Models/SettingsViewModel.swift | 30 -- Loop/View Models/StatusTableViewModel.swift | 59 +++ .../Presets/Components/CardSection.swift | 68 +++ .../Components/CardSectionScrollView.swift | 50 ++ .../Components/CorrectionRangePreview.swift | 126 +++++ .../Presets/Components/DayPickerPopup.swift | 61 +++ ...iew.swift => EditPresetDurationView.swift} | 48 +- .../Components/InsulinScaleAdjustView.swift | 178 +++++++ .../Views/Presets/Components/PresetCard.swift | 10 +- .../Presets/Components/PresetDetentView.swift | 46 +- .../Presets/Components/PresetStatsView.swift | 3 +- .../Components/RepeatOptionsView.swift | 46 ++ .../CreatePresetNameAndScheduledEdit.swift | 284 +++++++++++ Loop/Views/Presets/CreatePresetView.swift | 147 ++++++ Loop/Views/Presets/DurationPickerView.swift | 23 +- Loop/Views/Presets/EditPresetView.swift | 261 +++------- .../Presets/ExistingPresetRangeEdit.swift | 130 +++++ .../Presets/InsulinScaleInformationView.swift | 142 ++++++ Loop/Views/Presets/NewCustomPreset.swift | 181 +++++++ Loop/Views/Presets/NewPresetRangeEdit.swift | 124 +++++ Loop/Views/Presets/PresetRangeEditor.swift | 185 +++++++ Loop/Views/Presets/PresetsHistoryView.swift | 16 +- Loop/Views/Presets/PresetsView.swift | 99 ++-- Loop/Views/Presets/ReviewNewPresetView.swift | 208 ++++++++ .../PresetsAndExerciseContentView.swift | 5 +- Loop/Views/SettingsView.swift | 2 +- Loop/Views/StatusTableView.swift | 66 +-- .../InAppModalAlertSchedulerTests.swift | 2 +- 41 files changed, 2931 insertions(+), 998 deletions(-) create mode 100644 Loop/Extensions/Environment+SettingsManager.swift create mode 100644 Loop/Extensions/Environment+TemporaryPresetsManager.swift create mode 100644 Loop/Extensions/Publisher.swift create mode 100644 Loop/Models/SelectablePreset.swift delete mode 100644 Loop/View Models/PresetsViewModel.swift create mode 100644 Loop/View Models/StatusTableViewModel.swift create mode 100644 Loop/Views/Presets/Components/CardSection.swift create mode 100644 Loop/Views/Presets/Components/CardSectionScrollView.swift create mode 100644 Loop/Views/Presets/Components/CorrectionRangePreview.swift create mode 100644 Loop/Views/Presets/Components/DayPickerPopup.swift rename Loop/Views/Presets/Components/{EditOverrideDurationView.swift => EditPresetDurationView.swift} (58%) create mode 100644 Loop/Views/Presets/Components/InsulinScaleAdjustView.swift create mode 100644 Loop/Views/Presets/Components/RepeatOptionsView.swift create mode 100644 Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift create mode 100644 Loop/Views/Presets/CreatePresetView.swift create mode 100644 Loop/Views/Presets/ExistingPresetRangeEdit.swift create mode 100644 Loop/Views/Presets/InsulinScaleInformationView.swift create mode 100644 Loop/Views/Presets/NewCustomPreset.swift create mode 100644 Loop/Views/Presets/NewPresetRangeEdit.swift create mode 100644 Loop/Views/Presets/PresetRangeEditor.swift create mode 100644 Loop/Views/Presets/ReviewNewPresetView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 2b4a2a3c7b..697c9f348c 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -268,11 +268,10 @@ 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */; }; 84E8BBC82CC9D34B0078E6CF /* TherapySettingsExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */; }; 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC92CCA16290078E6CF /* PresetsView.swift */; }; - 84E8BBCC2CCA16B30078E6CF /* PresetsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCB2CCA16B30078E6CF /* PresetsViewModel.swift */; }; 84E8BBCE2CCA1E070078E6CF /* PresetsTrainingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */; }; 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */; }; - 84F20DFD2D0B9C3A0089DF02 /* EditOverrideDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFC2D0B9C3A0089DF02 /* EditOverrideDurationView.swift */; }; + 84F20DFD2D0B9C3A0089DF02 /* EditPresetDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */; }; 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */; }; 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */; }; 892A5D59222F0A27008961AB /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; @@ -417,6 +416,19 @@ 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 */; }; + C105095B2D78D35100118A37 /* CreatePresetNameAndScheduledEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105095A2D78D32C00118A37 /* CreatePresetNameAndScheduledEdit.swift */; }; + C105095D2D7A1DB700118A37 /* CardSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105095C2D7A1DB300118A37 /* CardSection.swift */; }; + C105095F2D7A311200118A37 /* ReviewNewPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105095E2D7A310B00118A37 /* ReviewNewPresetView.swift */; }; + C10509612D7B3DF400118A37 /* CardSectionScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509602D7B3DEA00118A37 /* CardSectionScrollView.swift */; }; + C10509652D7B6B1900118A37 /* CorrectionRangePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509642D7B6B0000118A37 /* CorrectionRangePreview.swift */; }; + C10509672D7F7A4900118A37 /* InsulinScaleAdjustView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509662D7F7A3700118A37 /* InsulinScaleAdjustView.swift */; }; + C105096D2D80E23A00118A37 /* DayPickerPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105096C2D80E22C00118A37 /* DayPickerPopup.swift */; }; + C105096F2D8237F300118A37 /* EditPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105096E2D8237EF00118A37 /* EditPresetView.swift */; }; + C10509712D84A80900118A37 /* RepeatOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509702D84A80500118A37 /* RepeatOptionsView.swift */; }; + C10509752D8B309400118A37 /* Environment+SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509742D8B308E00118A37 /* Environment+SettingsManager.swift */; }; + C10509772D8B591200118A37 /* StatusTableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509762D8B590D00118A37 /* StatusTableViewModel.swift */; }; + C10509792D8B636900118A37 /* Environment+TemporaryPresetsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509782D8B635F00118A37 /* Environment+TemporaryPresetsManager.swift */; }; + C105097B2D8B947B00118A37 /* SelectablePreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105097A2D8B947700118A37 /* SelectablePreset.swift */; }; 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 */; }; @@ -429,6 +441,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 */; }; + C120CECC2D8CD6990050944B /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C120CECB2D8CD6970050944B /* Publisher.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 */; }; @@ -446,8 +459,7 @@ C16575732538AFF6004AE16E /* CGMStalenessMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */; }; C16575752539FD60004AE16E /* LoopCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575742539FD60004AE16E /* LoopCoreConstants.swift */; }; C16575762539FEF3004AE16E /* LoopCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575742539FD60004AE16E /* LoopCoreConstants.swift */; }; - C16971DF2D10C21C001B7DF6 /* EditPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16971DE2D10C216001B7DF6 /* EditPresetView.swift */; }; - C16971F92D1231B5001B7DF6 /* EditPresetRangeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16971F82D1231AB001B7DF6 /* EditPresetRangeView.swift */; }; + C16971F92D1231B5001B7DF6 /* PresetRangeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16971F82D1231AB001B7DF6 /* PresetRangeEditor.swift */; }; 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 */; }; @@ -485,6 +497,11 @@ 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 */; }; C19F48742560ABFB003632D7 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; + C1AC03962D6E07D6004D4D2B /* CreatePresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC03952D6E07D3004D4D2B /* CreatePresetView.swift */; }; + C1AC039A2D6E3C88004D4D2B /* InsulinScaleInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC03992D6E3C7B004D4D2B /* InsulinScaleInformationView.swift */; }; + C1AC039C2D6E7551004D4D2B /* ExistingPresetRangeEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC039B2D6E751C004D4D2B /* ExistingPresetRangeEdit.swift */; }; + C1AC039E2D6FC8C8004D4D2B /* NewPresetRangeEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC039D2D6FC8BB004D4D2B /* NewPresetRangeEdit.swift */; }; + C1AC03A02D6FCB2F004D4D2B /* NewCustomPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC039F2D6FCB2C004D4D2B /* NewCustomPreset.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 */; }; @@ -1152,11 +1169,10 @@ 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PercentPickerView.swift; sourceTree = ""; }; 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySettingsExampleView.swift; sourceTree = ""; }; 84E8BBC92CCA16290078E6CF /* PresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsView.swift; sourceTree = ""; }; - 84E8BBCB2CCA16B30078E6CF /* PresetsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsViewModel.swift; sourceTree = ""; }; 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingCard.swift; sourceTree = ""; }; 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetStatsView.swift; sourceTree = ""; }; - 84F20DFC2D0B9C3A0089DF02 /* EditOverrideDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditOverrideDurationView.swift; sourceTree = ""; }; + 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPresetDurationView.swift; sourceTree = ""; }; 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsHistoryView.swift; sourceTree = ""; }; 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlowViewModel.swift; sourceTree = ""; }; 892A5D29222EF60A008961AB /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKit.framework; path = Carthage/Build/iOS/MockKit.framework; sourceTree = SOURCE_ROOT; }; @@ -1353,6 +1369,19 @@ 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; }; + C105095A2D78D32C00118A37 /* CreatePresetNameAndScheduledEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePresetNameAndScheduledEdit.swift; sourceTree = ""; }; + C105095C2D7A1DB300118A37 /* CardSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardSection.swift; sourceTree = ""; }; + C105095E2D7A310B00118A37 /* ReviewNewPresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewNewPresetView.swift; sourceTree = ""; }; + C10509602D7B3DEA00118A37 /* CardSectionScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardSectionScrollView.swift; sourceTree = ""; }; + C10509642D7B6B0000118A37 /* CorrectionRangePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangePreview.swift; sourceTree = ""; }; + C10509662D7F7A3700118A37 /* InsulinScaleAdjustView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinScaleAdjustView.swift; sourceTree = ""; }; + C105096C2D80E22C00118A37 /* DayPickerPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayPickerPopup.swift; sourceTree = ""; }; + C105096E2D8237EF00118A37 /* EditPresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPresetView.swift; sourceTree = ""; }; + C10509702D84A80500118A37 /* RepeatOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatOptionsView.swift; sourceTree = ""; }; + C10509742D8B308E00118A37 /* Environment+SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+SettingsManager.swift"; sourceTree = ""; }; + C10509762D8B590D00118A37 /* StatusTableViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewModel.swift; sourceTree = ""; }; + C10509782D8B635F00118A37 /* Environment+TemporaryPresetsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+TemporaryPresetsManager.swift"; sourceTree = ""; }; + C105097A2D8B947700118A37 /* SelectablePreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectablePreset.swift; sourceTree = ""; }; C1092BFD29F8116700AE3D1C /* apply-info-customizations.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "apply-info-customizations.sh"; sourceTree = ""; }; C110888C2A3913C600BA4898 /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = ""; }; C11613482983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1366,6 +1395,7 @@ C11B9D61286779C000500CF8 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusViewModel.swift; sourceTree = ""; }; C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchContextRequestUserInfo.swift; sourceTree = ""; }; + C120CECB2D8CD6970050944B /* Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = ""; }; C121D8CF29C7866D00DA0520 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Main.strings; sourceTree = ""; }; C121D8D029C7866D00DA0520 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; C121D8D129C7866D00DA0520 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; @@ -1411,8 +1441,7 @@ C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitor.swift; sourceTree = ""; }; C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitorTests.swift; sourceTree = ""; }; C16575742539FD60004AE16E /* LoopCoreConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCoreConstants.swift; sourceTree = ""; }; - C16971DE2D10C216001B7DF6 /* EditPresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPresetView.swift; sourceTree = ""; }; - C16971F82D1231AB001B7DF6 /* EditPresetRangeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPresetRangeView.swift; sourceTree = ""; }; + C16971F82D1231AB001B7DF6 /* PresetRangeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetRangeEditor.swift; sourceTree = ""; }; 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 = ""; }; @@ -1464,6 +1493,11 @@ 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 = ""; }; C19F496225630504003632D7 /* Minizip.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Minizip.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C1AC03952D6E07D3004D4D2B /* CreatePresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePresetView.swift; sourceTree = ""; }; + C1AC03992D6E3C7B004D4D2B /* InsulinScaleInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinScaleInformationView.swift; sourceTree = ""; }; + C1AC039B2D6E751C004D4D2B /* ExistingPresetRangeEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExistingPresetRangeEdit.swift; sourceTree = ""; }; + C1AC039D2D6FC8BB004D4D2B /* NewPresetRangeEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPresetRangeEdit.swift; sourceTree = ""; }; + C1AC039F2D6FCB2C004D4D2B /* NewCustomPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCustomPreset.swift; sourceTree = ""; }; 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 = ""; }; C1AD62FE29BBFAA80002685D /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1890,6 +1924,7 @@ 43757D131C06F26C00910CB9 /* Models */ = { isa = PBXGroup; children = ( + C105097A2D8B947700118A37 /* SelectablePreset.swift */, DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */, B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */, A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, @@ -2130,6 +2165,9 @@ 43E344A01B9E144300C85C07 /* Extensions */ = { isa = PBXGroup; children = ( + C120CECB2D8CD6970050944B /* Publisher.swift */, + C10509782D8B635F00118A37 /* Environment+TemporaryPresetsManager.swift */, + C10509742D8B308E00118A37 /* Environment+SettingsManager.swift */, A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */, C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */, C1F2CAAB2B7A980600D7F581 /* BasalRelativeDose.swift */, @@ -2500,12 +2538,19 @@ 84E8BBAF2CC979300078E6CF /* Presets */ = { isa = PBXGroup; children = ( + C105095A2D78D32C00118A37 /* CreatePresetNameAndScheduledEdit.swift */, + C1AC03952D6E07D3004D4D2B /* CreatePresetView.swift */, C14F68C82D4AC54000BC3B8D /* DurationPickerView.swift */, - C16971F82D1231AB001B7DF6 /* EditPresetRangeView.swift */, - C16971DE2D10C216001B7DF6 /* EditPresetView.swift */, - 84E8BBC92CCA16290078E6CF /* PresetsView.swift */, + C105096E2D8237EF00118A37 /* EditPresetView.swift */, + C1AC039B2D6E751C004D4D2B /* ExistingPresetRangeEdit.swift */, + C1AC03992D6E3C7B004D4D2B /* InsulinScaleInformationView.swift */, + C1AC039F2D6FCB2C004D4D2B /* NewCustomPreset.swift */, + C1AC039D2D6FC8BB004D4D2B /* NewPresetRangeEdit.swift */, + C16971F82D1231AB001B7DF6 /* PresetRangeEditor.swift */, 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */, 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */, + 84E8BBC92CCA16290078E6CF /* PresetsView.swift */, + C105095E2D7A310B00118A37 /* ReviewNewPresetView.swift */, 84E8BBC22CC9B9780078E6CF /* Components */, 84E8BBB62CC990480078E6CF /* Training Content */, ); @@ -2526,6 +2571,12 @@ 84E8BBC22CC9B9780078E6CF /* Components */ = { isa = PBXGroup; children = ( + C10509702D84A80500118A37 /* RepeatOptionsView.swift */, + C105096C2D80E22C00118A37 /* DayPickerPopup.swift */, + C10509662D7F7A3700118A37 /* InsulinScaleAdjustView.swift */, + C10509642D7B6B0000118A37 /* CorrectionRangePreview.swift */, + C10509602D7B3DEA00118A37 /* CardSectionScrollView.swift */, + C105095C2D7A1DB300118A37 /* CardSection.swift */, 84C170EC2CCA361F0098E52F /* ImpactView.swift */, 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */, 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */, @@ -2535,7 +2586,7 @@ 84C170EE2CCA37680098E52F /* PresetCard.swift */, 84D1F1A82D09800700CB271F /* PresetDetentView.swift */, 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */, - 84F20DFC2D0B9C3A0089DF02 /* EditOverrideDurationView.swift */, + 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */, ); path = Components; sourceTree = ""; @@ -2602,6 +2653,7 @@ 897A5A9724C22DCE00C4E71D /* View Models */ = { isa = PBXGroup; children = ( + C10509762D8B590D00118A37 /* StatusTableViewModel.swift */, 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */, 149A28BA2A853E5100052EDF /* CarbEntryViewModel.swift */, A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */, @@ -2614,7 +2666,6 @@ C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */, 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */, 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */, - 84E8BBCB2CCA16B30078E6CF /* PresetsViewModel.swift */, ); path = "View Models"; sourceTree = ""; @@ -3502,8 +3553,10 @@ C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */, 4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */, 4311FB9B1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift in Sources */, + C1AC03962D6E07D6004D4D2B /* CreatePresetView.swift in Sources */, C1F2075C26D6F9B0007AB7EB /* AppExpirationAlerter.swift in Sources */, B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */, + C1AC03A02D6FCB2F004D4D2B /* NewCustomPreset.swift in Sources */, 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */, C14F68C92D4AC54300BC3B8D /* DurationPickerView.swift in Sources */, 14C9706E2C5A83AF00E8A01B /* IOBChartView.swift in Sources */, @@ -3533,6 +3586,7 @@ 14C970802C5C0A1500E8A01B /* FavoriteFoodInsightsViewModel.swift in Sources */, 149A28BB2A853E5100052EDF /* CarbEntryViewModel.swift in Sources */, C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */, + C1AC039C2D6E7551004D4D2B /* ExistingPresetRangeEdit.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, @@ -3540,14 +3594,15 @@ 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */, 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, - C16971F92D1231B5001B7DF6 /* EditPresetRangeView.swift in Sources */, + C16971F92D1231B5001B7DF6 /* PresetRangeEditor.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, - 84F20DFD2D0B9C3A0089DF02 /* EditOverrideDurationView.swift in Sources */, + 84F20DFD2D0B9C3A0089DF02 /* EditPresetDurationView.swift in Sources */, 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */, 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */, C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */, + C105095B2D78D35100118A37 /* CreatePresetNameAndScheduledEdit.swift in Sources */, 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */, 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */, 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */, @@ -3570,6 +3625,7 @@ A9C62D8A2331703100535612 /* ServicesManager.swift in Sources */, 43DBF0531C93EC8200B3C386 /* DeviceDataManager.swift in Sources */, A9347F2F24E7508A00C99C34 /* WatchHistoricalCarbs.swift in Sources */, + C10509792D8B636900118A37 /* Environment+TemporaryPresetsManager.swift in Sources */, A9B996F027235191002DC09C /* LoopWarning.swift in Sources */, C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */, 4372E487213C86240068E043 /* SampleValue.swift in Sources */, @@ -3586,12 +3642,13 @@ C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, A977A2F424ACFECF0059C207 /* CriticalEventLogExportManager.swift in Sources */, 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, - 84E8BBCC2CCA16B30078E6CF /* PresetsViewModel.swift in Sources */, 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */, + C1AC039E2D6FC8C8004D4D2B /* NewPresetRangeEdit.swift in Sources */, 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */, B455C7332BD14E25002B847E /* Comparable.swift in Sources */, A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */, A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */, + C120CECC2D8CD6990050944B /* Publisher.swift in Sources */, 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */, 1DB1065124467E18005542BD /* AlertManager.swift in Sources */, 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */, @@ -3611,6 +3668,7 @@ 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */, DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, A9347F3124E7521800C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */, + C105095D2D7A1DB700118A37 /* CardSection.swift in Sources */, A9CBE458248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift in Sources */, 84E8BBBC2CC992660078E6CF /* PresetsAndIllnessContentView.swift in Sources */, 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */, @@ -3618,12 +3676,15 @@ 1DB619AC270BAD3D006C9D07 /* VersionUpdateViewModel.swift in Sources */, A9C62D882331703100535612 /* Service.swift in Sources */, 89CAB36324C8FE96009EE3CE /* PredictedGlucoseChartView.swift in Sources */, + C105096D2D80E23A00118A37 /* DayPickerPopup.swift in Sources */, DDC389F82A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift in Sources */, 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */, A9F703732489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift in Sources */, + C105097B2D8B947B00118A37 /* SelectablePreset.swift in Sources */, 4328E0351CFC0AE100E199AA /* WatchDataManager.swift in Sources */, 4345E3FC21F04911009E00E5 /* UIColor+HIG.swift in Sources */, 1D4A3E2E2478628500FD601B /* StoredAlert+CoreDataProperties.swift in Sources */, + C10509752D8B309400118A37 /* Environment+SettingsManager.swift in Sources */, E95D380124EADE7C005E2F50 /* DoseStoreProtocol.swift in Sources */, 43D381621EBD9759007F8C8F /* HeaderValuesTableViewCell.swift in Sources */, C13DA2B024F6C7690098BB29 /* UIViewController.swift in Sources */, @@ -3631,6 +3692,7 @@ 89E267FF229267DF00A3F2AF /* Optional.swift in Sources */, 43785E982120E7060057DED1 /* Intents.intentdefinition in Sources */, 1D82E6A025377C6B009131FB /* TrustedTimeChecker.swift in Sources */, + C105096F2D8237F300118A37 /* EditPresetView.swift in Sources */, A98556852493F901000FD662 /* AlertStore+SimulatedCoreData.swift in Sources */, 899433B823FE129800FA4BEA /* OverrideBadgeView.swift in Sources */, 89D1503E24B506EB00EDE253 /* Dictionary.swift in Sources */, @@ -3641,6 +3703,7 @@ 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */, 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */, 14C9707E2C5A9EB600E8A01B /* GlucoseCarbChartView.swift in Sources */, + C10509712D84A80900118A37 /* RepeatOptionsView.swift in Sources */, 43E93FB61E469A4000EAB8DB /* NumberFormatter.swift in Sources */, 14C9706C2C5A836000E8A01B /* DoseChartView.swift in Sources */, C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */, @@ -3654,12 +3717,15 @@ A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */, E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, + C10509612D7B3DF400118A37 /* CardSectionScrollView.swift in Sources */, C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, + C10509772D8B591200118A37 /* StatusTableViewModel.swift in Sources */, 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */, DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */, 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */, + C105095F2D7A311200118A37 /* ReviewNewPresetView.swift in Sources */, 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */, A9B607B0247F000F00792BE4 /* UserNotifications+Loop.swift in Sources */, 43F89CA322BDFBBD006BB54E /* UIActivityIndicatorView.swift in Sources */, @@ -3682,6 +3748,7 @@ 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */, 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */, 14ED83F62C6421F9008B4A5C /* FavoriteFoodInsightsCardView.swift in Sources */, + C10509652D7B6B1900118A37 /* CorrectionRangePreview.swift in Sources */, C174233C259BEB0F00399C9D /* ManualEntryDoseViewModel.swift in Sources */, 84E8BBC82CC9D34B0078E6CF /* TherapySettingsExampleView.swift in Sources */, 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, @@ -3691,6 +3758,7 @@ A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */, + C10509672D7F7A4900118A37 /* InsulinScaleAdjustView.swift in Sources */, DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */, 4F70C2101DE8FAC5006380B7 /* ExtensionDataManager.swift in Sources */, 1455ACB22C667BEE004F44F2 /* Deeplink.swift in Sources */, @@ -3706,7 +3774,6 @@ DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */, A9C62D842331700E00535612 /* DiagnosticLog+Subsystem.swift in Sources */, 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */, - C16971DF2D10C21C001B7DF6 /* EditPresetView.swift in Sources */, C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */, A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */, 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */, @@ -3724,6 +3791,7 @@ 432E73CB1D24B3D6009AD15D /* RemoteDataServicesManager.swift in Sources */, 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */, B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */, + C1AC039A2D6E3C88004D4D2B /* InsulinScaleInformationView.swift in Sources */, 43C2FAE11EB656A500364AFF /* GlucoseEffectVelocity.swift in Sources */, 14C970862C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift in Sources */, ); diff --git a/Loop/Extensions/Environment+SettingsManager.swift b/Loop/Extensions/Environment+SettingsManager.swift new file mode 100644 index 0000000000..717726f025 --- /dev/null +++ b/Loop/Extensions/Environment+SettingsManager.swift @@ -0,0 +1,24 @@ +// +// Environment+SettingsProvider.swift +// Loop +// +// Created by Pete Schwamb on 3/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopAlgorithm + + +@MainActor +private struct SettingsManagerKey: @preconcurrency EnvironmentKey { + static let defaultValue: SettingsManager = SettingsManager.placeholder +} + +extension EnvironmentValues { + var settingsManager: SettingsManager { + get { self[SettingsManagerKey.self] } + set { self[SettingsManagerKey.self] = newValue } + } +} diff --git a/Loop/Extensions/Environment+TemporaryPresetsManager.swift b/Loop/Extensions/Environment+TemporaryPresetsManager.swift new file mode 100644 index 0000000000..fabc168fa9 --- /dev/null +++ b/Loop/Extensions/Environment+TemporaryPresetsManager.swift @@ -0,0 +1,23 @@ +// +// Environment+TemporaryPresetManager.swift +// Loop +// +// Created by Pete Schwamb on 3/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit + +@MainActor +private struct TemporaryPresetsManagerKey: @preconcurrency EnvironmentKey { + // Default value should never really be used + static let defaultValue: TemporaryPresetsManager = TemporaryPresetsManager.placeholder +} + +extension EnvironmentValues { + var temporaryPresetsManager: TemporaryPresetsManager { + get { self[TemporaryPresetsManagerKey.self] } + set { self[TemporaryPresetsManagerKey.self] = newValue } + } +} diff --git a/Loop/Extensions/Publisher.swift b/Loop/Extensions/Publisher.swift new file mode 100644 index 0000000000..16f93d073d --- /dev/null +++ b/Loop/Extensions/Publisher.swift @@ -0,0 +1,41 @@ +// +// Publisher.swift +// Loop +// +// Created by Pete Schwamb on 3/20/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import Combine +import Observation + +enum ObservablePublishers { + static func tracking( + _ object: Object, + keyPath: KeyPath + ) -> AnyPublisher { + let subject = PassthroughSubject() + + Task { + while true { + // Get the value and track access + let initialValue = withObservationTracking { + object[keyPath: keyPath] + } onChange: { + // When change happens, continue the loop + Task { @MainActor in + subject.send(object[keyPath: keyPath]) + } + } + + // Send initial value + subject.send(initialValue) + + // Wait until the next change + try? await Task.sleep(for: .seconds(100)) + } + } + + return subject.eraseToAnyPublisher() + } +} diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 683ae775fd..538be5efbb 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -251,7 +251,7 @@ class LoopAppManager: NSObject { ) temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsManager) - temporaryPresetsManager.overrideHistory.delegate = self + temporaryPresetsManager.presetHistory.delegate = self temporaryPresetsManager.addTemporaryPresetObserver(alertManager) temporaryPresetsManager.addTemporaryPresetObserver(analyticsServicesManager) @@ -355,7 +355,7 @@ class LoopAppManager: NSObject { glucoseStore: glucoseStore, cgmEventStore: cgmEventStore, settingsProvider: settingsManager, - overrideHistory: temporaryPresetsManager.overrideHistory, + overrideHistory: temporaryPresetsManager.presetHistory, insulinDeliveryStore: doseStore.insulinDeliveryStore, deviceLog: deviceLog, automationHistoryProvider: loopDataManager @@ -405,9 +405,7 @@ class LoopAppManager: NSObject { dosingDecisionStore.delegate = deviceDataManager remoteDataServicesManager.delegate = deviceDataManager - - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, directory: FileManager.default.exportsDirectoryURL, historicalDuration: localCacheDuration) @@ -501,8 +499,10 @@ class LoopAppManager: NSObject { analyticsServicesManager.application(didFinishLaunchingWithOptions: launchOptions) + let dosingEnablePublisher = ObservablePublishers.tracking(settingsManager, keyPath: \.dosingEnabled) + automaticDosingStatus.$isAutomaticDosingAllowed - .combineLatest(settingsManager.$dosingEnabled) + .combineLatest(dosingEnablePublisher) .map { $0 && $1 } .assign(to: \.automaticDosingStatus.automaticDosingEnabled, on: self) .store(in: &cancellables) @@ -590,7 +590,7 @@ class LoopAppManager: NSObject { availableSupports: supportManager.availableSupports, isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceDataManager, - presetHistory: temporaryPresetsManager.overrideHistory, + presetHistory: temporaryPresetsManager.presetHistory, temporaryPresetsManager: temporaryPresetsManager ) @@ -630,6 +630,8 @@ class LoopAppManager: NSObject { .environment(\.appName, Bundle.main.bundleDisplayName) .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) .environment(\.loopStatusColorPalette, .loopStatus) + .environment(\.settingsManager, settingsManager) + .environment(\.temporaryPresetsManager, temporaryPresetsManager) .edgesIgnoringSafeArea(.top) var rootNavigationController = rootViewController as? RootNavigationController @@ -829,6 +831,7 @@ extension LoopAppManager: AlertPresenter { } } +@MainActor protocol DisplayGlucoseUnitBroadcaster: AnyObject { func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) @@ -858,7 +861,7 @@ extension LoopAppManager: DisplayGlucoseUnitBroadcaster { // MARK: - DeviceOrientationController -extension LoopAppManager: DeviceOrientationController { +extension LoopAppManager: @preconcurrency DeviceOrientationController { func setDefaultSupportedInferfaceOrientations() { supportedInterfaceOrientations = Self.defaultSupportedInterfaceOrientations } @@ -1060,6 +1063,7 @@ extension LoopAppManager: DiagnosticReportGenerator { // MARK: SimulatedData +@MainActor protocol SimulatedData { func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) @@ -1071,7 +1075,11 @@ extension LoopAppManager: SimulatedData { fatalError("\(#function) should be invoked only when simulated core data is enabled") } - settingsManager.settingsStore.generateSimulatedHistoricalSettingsObjects() { error in + guard let settingsStore = settingsManager.settingsStore else { + fatalError("\(#function) invoke with no settings store") + } + + settingsManager.settingsStore?.generateSimulatedHistoricalSettingsObjects() { error in guard error == nil else { completion(error) return diff --git a/Loop/Managers/LoopDataManager+CarbAbsorption.swift b/Loop/Managers/LoopDataManager+CarbAbsorption.swift index 705ef75b35..24b92f7886 100644 --- a/Loop/Managers/LoopDataManager+CarbAbsorption.swift +++ b/Loop/Managers/LoopDataManager+CarbAbsorption.swift @@ -49,7 +49,7 @@ extension LoopDataManager { let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: sensitivityStart, endDate: end) - let overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: end) + let overrides = temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: sensitivityStart, endDate: end) guard !sensitivity.isEmpty else { throw LoopError.configurationError(.insulinSensitivitySchedule) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 4519ce0cd3..efe33a2499 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -396,7 +396,7 @@ final class LoopDataManager: ObservableObject { throw LoopError.configurationError(.maximumBasalRatePerHour) } - var overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: neededSensitivityTimeline.start, endDate: forecastEndTime) + var overrides = temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: neededSensitivityTimeline.start, endDate: forecastEndTime) if disablingPreMeal, let activeOverride = temporaryPresetsManager.activeOverride, @@ -575,7 +575,7 @@ final class LoopDataManager: ObservableObject { basal.unitsPerHour = deliveryDelegate.roundBasalRate(unitsPerHour: basal.unitsPerHour) let scheduledBasalRate = input.basal.closestPrior(to: loopBaseTime)!.value - let activeOverride = temporaryPresetsManager.overrideHistory.activeOverride(at: loopBaseTime) + let activeOverride = temporaryPresetsManager.presetHistory.activeOverride(at: loopBaseTime) let basalAdjustment = basal.adjustForCurrentDelivery( at: loopBaseTime, @@ -1280,7 +1280,7 @@ extension LoopDataManager: FavoriteFoodInsightsViewModelDelegate { let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: sensitivityStart, endDate: end) - let overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: end) + let overrides = temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: sensitivityStart, endDate: end) guard !sensitivity.isEmpty else { throw LoopError.configurationError(.insulinSensitivitySchedule) diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index 6435e126ed..3e64f3cb08 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -373,7 +373,7 @@ extension OnboardingManager: CGMManagerProvider { // MARK: - PumpManagerProvider -extension OnboardingManager: PumpManagerProvider { +extension OnboardingManager: @preconcurrency PumpManagerProvider { var activePumpManager: PumpManager? { deviceDataManager.pumpManager } var availablePumpManagers: [PumpManagerDescriptor] { deviceDataManager.availablePumpManagers } @@ -425,7 +425,7 @@ extension OnboardingManager: StatefulPluggableProvider { // MARK: - ServiceProvider -extension OnboardingManager: ServiceProvider { +extension OnboardingManager: @preconcurrency ServiceProvider { var activeServices: [Service] { servicesManager.activeServices } var availableServices: [ServiceDescriptor] { servicesManager.availableServices } @@ -433,7 +433,7 @@ extension OnboardingManager: ServiceProvider { // MARK: - TherapySettingsProvider -extension OnboardingManager: TherapySettingsProvider { +extension OnboardingManager: @preconcurrency OnboardingTherapySettingsProvider { var onboardingTherapySettings: TherapySettings { return settingsManager.therapySettings } diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index bba10ac297..6a76f3078f 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -23,9 +23,10 @@ protocol DeviceStatusProvider { } @MainActor +@Observable class SettingsManager { - let settingsStore: SettingsStore + let settingsStore: SettingsStore? var remoteDataServicesManager: RemoteDataServicesManager? @@ -37,7 +38,7 @@ class SettingsManager { var displayGlucosePreference: DisplayGlucosePreference? - public var settings: StoredSettings + private var storedSettings: StoredSettings private var remoteNotificationRegistrationResult: Swift.Result? @@ -45,27 +46,35 @@ class SettingsManager { private let log = OSLog(category: "SettingsManager") - private var loopSettingsLock = UnfairLock() - - @Published private(set) var dosingEnabled: Bool + var dosingEnabled: Bool { + get { storedSettings.dosingEnabled } + set { storedSettings.dosingEnabled = newValue } + } - init(cacheStore: PersistenceController, expireAfter: TimeInterval, alertMuter: AlertMuter, analyticsServicesManager: AnalyticsServicesManager? = nil) + 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 { - settings = storedSettings + if let cacheStore { + settingsStore = SettingsStore(store: cacheStore, expireAfter: expireAfter) + } else { + settingsStore = nil + } + + if let latest = settingsStore?.latestSettings { + storedSettings = latest } else { log.default("SettingsStore has no settings: initializing empty StoredSettings.") - settings = StoredSettings() + storedSettings = StoredSettings() } dosingEnabled = settings.dosingEnabled - settingsStore.delegate = self + settingsStore?.delegate = self + + // Migrate old settings from UserDefaults if var legacyLoopSettings = UserDefaults.appGroup?.legacyLoopSettings { @@ -114,7 +123,7 @@ class SettingsManager { private func mergeSettings(newLoopSettings: LoopSettings? = nil, notificationSettings: NotificationSettings? = nil, deviceToken: String? = nil) -> StoredSettings { let newLoopSettings = newLoopSettings ?? loopSettings - let newNotificationSettings = notificationSettings ?? settingsStore.latestSettings?.notificationSettings + let newNotificationSettings = notificationSettings ?? settingsStore?.latestSettings?.notificationSettings return StoredSettings(date: Date(), dosingEnabled: newLoopSettings.dosingEnabled, @@ -155,7 +164,7 @@ class SettingsManager { return } - settings = mergedSettings + storedSettings = mergedSettings if remoteNotificationRegistrationResult == nil && FeatureFlags.remoteCommandsEnabled { // remote notification registration not finished @@ -166,7 +175,7 @@ class SettingsManager { log.default("Saving settings with no ISF schedule.") } - settingsStore.storeSettings(settings) { error in + settingsStore?.storeSettings(settings) { error in if let error = error { self.log.error("Error storing settings: %{public}@", error.localizedDescription) } @@ -201,38 +210,36 @@ class SettingsManager { } func mutateLoopSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { - loopSettingsLock.withLock { - let oldValue = loopSettings - var newValue = oldValue - changes(&newValue) + let oldValue = loopSettings + var newValue = oldValue + changes(&newValue) - guard oldValue != newValue else { - return - } + guard oldValue != newValue else { + return + } - storeSettings(newLoopSettings: newValue) + storeSettings(newLoopSettings: newValue) - if newValue.insulinSensitivitySchedule != oldValue.insulinSensitivitySchedule { - analyticsServicesManager?.didChangeInsulinSensitivitySchedule() - } + 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.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.carbRatioSchedule != oldValue.carbRatioSchedule { + analyticsServicesManager?.didChangeCarbRatioSchedule() + } - if newValue.defaultRapidActingModel != oldValue.defaultRapidActingModel { - analyticsServicesManager?.didChangeInsulinModel() - } + if newValue.defaultRapidActingModel != oldValue.defaultRapidActingModel { + analyticsServicesManager?.didChangeInsulinModel() + } - if newValue.dosingEnabled != oldValue.dosingEnabled { - self.dosingEnabled = newValue.dosingEnabled - } + if newValue.dosingEnabled != oldValue.dosingEnabled { + self.dosingEnabled = newValue.dosingEnabled } notify(forChange: .preferences) } @@ -240,7 +247,7 @@ class SettingsManager { func storeSettingsCheckingNotificationPermissions() { UNUserNotificationCenter.current().getNotificationSettings() { notificationSettings in DispatchQueue.main.async { - guard let settings = self.settingsStore.latestSettings else { + guard let settings = self.settingsStore?.latestSettings else { return } @@ -265,29 +272,29 @@ class SettingsManager { } func purgeHistoricalSettingsObjects(completion: @escaping (Error?) -> Void) { - settingsStore.purgeHistoricalSettingsObjects(completion: completion) + settingsStore?.purgeHistoricalSettingsObjects(completion: completion) } // MARK: Historical queries func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { - try await settingsStore.getBasalHistory(startDate: startDate, endDate: endDate) + 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) + 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) + 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) + try await settingsStore!.getTargetRangeHistory(startDate: startDate, endDate: endDate) } func getDosingLimits(at date: Date) async throws -> DosingLimits { - try await settingsStore.getDosingLimits(at: date) + try await settingsStore!.getDosingLimits(at: date) } } @@ -330,8 +337,77 @@ extension SettingsManager { } } } + + public func guardrailForPreset(_ preset: SelectablePreset) -> Guardrail { + switch preset { + case .preMeal: + return preMealGuardrail + case .legacyWorkout: + return legacyWorkoutPresetGuardrail + default: + return .correctionRange + } + } + + public var preMealGuardrail: Guardrail { + if let scheduleRange = settings.glucoseTargetRangeSchedule?.scheduleRange() { + return Guardrail.correctionRangeOverride( + for: .preMeal, + correctionRangeScheduleRange: scheduleRange, + suspendThreshold: settings.suspendThreshold + ) + } else { + return Guardrail.correctionRange + } + } + + public var legacyWorkoutPresetGuardrail: Guardrail { + if let scheduleRange = settings.glucoseTargetRangeSchedule?.scheduleRange() { + return Guardrail.correctionRangeOverride( + for: .workout, + correctionRangeScheduleRange: scheduleRange, + suspendThreshold: settings.suspendThreshold + ) + } else { + return Guardrail.correctionRange + } + } + + func savePreset(_ preset: SelectablePreset) { + switch(preset) { + case .preMeal(let range): + mutateLoopSettings { settings in + settings.preMealTargetRange = range + } + case .legacyWorkout(let range, let duration): + mutateLoopSettings { settings in + settings.legacyWorkoutTargetRange = range + switch duration { + case .indefinite: + settings.legacyWorkoutDuration = .indefinite + case .duration(let interval): + settings.legacyWorkoutDuration = .finite(interval) + case .untilCarbsEntered: + break + } + } + case .custom(let preset): + if let index = settings.overridePresets.firstIndex(where: { $0.id == preset.id }) { + mutateLoopSettings { settings in + settings.overridePresets[index] = preset + } + } + } + } + + func createPreset(_ preset: TemporaryScheduleOverridePreset) { + mutateLoopSettings { settings in + settings.overridePresets.append(preset) + } + } } +@MainActor protocol SettingsProvider { var settings: StoredSettings { get } @@ -344,15 +420,29 @@ protocol SettingsProvider { } extension SettingsManager: SettingsProvider { + var settings: StoredSettings { storedSettings } + func executeSettingsQuery(fromQueryAnchor queryAnchor: SettingsStore.QueryAnchor?, limit: Int, completion: @escaping (SettingsStore.SettingsQueryResult) -> Void) { - settingsStore.executeSettingsQuery(fromQueryAnchor: queryAnchor, limit: limit, completion: completion) + settingsStore!.executeSettingsQuery(fromQueryAnchor: queryAnchor, limit: limit, completion: completion) + } +} + +extension SettingsManager { + static var placeholder: SettingsManager { + .init( + cacheStore: .controllerInLocalDirectory(), + expireAfter: .hours(1), + alertMuter: .init() + ) } } // MARK: - SettingsStoreDelegate extension SettingsManager: SettingsStoreDelegate { - func settingsStoreHasUpdatedSettingsData(_ settingsStore: SettingsStore) { - remoteDataServicesManager?.triggerUpload(for: .settings) + nonisolated func settingsStoreHasUpdatedSettingsData(_ settingsStore: SettingsStore) { + Task { + await remoteDataServicesManager?.triggerUpload(for: .settings) + } } } @@ -388,5 +478,3 @@ private extension NotificationSettings { ) } } - - diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 1b98f9fcaa..a88d51cf9a 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -16,6 +16,7 @@ protocol PresetActivationObserver: AnyObject { func presetDeactivated(context: TemporaryScheduleOverride.Context) } +@MainActor @Observable class TemporaryPresetsManager { @@ -23,20 +24,19 @@ class TemporaryPresetsManager { @ObservationIgnored private var settingsProvider: SettingsProvider - var overrideHistory: TemporaryScheduleOverrideHistory + var presetHistory: TemporaryScheduleOverrideHistory @ObservationIgnored private var presetActivationObservers: [PresetActivationObserver] = [] @ObservationIgnored private var overrideIntentObserver: NSKeyValueObservation? = nil - @MainActor init(settingsProvider: SettingsProvider) { self.settingsProvider = settingsProvider - self.overrideHistory = TemporaryScheduleOverrideHistoryContainer.shared.fetch() + self.presetHistory = TemporaryScheduleOverrideHistoryContainer.shared.fetch() TemporaryScheduleOverrideHistory.relevantTimeWindow = Bundle.main.localCacheDuration - scheduleOverride = overrideHistory.activeOverride(at: Date()) + scheduleOverride = presetHistory.activeOverride(at: Date()) if scheduleOverride?.context == .preMeal { preMealOverride = scheduleOverride @@ -48,7 +48,9 @@ class TemporaryPresetsManager { options: [.new], changeHandler: { [weak self] (defaults, change) in - self?.handleIntentOverrideAction(default: defaults, change: change) + Task { @MainActor in + self?.handleIntentOverrideAction(default: defaults, change: change) + } } ) } @@ -79,6 +81,7 @@ class TemporaryPresetsManager { public var scheduleOverride: TemporaryScheduleOverride? { didSet { + print("didSet scheduleOverride called: \(scheduleOverride)") guard oldValue != scheduleOverride else { return } @@ -88,7 +91,7 @@ class TemporaryPresetsManager { } if scheduleOverride != oldValue { - overrideHistory.recordOverride(scheduleOverride) + presetHistory.recordOverride(scheduleOverride) if let oldPreset = oldValue { for observer in self.presetActivationObservers { @@ -122,7 +125,7 @@ class TemporaryPresetsManager { scheduleOverride = nil } - overrideHistory.recordOverride(preMealOverride) + presetHistory.recordOverride(preMealOverride) if let newPreset = preMealOverride { for observer in self.presetActivationObservers { @@ -145,18 +148,76 @@ class TemporaryPresetsManager { } } + public var activePreset: SelectablePreset? { + guard let override = activeOverride else { + return nil + } + + let range = override.settings.targetRange + + switch override.context { + case .preMeal: + return .preMeal(range: range!) + case .legacyWorkout: + return .legacyWorkout(range: range!, duration: override.duration.presetDurationType) + case .custom: + let preset = TemporaryScheduleOverridePreset( + id: override.syncIdentifier, + symbol: "", + name: "Single Use Preset", + settings: override.settings, + duration: override.duration + ) + return .custom(preset) + case .preset(let preset): + return .custom(preset) + } + } + + var selectablePresets: [SelectablePreset] { + var presets: [SelectablePreset] = [] + + let settings = settingsProvider.settings + + if let activeOverride, activeOverride.context == .custom { + presets.append(activePreset!) + } + + if let preMealTargetRange = settings.preMealTargetRange { + presets.append(.preMeal(range: preMealTargetRange)) + } + + if let legacyWorkoutTargetRange = settings.workoutTargetRange { + let duration = settings.workoutDefaultDuration ?? .indefinite + presets.append(.legacyWorkout( + range: legacyWorkoutTargetRange, + duration: duration.presetDurationType + )) + } + + presets.append(contentsOf: settings.overridePresets.map { .custom($0)} ) + + return presets + } + + + var clearOverrideTimer: Timer? public func scheduleClearOverride(override: TemporaryScheduleOverride) { clearOverrideTimer?.invalidate() clearOverrideTimer = Timer.scheduledTimer(withTimeInterval: override.scheduledEndDate.timeIntervalSince(Date()), repeats: false, block: { [weak self] _ in - if override == self?.scheduleOverride { - self?.clearOverride() - } else if override == self?.preMealOverride { - self?.clearOverride(matching: .preMeal) - } + Task { await self?.endOverride(override) } }) } - + + func endOverride(_ override: TemporaryScheduleOverride) { + if override == scheduleOverride { + clearOverride() + } else if override == preMealOverride { + clearOverride(matching: .preMeal) + } + } + public var isScheduleOverrideInfiniteWorkout: Bool { guard let scheduleOverride = scheduleOverride else { return false } return scheduleOverride.context == .legacyWorkout && scheduleOverride.duration.isInfinite @@ -245,7 +306,26 @@ class TemporaryPresetsManager { syncIdentifier: UUID() ) } - + + func startPreset(_ preset: SelectablePreset) { + switch preset { + case .custom(let temporaryScheduleOverridePreset): + scheduleOverride = temporaryScheduleOverridePreset.createOverride(enactTrigger: .local) + case .preMeal: + enablePreMealOverride(for: .hours(1)) + case .legacyWorkout(_, let duration): + enableLegacyWorkoutOverride(for: duration.presetDuration) + } + } + + func endPreset() { + if activeOverride?.context == .preMeal { + clearOverride(matching: .preMeal) + } else { + clearOverride() + } + } + public func endPreMealOverride() { preMealOverride?.scheduledEndDate = .now clearOverride(matching: .preMeal) @@ -270,7 +350,7 @@ class TemporaryPresetsManager { public var basalRateScheduleApplyingOverrideHistory: BasalRateSchedule? { if let basalSchedule = settingsProvider.settings.basalRateSchedule { - return overrideHistory.resolvingRecentBasalSchedule(basalSchedule) + return presetHistory.resolvingRecentBasalSchedule(basalSchedule) } else { return nil } @@ -279,7 +359,7 @@ class TemporaryPresetsManager { /// 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) + return presetHistory.resolvingRecentInsulinSensitivitySchedule(insulinSensitivitySchedule) } else { return nil } @@ -287,7 +367,7 @@ class TemporaryPresetsManager { public var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { if let carbRatioSchedule = carbRatioSchedule { - return overrideHistory.resolvingRecentCarbRatioSchedule(carbRatioSchedule) + return presetHistory.resolvingRecentCarbRatioSchedule(carbRatioSchedule) } else { return nil } @@ -301,8 +381,8 @@ class TemporaryPresetsManager { ] ) } - - func updateActiveOverrideDuration(newEndDate: Date) { + + func updateActivePresetDuration(newEndDate: Date) { if var scheduleOverride { if newEndDate > Date() { scheduleOverride.scheduledEndDate = newEndDate @@ -314,8 +394,36 @@ class TemporaryPresetsManager { self.scheduleClearOverride(override: scheduleOverride) } } + + var lastUsed: [String: Date]? + + func lastUsed(id: String) -> Date? { + if lastUsed == nil { + let enacts = presetHistory.getOverrideHistory(startDate: .distantPast, endDate: Date()) + lastUsed = [:] + for enact in enacts { + var id: String + switch enact.context { + case .preMeal: id = "preMeal" + case .legacyWorkout: id = "legacyWorkout" + case .preset(let preset): id = preset.id.uuidString + case .custom: continue + } + lastUsed![id] = max(lastUsed![id] ?? .distantPast, enact.startDate) + } + } + return lastUsed![id] + } + +} + +extension TemporaryPresetsManager { + static var placeholder: TemporaryPresetsManager { + .init(settingsProvider: SettingsManager.placeholder) + } } +@MainActor public protocol SettingsWithOverridesProvider { var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { get } var carbRatioSchedule: CarbRatioSchedule? { get } diff --git a/Loop/Models/SelectablePreset.swift b/Loop/Models/SelectablePreset.swift new file mode 100644 index 0000000000..0d20573610 --- /dev/null +++ b/Loop/Models/SelectablePreset.swift @@ -0,0 +1,272 @@ +// +// SelectablePreset.swift +// Loop +// +// Created by Pete Schwamb on 3/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKit +import SwiftUI +import LoopAlgorithm + +enum PresetDuration: Equatable { + case untilCarbsEntered + case duration(TimeInterval) + case indefinite + + var presetDuration: TemporaryScheduleOverride.Duration { + switch self { + case .indefinite: return .indefinite + case .duration(let duration): return .finite(duration) + case .untilCarbsEntered: return .indefinite + } + } +} + +enum PresetExpectedEndTime { + case untilCarbsEntered + case scheduled(Date) + case indefinite +} + +extension TemporaryScheduleOverride.Duration { + var presetDurationType: PresetDuration { + switch self { + case .finite(let interval): + return .duration(interval) + case .indefinite: + return .indefinite + } + } +} + +extension TemporaryScheduleOverride { + var expectedEndTime: PresetExpectedEndTime? { + switch context { + case .preMeal: return .untilCarbsEntered + case .legacyWorkout, .custom, .preset: + switch duration { + case .indefinite: return .indefinite + case .finite: return .scheduled(scheduledEndDate) + } + } + } + + var presetId: String { + switch context { + case .preMeal: return "preMeal" + case .legacyWorkout: return "legacyWorkout" + case .custom: return self.syncIdentifier.uuidString + case .preset(let preset): return preset.id.uuidString + } + } +} + +enum PresetIcon { + case emoji(String) + case image(String, Color) +} + +typealias RangeSafetyClassification = (lower: SafetyClassification, upper: SafetyClassification) + +extension PresetDuration: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .indefinite: + hasher.combine("indefinite") + case .untilCarbsEntered: + hasher.combine("untilCarbsEntered") + case .duration(let interval): + hasher.combine("duration") + hasher.combine(interval) + } + } +} + +enum SelectablePreset: Hashable, Identifiable { + + case custom(TemporaryScheduleOverridePreset) + case preMeal(range: ClosedRange) + case legacyWorkout(range: ClosedRange, duration: PresetDuration) + + func hash(into hasher: inout Hasher) { + switch self { + case .custom(let preset): + hasher.combine(preset) + case .legacyWorkout(let range, let duration): + hasher.combine("legacyWorkout") + hasher.combine(range) + hasher.combine(duration) + case .preMeal(let range): + hasher.combine("preMeal") + hasher.combine(range) + } + } + + static func == (lhs: SelectablePreset, rhs: SelectablePreset) -> Bool { + switch (lhs, rhs) { + case (.custom(let lhsPreset), .custom(let rhsPreset)): + return lhsPreset == rhsPreset + case (.legacyWorkout(let lhsRange, let lhsDuration), .legacyWorkout(let rhsRange, let rhsDuration)): + return lhsRange == rhsRange && lhsDuration == rhsDuration + case (.preMeal(let lhsRange), .preMeal(let rhsRange)): + return lhsRange == rhsRange + default: + return false + } + } + + var id: String { + switch self { + case .custom(let preset): return preset.id.uuidString + case .legacyWorkout: return "legacyWorkout" + case .preMeal: return "preMeal" + } + } + + var icon: PresetIcon { + switch self { + case .custom(let preset): return .emoji(preset.symbol) + case .preMeal: return .image("Pre-Meal", .carbTintColor) + case .legacyWorkout: return .image("workout", .glucoseTintColor) + } + } + + var duration: PresetDuration { + get { + switch self { + case .custom(let preset): + switch preset.duration { + case .indefinite: + return .indefinite + case .finite(let duration): + return .duration(duration) + } + case .preMeal: return .untilCarbsEntered + case .legacyWorkout(_, let duration): + return duration + } + } + set { + switch self { + case .preMeal(let range): + self = .preMeal(range: range) + case .legacyWorkout(let range, _): + self = .legacyWorkout(range: range, duration: newValue) + case .custom(var preset): + preset.settings = TemporaryScheduleOverrideSettings(targetRange: preset.settings.targetRange, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) + } + } + } + + var name: String { + get { + switch self { + case .custom(let preset): return preset.name + case .preMeal: return "Pre-Meal" + case .legacyWorkout: return "Workout" + } + } + set { + switch self { + case .custom(var preset): preset.name = newValue; self = .custom(preset) + default: break + } + } + } + + var correctionRange: ClosedRange? { + get { + switch self { + case .custom(let preset): return preset.settings.targetRange + case .preMeal(let range): return range + case .legacyWorkout(let range, _): return range + } + } + + set { + switch self { + case .preMeal: + self = .preMeal(range: newValue!) + case .legacyWorkout(_, let duration): + self = .legacyWorkout(range: newValue!, duration: duration) + case .custom(var preset): + preset.settings = TemporaryScheduleOverrideSettings(targetRange: newValue, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) + } + } + } + + var insulinSensitivityMultiplier: Double? { + if case .custom(let preset) = self { + return preset.settings.insulinSensitivityMultiplier + } else { + return nil + } + } + + var canAdjustSensitivity: Bool { + switch self { + case .custom: + return true + case .preMeal, .legacyWorkout: + return false + } + } + + var canAdjustDuration: Bool { + switch self { + case .custom, .legacyWorkout: + return true; + case .preMeal: + return false; + } + } + + var canChangeName: Bool { + switch self { + case .custom: + return true; + case .preMeal, .legacyWorkout: + return false; + } + } + + + var isPreMeal: Bool { + if case .preMeal = self { + return true + } + return false + } + + var dateCreated: Date { + switch self { + case .custom: + return .distantPast // TODO + case .preMeal: + return .distantPast.addingTimeInterval(1) + case .legacyWorkout: + return .distantPast + } + } + + func title(font: Font, iconSize: Double) -> some View { + HStack(spacing: 6) { + switch icon { + case .emoji(let emoji): + Text(emoji) + case .image(let name, let iconColor): + Image(name) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(iconColor) + .frame(width: UIFontMetrics.default.scaledValue(for: iconSize), height: UIFontMetrics.default.scaledValue(for: iconSize)) + } + + Text(name) + .font(font) + .fontWeight(.semibold) + } + } +} diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 17688d78f3..b204db83d0 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -735,6 +735,7 @@ final class StatusTableViewController: LoopChartsTableViewController { private var canceledDose: DoseEntry? = nil private func determinePresetsRowMode() -> PresetsRowMode { + print("temporaryPresetsManager.scheduleOverride = \(String(describing: temporaryPresetsManager.scheduleOverride))") if let preset = temporaryPresetsManager.scheduleOverride ?? temporaryPresetsManager.preMealOverride, !preset.hasFinished() { return .scheduleOverrideEnabled(preset) } else { @@ -962,11 +963,11 @@ final class StatusTableViewController: LoopChartsTableViewController { case .preset(let preset): cell.titleLabel.text = String(format: NSLocalizedString("%@ %@", comment: "The format for an active custom preset. (1: preset symbol)(2: preset name)"), preset.symbol, preset.name) case .custom: - cell.titleLabel.text = NSLocalizedString("Custom Preset", comment: "The title of the cell indicating a generic custom preset is enabled") + cell.titleLabel.text = NSLocalizedString("Single Use Preset", comment: "The title of the cell indicating a generic custom preset is enabled") } if override.isActive() { - if let preset = statusTableViewModel.settingsViewModel.presetsViewModel.allPresets.first(where: { $0.id == override.presetId }), case .preMeal(_, _) = preset { + if let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == override.presetId }), case .preMeal(_) = preset { cell.subtitleLabel.text = NSLocalizedString("on until carbs added", comment: "The format for the description of a premeal preset end date") } else { switch override.duration { @@ -1268,7 +1269,7 @@ final class StatusTableViewController: LoopChartsTableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch Section(rawValue: indexPath.section)! { case .presets: - statusTableViewModel.pendingPreset = statusTableViewModel.settingsViewModel.presetsViewModel.activePreset + statusTableViewModel.pendingPreset = temporaryPresetsManager.activePreset case .alertWarning: if alertPermissionsChecker.showWarning { tableView.deselectRow(at: indexPath, animated: true) @@ -1423,7 +1424,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } vc.presets = loopManager.settings.overridePresets vc.glucoseUnit = statusCharts.glucose.glucoseUnit - vc.overrideHistory = temporaryPresetsManager.overrideHistory.getEvents() + vc.overrideHistory = temporaryPresetsManager.presetHistory.getEvents() vc.delegate = self case let vc as PredictionTableViewController: vc.deviceManager = deviceManager @@ -1520,13 +1521,15 @@ final class StatusTableViewController: LoopChartsTableViewController { func presentPresets() { let hostingController = DismissibleHostingController( - rootView: PresetsView(viewModel: statusTableViewModel.settingsViewModel.presetsViewModel) + rootView: PresetsView() .onAppear { self.isShowingPresets = true } .onDisappear { self.isShowingPresets = false } .environmentObject(deviceManager.displayGlucosePreference) .environment(\.appName, Bundle.main.bundleDisplayName) .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) - .environment(\.loopStatusColorPalette, .loopStatus), + .environment(\.loopStatusColorPalette, .loopStatus) + .environment(\.temporaryPresetsManager, temporaryPresetsManager) + .environment(\.settingsManager, settingsManager), isModalInPresentation: false) present(hostingController, animated: true) } @@ -1541,7 +1544,10 @@ final class StatusTableViewController: LoopChartsTableViewController { .environmentObject(deviceManager.displayGlucosePreference) .environment(\.appName, Bundle.main.bundleDisplayName) .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) - .environment(\.loopStatusColorPalette, .loopStatus), + .environment(\.loopStatusColorPalette, .loopStatus) + .environment(\.settingsManager, settingsManager) + .environment(\.temporaryPresetsManager, temporaryPresetsManager), + isModalInPresentation: false) present(hostingController, animated: true) } diff --git a/Loop/View Models/PresetsViewModel.swift b/Loop/View Models/PresetsViewModel.swift deleted file mode 100644 index e2d0a87a34..0000000000 --- a/Loop/View Models/PresetsViewModel.swift +++ /dev/null @@ -1,473 +0,0 @@ -// -// PresetsViewModel.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopAlgorithm -import LoopKit - -enum PresetDurationType: Equatable { - case untilCarbsEntered - case duration(TimeInterval) - case indefinite - - var presetDuration: TemporaryScheduleOverride.Duration { - switch self { - case .indefinite: return .indefinite - case .duration(let duration): return .finite(duration) - case .untilCarbsEntered: return .indefinite - } - } -} - -enum PresetExpectedEndTime { - case untilCarbsEntered - case scheduled(Date) - case indefinite -} - -extension TemporaryScheduleOverride.Duration { - var presetDurationType: PresetDurationType { - switch self { - case .finite(let interval): - return .duration(interval) - case .indefinite: - return .indefinite - } - } -} - -extension TemporaryScheduleOverride { - var expectedEndTime: PresetExpectedEndTime? { - switch context { - case .preMeal: return .untilCarbsEntered - case .legacyWorkout, .custom, .preset: - switch duration { - case .indefinite: return .indefinite - case .finite: return .scheduled(scheduledEndDate) - } - } - } - - var presetId: String { - switch context { - case .preMeal: return "preMeal" - case .legacyWorkout: return "legacyWorkout" - case .custom: return self.syncIdentifier.uuidString - case .preset(let preset): return preset.id.uuidString - } - } -} - -enum PresetIcon { - case emoji(String) - case image(String, Color) -} - -typealias RangeSafetyClassification = (lower: SafetyClassification, upper: SafetyClassification) - -extension PresetDurationType: Hashable { - func hash(into hasher: inout Hasher) { - switch self { - case .indefinite: - hasher.combine("indefinite") - case .untilCarbsEntered: - hasher.combine("untilCarbsEntered") - case .duration(let interval): - hasher.combine("duration") - hasher.combine(interval) - } - } -} - -enum SelectablePreset: Hashable, Identifiable { - - func hash(into hasher: inout Hasher) { - switch self { - case .custom(let preset): - hasher.combine(preset) - case .legacyWorkout(let range, let duration, _): - hasher.combine("legacyWorkout") - hasher.combine(range) - hasher.combine(duration) - case .preMeal(let range, _): - hasher.combine("preMeal") - hasher.combine(range) - } - } - - static func == (lhs: SelectablePreset, rhs: SelectablePreset) -> Bool { - switch (lhs, rhs) { - case (.custom(let lhsPreset), .custom(let rhsPreset)): - return lhsPreset == rhsPreset - case (.legacyWorkout(let lhsRange, let lhsDuration, _), .legacyWorkout(let rhsRange, let rhsDuration, _)): - return lhsRange == rhsRange && lhsDuration == rhsDuration - case (.preMeal(let lhsRange, _), .preMeal(let rhsRange, _)): - return lhsRange == rhsRange - default: - return false - } - } - - var id: String { - switch self { - case .custom(let preset): return preset.id.uuidString - case .legacyWorkout: return "legacyWorkout" - case .preMeal: return "preMeal" - } - } - - case custom(TemporaryScheduleOverridePreset) - case preMeal(range: ClosedRange, guardrail: Guardrail) - case legacyWorkout(range: ClosedRange, duration: PresetDurationType, guardrail: Guardrail) - - var icon: PresetIcon { - switch self { - case .custom(let preset): return .emoji(preset.symbol) - case .preMeal: return .image("Pre-Meal", .carbTintColor) - case .legacyWorkout: return .image("workout", .glucoseTintColor) - } - } - - var duration: PresetDurationType { - get { - switch self { - case .custom(let preset): - switch preset.duration { - case .indefinite: - return .indefinite - case .finite(let duration): - return .duration(duration) - } - case .preMeal: return .untilCarbsEntered - case .legacyWorkout(_, let duration, _): - return duration - } - } - set { - switch self { - case .preMeal(let range, let guardrail): - self = .preMeal(range: range, guardrail: guardrail) - case .legacyWorkout(let range, _, let guardrail): - self = .legacyWorkout(range: range, duration: newValue, guardrail: guardrail) - case .custom(var preset): - preset.settings = TemporaryScheduleOverrideSettings(targetRange: preset.settings.targetRange, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) - } - } - } - - var name: String { - switch self { - case .custom(let preset): return preset.name - case .preMeal: return "Pre-Meal" - case .legacyWorkout: return "Workout" - } - } - - var correctionRange: ClosedRange? { - get { - switch self { - case .custom(let preset): return preset.settings.targetRange - case .preMeal(let range, _): return range - case .legacyWorkout(let range, _, _): return range - } - } - - set { - switch self { - case .preMeal(_, let guardrail): - self = .preMeal(range: newValue!, guardrail: guardrail) - case .legacyWorkout(_, let duration, let guardrail): - self = .legacyWorkout(range: newValue!, duration: duration, guardrail: guardrail) - case .custom(var preset): - preset.settings = TemporaryScheduleOverrideSettings(targetRange: newValue, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) - } - } - } - - var insulinSensitivityMultiplier: Double? { - if case .custom(let preset) = self { - return preset.settings.insulinSensitivityMultiplier - } else { - return nil - } - } - - var canAdjustSensitivity: Bool { - switch self { - case .custom: - return true - case .preMeal: - return false - case .legacyWorkout: - return false - } - } - - var canAdjustDuration: Bool { - switch self { - case .custom: - return true; - case .preMeal: - return false; - case .legacyWorkout: - return true; - } - } - - var isPreMeal: Bool { - if case .preMeal = self { - return true - } - return false - } - - var guardrail: Guardrail { - switch self { - case .custom: - return Guardrail.correctionRange - case .preMeal(_, let guardrail): - return guardrail - case .legacyWorkout(_, _, let guardrail): - return guardrail - } - } - - var dateCreated: Date { - switch self { - case .custom: - return .distantPast // TODO - case .preMeal: - return .distantPast.addingTimeInterval(1) - case .legacyWorkout: - return .distantPast - } - } - - func title(font: Font, iconSize: Double) -> some View { - HStack(spacing: 6) { - switch icon { - case .emoji(let emoji): - Text(emoji) - case .image(let name, let iconColor): - Image(name) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(iconColor) - .frame(width: UIFontMetrics.default.scaledValue(for: iconSize), height: UIFontMetrics.default.scaledValue(for: iconSize)) - } - - Text(name) - .font(font) - .fontWeight(.semibold) - } - } -} - -@MainActor -@Observable -public class PresetsViewModel { - - // MARK: Training - - // This double property is needed to allow AppStorage to be observed - @ObservationIgnored @AppStorage("hasCompletedPresetsTraining") private var _hasCompletedTraining: Bool = false - @ObservationIgnored - var hasCompletedTraining: Bool { - get { - access(keyPath: \.hasCompletedTraining) - return _hasCompletedTraining - } - set { - withMutation(keyPath: \.hasCompletedTraining) { - _hasCompletedTraining = newValue - } - } - } - - // This double property is needed to allow AppStorage to be observed - @ObservationIgnored @AppStorage("presetsSortOrder") private var _selectedSortOption: PresetSortOption = .name - @ObservationIgnored - var selectedSortOption: PresetSortOption { - get { - access(keyPath: \.selectedSortOption) - return _selectedSortOption - } - set { - withMutation(keyPath: \.selectedSortOption) { - _selectedSortOption = newValue - } - } - } - - // This double property is needed to allow AppStorage to be observed - @ObservationIgnored @AppStorage("presetsSortDirectionReversed") private var _presetsSortAscending: Bool = true - @ObservationIgnored - var presetsSortAscending: Bool { - get { - access(keyPath: \.selectedSortOption) - return _presetsSortAscending - } - set { - withMutation(keyPath: \.selectedSortOption) { - _presetsSortAscending = newValue - } - } - } - - @ObservationIgnored var premealRange: ClosedRange? - @ObservationIgnored var workoutRange: ClosedRange? - @ObservationIgnored var workoutDuration: TemporaryScheduleOverride.Duration - - let temporaryPresetsManager: TemporaryPresetsManager - - var customPresets: [TemporaryScheduleOverridePreset] - var pendingPreset: SelectablePreset? - var editPreset: [String] = [] - - public private(set) var preMealGuardrail: Guardrail - public private(set) var legacyWorkoutGuardrail: Guardrail - - private var presetHistory: TemporaryScheduleOverrideHistory - - var scheduledRange: ClosedRange - - var activeOverride: TemporaryScheduleOverride? { - temporaryPresetsManager.preMealOverride ?? temporaryPresetsManager.scheduleOverride - } - - var activePreset: SelectablePreset? { - return allPresets.first(where: { $0.id == temporaryPresetsManager.activeOverride?.presetId }) - } - - var allPresets: [SelectablePreset] { - var presets: [SelectablePreset] = [] - - if let preMealTargetRange = premealRange { - presets.append(.preMeal( - range: preMealTargetRange, - guardrail: preMealGuardrail - )) - } - - if let legacyWorkoutTargetRange = workoutRange { - presets.append(.legacyWorkout( - range: legacyWorkoutTargetRange, - duration: workoutDuration.presetDurationType, - guardrail: legacyWorkoutGuardrail - )) - } - - presets.append(contentsOf: customPresets.map { .custom($0)} ) - - return presets - } - - var lastUsed: [String: Date]? - - func lastUsed(id: String) -> Date? { - if lastUsed == nil { - let enacts = presetHistory.getOverrideHistory(startDate: .distantPast, endDate: Date()) - lastUsed = [:] - for enact in enacts { - var id: String - switch enact.context { - case .preMeal: id = "preMeal" - case .legacyWorkout: id = "legacyWorkout" - case .preset(let preset): id = preset.id.uuidString - case .custom: continue - } - lastUsed![id] = max(lastUsed![id] ?? .distantPast, enact.startDate) - } - } - return lastUsed![id] - } - - var presetWasEdited: ((SelectablePreset) throws -> Void)?; - - var impactForPreset: (SelectablePreset) -> TherapySettings.InsulinMultiplierImpact - - init( - customPresets: [TemporaryScheduleOverridePreset], - premealRange: ClosedRange?, - workoutRange: ClosedRange?, - workoutDuration: TemporaryScheduleOverride.Duration, - presetsHistory: TemporaryScheduleOverrideHistory, - preMealGuardrail: Guardrail, - legacyWorkoutGuardrail: Guardrail, - temporaryPresetsManager: TemporaryPresetsManager, - scheduledRange: ClosedRange, - impactForPreset: @escaping (SelectablePreset) -> TherapySettings.InsulinMultiplierImpact - ) { - self.customPresets = customPresets - self.premealRange = premealRange - self.workoutRange = workoutRange - self.workoutDuration = workoutDuration - self.presetHistory = presetsHistory - self.preMealGuardrail = preMealGuardrail - self.legacyWorkoutGuardrail = legacyWorkoutGuardrail - self.temporaryPresetsManager = temporaryPresetsManager - self.scheduledRange = scheduledRange - self.impactForPreset = impactForPreset - } - - convenience init( - therapySettings: TherapySettings, - temporaryPresetsManager: TemporaryPresetsManager, - presetsHistory: TemporaryScheduleOverrideHistory - ) { - self.init( - customPresets: therapySettings.overridePresets ?? [], - premealRange: therapySettings.correctionRangeOverrides?.preMeal, - workoutRange: therapySettings.correctionRangeOverrides?.workout, - workoutDuration: therapySettings.correctionRangeOverrides?.workoutDuration ?? .indefinite, - presetsHistory: presetsHistory, - preMealGuardrail: therapySettings.preMealGuardrail, - legacyWorkoutGuardrail: therapySettings.legacyWorkoutPresetGuardrail, - temporaryPresetsManager: temporaryPresetsManager, - scheduledRange: therapySettings.glucoseTargetRangeSchedule!.quantityRange(at: Date()), - impactForPreset: { therapySettings.impact(for: Double( 1 / ($0.insulinSensitivityMultiplier ?? 1))) } - ) - } - - func savePreset(_ preset: SelectablePreset) { - try? presetWasEdited?(preset); - - switch preset { - case .preMeal(let range, _): - self.premealRange = range; - case .legacyWorkout(let range, let duration, _): - self.workoutRange = range; - self.workoutDuration = duration.presetDuration; - default: - break - } - } - - func startPreset(_ preset: SelectablePreset) { - switch preset { - case .custom(let temporaryScheduleOverridePreset): - temporaryPresetsManager.scheduleOverride = temporaryScheduleOverridePreset.createOverride(enactTrigger: .local) - case .preMeal: - temporaryPresetsManager.enablePreMealOverride(for: .hours(1)) - case .legacyWorkout(_, let duration, _): - temporaryPresetsManager.enableLegacyWorkoutOverride(for: duration.presetDuration) - } - } - - func endPreset() { - if case .preMeal(_, _) = activePreset { - temporaryPresetsManager.clearOverride(matching: .preMeal) - } else { - temporaryPresetsManager.clearOverride() - } - } - - func updateActivePresetDuration(newEndDate: Date) { - temporaryPresetsManager.updateActiveOverrideDuration(newEndDate: newEndDate) - } -} diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index e626d5e249..d04a3f6fd1 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -88,8 +88,6 @@ class SettingsViewModel { private(set) var mostRecentGlucoseDataDate: Date? private(set) var mostRecentPumpDataDate: Date? - var presetsViewModel: PresetsViewModel - var closedLoopDescriptiveText: String? { return delegate?.closedLoopDescriptiveText } @@ -176,14 +174,6 @@ class SettingsViewModel { self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate self.presetHistory = presetHistory - self.presetsViewModel = PresetsViewModel( - therapySettings: therapySettings(), - temporaryPresetsManager: temporaryPresetsManager, - presetsHistory: presetHistory - ) - - self.presetsViewModel.presetWasEdited = savePreset - // This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject) lastLoopCompletion .assign(to: \.lastLoopCompletion, on: self) @@ -195,26 +185,6 @@ class SettingsViewModel { .assign(to: \.mostRecentPumpDataDate, on: self) .store(in: &cancellables) } - - func savePreset(_ preset: SelectablePreset) throws { - var therapySettings = therapySettings() - var preMealRange = therapySettings.correctionRangeOverrides?.ranges[.preMeal] - var workoutRange = therapySettings.correctionRangeOverrides?.ranges[.workout] - var workoutDuration = therapySettings.correctionRangeOverrides?.workoutDuration - - switch(preset) { - case .preMeal(let range, _): - preMealRange = range - case .legacyWorkout(let range, let duration, _): - workoutRange = range - workoutDuration = duration.presetDuration - default: - // TODO: editing of custom presets - break - } - therapySettings.correctionRangeOverrides = CorrectionRangeOverrides(preMeal: preMealRange, workout: workoutRange, workoutDuration: workoutDuration) - therapySettingsViewModelDelegate?.saveCompletion(therapySettings: therapySettings) - } } // For previews only diff --git a/Loop/View Models/StatusTableViewModel.swift b/Loop/View Models/StatusTableViewModel.swift new file mode 100644 index 0000000000..24d6f9102d --- /dev/null +++ b/Loop/View Models/StatusTableViewModel.swift @@ -0,0 +1,59 @@ +// +// StatusTableViewModel.swift +// Loop +// +// Created by Pete Schwamb on 3/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKit + +@MainActor +@Observable +class StatusTableViewModel { + let alertPermissionsChecker: AlertPermissionsChecker + let alertMuter: AlertMuter + let deviceDataManager: DeviceDataManager + let supportManager: SupportManager + let testingScenariosManager: TestingScenariosManager? + let loopDataManager: LoopDataManager + let diagnosticReportGenerator: DiagnosticReportGenerator + let simulatedData: SimulatedData + let analyticsServicesManager: AnalyticsServicesManager + let servicesManager: ServicesManager + let carbStore: CarbStore + let doseStore: DoseStore + let criticalEventLogExportManager: CriticalEventLogExportManager + let bluetoothStateManager: BluetoothStateManager + let settingsManager: SettingsManager + let automaticDosingStatus: AutomaticDosingStatus + let onboardingManager: OnboardingManager + let temporaryPresetsManager: TemporaryPresetsManager + let settingsViewModel: SettingsViewModel + let legacyPresetsEnabled: Bool + + var pendingPreset: SelectablePreset? + + init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, settingsViewModel: SettingsViewModel, legacyPresetsEnabled: Bool = false) { + self.alertPermissionsChecker = alertPermissionsChecker + self.alertMuter = alertMuter + self.automaticDosingStatus = automaticDosingStatus + self.deviceDataManager = deviceDataManager + self.onboardingManager = onboardingManager + self.supportManager = supportManager + self.testingScenariosManager = testingScenariosManager + self.temporaryPresetsManager = temporaryPresetsManager + self.settingsManager = settingsManager + self.loopDataManager = loopDataManager + self.diagnosticReportGenerator = diagnosticReportGenerator + self.simulatedData = simulatedData + self.analyticsServicesManager = analyticsServicesManager + self.servicesManager = servicesManager + self.carbStore = carbStore + self.doseStore = doseStore + self.criticalEventLogExportManager = criticalEventLogExportManager + self.bluetoothStateManager = bluetoothStateManager + self.settingsViewModel = settingsViewModel + self.legacyPresetsEnabled = legacyPresetsEnabled + } +} diff --git a/Loop/Views/Presets/Components/CardSection.swift b/Loop/Views/Presets/Components/CardSection.swift new file mode 100644 index 0000000000..e6cfe03ed4 --- /dev/null +++ b/Loop/Views/Presets/Components/CardSection.swift @@ -0,0 +1,68 @@ +// +// CardSection.swift +// Loop +// +// Created by Pete Schwamb on 3/6/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +// Simple rounded card view with arbitrary content. Can be used to make screens that look like grouped section table views, +// that need to animate height (List/TableViews have problems with resizing views and animating them). Similar to Card in +// LoopKitUI, but unlike Card, has requirement on the type of content except that it is a View. + +struct CardSection: View { + let header: Header? + let footer: Footer? + let content: Content + + // Initializer for custom view header + init(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> Header, @ViewBuilder footer: () -> Footer) { + self.content = content() + self.header = header() + self.footer = footer() + } + + // Initializer for string header + init(_ headerText: String? = nil, @ViewBuilder content: () -> Content, footerText: String? = nil) where Header == Text, Footer == Text { + self.content = content() + self.header = headerText.map { Text($0) } + self.footer = footerText.map { Text($0) } + } + + // Initializer for no header + init(@ViewBuilder content: () -> Content) where Header == Text, Footer == Text { + self.content = content() + self.header = nil + self.footer = nil + } + + var body: some View { + VStack(alignment: .leading) { + if let header = header { + header + .font(.footnote) + .textCase(.uppercase) + .foregroundStyle(.secondary) + } + VStack { + content + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(RoundedRectangle(cornerRadius: 10) + .fill(Color(UIColor.tertiarySystemBackground)) + .frame(maxWidth: .infinity)) + .clipped() + if let footer = footer { + footer + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.leading) + } + } + .padding(.top, 10) + } +} + diff --git a/Loop/Views/Presets/Components/CardSectionScrollView.swift b/Loop/Views/Presets/Components/CardSectionScrollView.swift new file mode 100644 index 0000000000..4a93d3c7f8 --- /dev/null +++ b/Loop/Views/Presets/Components/CardSectionScrollView.swift @@ -0,0 +1,50 @@ +// +// CardSectionScrollView.swift +// Loop +// +// Created by Pete Schwamb on 3/7/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +// Container designed to hold CardSection views in a scrollview, and an optional action area +// that the scrollview would flow under, with a shadow effect. Together, they replace a List (TableView) +// with grouped styling, and allow rows to have their height animated as expected, avoiding the animation +// issues that resizing rows in Lists presents. + +import SwiftUI + +struct CardSectionScrollView: View { + let content: Content + let actionArea: ActionArea? + + // Initializer for custom view header + init(@ViewBuilder content: () -> Content, @ViewBuilder actionArea: () -> ActionArea) { + self.content = content() + self.actionArea = actionArea() + } + + // Initializer for no action area + init(@ViewBuilder content: () -> Content) where ActionArea == Text { + self.content = content() + self.actionArea = nil + } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading) { + content + } + .padding() + } + if let actionArea { + VStack(spacing: 0) { + actionArea + } + .background(Color(.secondarySystemGroupedBackground).shadow(radius: 5)) + } + } + .background(Color(.systemGroupedBackground)) + .edgesIgnoringSafeArea(actionArea != nil ? .bottom : []) + } +} diff --git a/Loop/Views/Presets/Components/CorrectionRangePreview.swift b/Loop/Views/Presets/Components/CorrectionRangePreview.swift new file mode 100644 index 0000000000..ae15787a9e --- /dev/null +++ b/Loop/Views/Presets/Components/CorrectionRangePreview.swift @@ -0,0 +1,126 @@ +// +// CorrectionRangePreview.swift +// Loop +// +// Created by Pete Schwamb on 3/7/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopAlgorithm +import LoopKit +import LoopKitUI + +public struct CorrectionRangePreview: View { + @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.guidanceColors) private var guidanceColors + + @Binding var range: ClosedRange? + var guardrail: Guardrail + private var scheduledRange: ClosedRange + @State private var editedRange: ClosedRange? + private var allowsScheduledRange: Bool + var showDisclosure: Bool + + init(range: Binding?>, guardrail: Guardrail, scheduledRange: ClosedRange, allowsScheduledRange: Bool = true, showDisclosure: Bool = false) { + self._range = range + self.editedRange = range.wrappedValue + self.guardrail = guardrail + self.scheduledRange = scheduledRange + self.allowsScheduledRange = allowsScheduledRange + self.showDisclosure = showDisclosure + } + + func boundText(for bound: LoopQuantity) -> Text { + let color = guardrail.color(for: bound, guidanceColors: guidanceColors) + let text = displayGlucosePreference.format(bound, includeUnit: false) + switch guardrail.classification(for: bound) { + case .withinRecommendedRange: + return Text(text) + .foregroundColor(.accentColor) + .font(.system(size: 34, weight: .bold)) + case .outsideRecommendedRange: + return ( + Text(text) + .foregroundColor(color) + .font(.system(size: 34, weight: .bold)) + ) + } + } + + func correctionRangeLabel(range: ClosedRange) -> Text { + boundText(for: range.lowerBound) + + Text("-").foregroundColor(.secondary) + .font(.system(size: 34, weight: .light)) + + + boundText(for: range.upperBound) + + Text(" ") + + Text(displayGlucosePreference.unit.localizedShortUnitString) + .font(.system(.body)) + .foregroundColor(.secondary) + .baselineOffset(12) + } + + private var correctionRangeCrossedThresholds: [SafetyClassification.Threshold] { + guard let range else { return [] } + + let thresholds: [SafetyClassification.Threshold] = [range.lowerBound, range.upperBound].compactMap { bound in + switch guardrail.classification(for: bound) { + case .withinRecommendedRange: + return nil + case .outsideRecommendedRange(let threshold): + return threshold + } + } + + return thresholds + } + + private var guardrailWarningIfNecessary: some View { + let crossedThresholds = self.correctionRangeCrossedThresholds + let severity = crossedThresholds.map { $0.severity }.max() + + return Group { + if let severity, !crossedThresholds.isEmpty { + let color: Color = severity > .default ? guidanceColors.critical : guidanceColors.warning + HStack(alignment: .top, spacing: 12) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + .foregroundColor(color) + Text(SafetyClassification.captionForCrossedThresholds(crossedThresholds, isRange: true)); + } + .padding(12) + .background(color.opacity(0.1)) + .cornerRadius(12) + } + } + } + + public var body: some View { + VStack(alignment: .center, spacing: 12) { + HStack { + Text("Correction Range") + .font(.headline) + Spacer() + if showDisclosure { + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } + }.padding(.bottom, 10) + VStack(spacing: 4) { + if let range { + correctionRangeLabel(range: range) + Text("Adjusted Range") + } else { + correctionRangeLabel(range: scheduledRange) + Text("Scheduled Range") + } + } + guardrailWarningIfNecessary + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.primary) + .padding(.bottom, 5) + .padding(.horizontal, 2) + } + .foregroundColor(.primary) + } +} diff --git a/Loop/Views/Presets/Components/DayPickerPopup.swift b/Loop/Views/Presets/Components/DayPickerPopup.swift new file mode 100644 index 0000000000..68475d037c --- /dev/null +++ b/Loop/Views/Presets/Components/DayPickerPopup.swift @@ -0,0 +1,61 @@ +// +// DayPickerPopup.swift +// Loop +// +// Created by Pete Schwamb on 3/11/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct DayPickerPopup: View { + @Binding var selectedDays: PresetScheduleRepeatOptions + + var body: some View { + VStack(spacing: 10) { + Text("Select Days") + .font(.headline) + + ForEach(PresetScheduleRepeatOptions.allCases, id: \.rawValue) { day in + MultipleSelectionRow( + day: day, + isSelected: selectedDays.contains(day) + ) { + toggleDay(day) + } + } + } + .padding() + .frame(width: 250) + } + + private func toggleDay(_ day: PresetScheduleRepeatOptions) { + if selectedDays.contains(day) { + selectedDays.remove(day) + } else { + selectedDays.insert(day) + } + } +} + +// Selection row (unchanged) +struct MultipleSelectionRow: View { + let day: PresetScheduleRepeatOptions + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Text(day.description) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + .padding(.vertical, 4) + } + .foregroundColor(.primary) + } +} diff --git a/Loop/Views/Presets/Components/EditOverrideDurationView.swift b/Loop/Views/Presets/Components/EditPresetDurationView.swift similarity index 58% rename from Loop/Views/Presets/Components/EditOverrideDurationView.swift rename to Loop/Views/Presets/Components/EditPresetDurationView.swift index b5f6314c42..2c9b65a093 100644 --- a/Loop/Views/Presets/Components/EditOverrideDurationView.swift +++ b/Loop/Views/Presets/Components/EditPresetDurationView.swift @@ -10,37 +10,24 @@ import LoopKit import LoopKitUI import SwiftUI -struct EditOverrideDurationView: View { - +struct EditPresetDurationView: View { + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager + @Environment(\.settingsManager) private var settingsManager + @Environment(\.dismiss) private var dismiss - let viewModel: PresetsViewModel - let override: TemporaryScheduleOverride - - @State var dateSelection: Date + @State var dateSelection: Date = Date() + + private let currentDate: Date = Date() - private let currentDate: Date - - init(override: TemporaryScheduleOverride, viewModel: PresetsViewModel) { - self.override = override - self.viewModel = viewModel - self.currentDate = Date() - - if case let .finite(timeInterval) = viewModel.temporaryPresetsManager.activeOverride?.duration { - dateSelection = override.startDate.addingTimeInterval(timeInterval) - } else { - dateSelection = currentDate - } - } - var preset: SelectablePreset? { - viewModel.allPresets.first(where: { $0.id == override.presetId }) + temporaryPresetsManager.selectablePresets.first { $0.id == temporaryPresetsManager.activeOverride?.presetId } } - + var buttonDisabled: Bool { - if case .duration = viewModel.activePreset?.duration { - return dateSelection == override.actualEndDate - } else if case .indefinite = viewModel.activePreset?.duration { + if case .finite = temporaryPresetsManager.activeOverride?.duration { + return dateSelection == temporaryPresetsManager.activeOverride?.actualEndDate + } else if case .indefinite = temporaryPresetsManager.activeOverride?.duration { return false } else { return dateSelection == currentDate @@ -68,7 +55,7 @@ struct EditOverrideDurationView: View { .padding(.horizontal) Button("Save") { - viewModel.updateActivePresetDuration(newEndDate: dateSelection) + temporaryPresetsManager.updateActivePresetDuration(newEndDate: dateSelection) dismiss() } .buttonStyle(ActionButtonStyle()) @@ -77,5 +64,14 @@ struct EditOverrideDurationView: View { .disabled(buttonDisabled) } } + .onAppear { + if let activeOverride = temporaryPresetsManager.activeOverride { + if case let .finite(timeInterval) = activeOverride.duration { + dateSelection = activeOverride.startDate.addingTimeInterval(timeInterval) + } else { + dateSelection = currentDate + } + } + } } } diff --git a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift new file mode 100644 index 0000000000..c7e0c85c74 --- /dev/null +++ b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift @@ -0,0 +1,178 @@ +// +// InsulinScaleAdjustView.swift +// Loop +// +// Created by Pete Schwamb on 3/10/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopAlgorithm +import LoopKitUI + +public struct InsulinScaleAdjustView: View { + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.settingsManager) private var settingsManager + + @State private var presentInfoView: Bool = false + + @Binding var insulinMultiplier: Double + + var insulinPercentage: Double { + get { return (insulinMultiplier * 100).rounded() } + } + + var basalRate: Double? { + if let baseValue = settingsManager.settings.basalRateSchedule?.value(at: Date()) { + return baseValue * insulinMultiplier + } else { + return nil + } + } + var carbRatio: Double? { + if let baseValue = settingsManager.settings.carbRatioSchedule?.value(at: Date()) { + return baseValue / insulinMultiplier + } else { + return nil + } + } + var isf: LoopQuantity? { + if let baseQuantity = settingsManager.settings.insulinSensitivitySchedule?.quantity(at: Date()) { + let value = baseQuantity.doubleValue(for: .milligramsPerDeciliter) + let adjustedValue = value / insulinMultiplier + return LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: adjustedValue) + } else { + return nil + } + } + + public var body: some View { + // Header Section + VStack(spacing: 16) { + HStack { + Text("Overall Insulin Needs") + .foregroundColor(.secondary) + .font(.subheadline) + .padding(.vertical) + + Button(action: { + presentInfoView = true; + }) { + Image(systemName: "info.circle") + } + .buttonStyle(BorderlessButtonStyle()) + } + + Text("Set your overall insulin needs") + .font(.title2) + .fontWeight(.bold) + + Text("Use the + and - buttons to set whether you need") + + Text(" more ").fontWeight(.bold) + + Text("or") + + Text(" less ").fontWeight(.bold) + + Text("insulin than usual.") + + adjustInsulinControls + + Divider() + + settingsImpact + + } + .multilineTextAlignment(.center) + .sheet(isPresented: $presentInfoView) { + InsulinScaleInformationView() + } + } + + private var adjustInsulinControls: some View { + HStack(spacing: 24) { + Button(action: { + if insulinPercentage > 10 { + insulinMultiplier = (insulinPercentage - 5) / 100 + } + }) { + Text(Image(systemName: "minus.circle.fill").symbolRenderingMode(.hierarchical)) + .font(.system(size: 44, weight: .bold)) + .foregroundColor(.insulin) + } + .buttonStyle(BorderlessButtonStyle()) + + + Text("\(Int(insulinPercentage))%") + .font(.system(size: 50, weight: .bold)) + .foregroundColor(.insulin) + + Button(action: { + if insulinPercentage < 200 { + insulinMultiplier = (insulinPercentage + 5) / 100 + } + }) { + Text(Image(systemName: "plus.circle.fill").symbolRenderingMode(.hierarchical)) + .font(.system(size: 44, weight: .bold)) + .foregroundColor(.insulin) + } + .buttonStyle(BorderlessButtonStyle()) + } + + } + + private var settingsImpact: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Settings Impact") + .font(.headline) + + if insulinPercentage < 100 { + Text("This adjustment will make your settings weaker.") + } else if (insulinPercentage > 100) { + Text("This adjustment will make your settings stronger.") + } else { + Text("No change to insulin settings.") + } + } + + exampleSettings + + // Footer Note + Text("Note: These example values are based on your current settings. Values may be different when you enable the preset.") + .font(.footnote) + .foregroundColor(.secondary) + } + .font(.subheadline) + .multilineTextAlignment(.leading) + } + + private var exampleSettings: some View { + Group { + if let basalRate = basalRate, let carbRatio = carbRatio, let isf = isf { + HStack(spacing: 32) { + SettingAdjustmentPreview( + value: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: basalRate), + displayUnit: .internationalUnitsPerHour, + name: "Basal Rate", + highlighted: insulinPercentage != 100 + ) + .frame(maxWidth: .infinity) + + SettingAdjustmentPreview( + value: LoopQuantity(unit: .gram, doubleValue: carbRatio), + displayUnit: .gram, + name: "Carb Ratio", + highlighted: insulinPercentage != 100 + ) + .frame(maxWidth: .infinity) + + SettingAdjustmentPreview( + value: isf, + displayUnit: displayGlucosePreference.unit, + name: "ISF", + highlighted: insulinPercentage != 100 + ) + .frame(maxWidth: .infinity) + } + } + } + } +} diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index d122418aea..d66509a451 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -18,7 +18,7 @@ struct PresetCard: View { let icon: PresetIcon let presetName: String - let duration: PresetDurationType + let duration: PresetDuration let insulinSensitivityMultiplier: Double? let correctionRange: ClosedRange? let guardrail: Guardrail? @@ -121,7 +121,7 @@ extension PresetExpectedEndTime { case .untilCarbsEntered: return NSLocalizedString("on until carbs added", comment: "Preset card pre-meal expected end time") case .indefinite: - return NSLocalizedString("on indefinitely", comment: "Preset card indefinite scheduled end time") + return NSLocalizedString("on until turned off", comment: "Preset card indefinite scheduled end time") case .scheduled(let date): return NSLocalizedString("on until \(Self.timeFormatter.string(from: date))", comment: "Presets card time duration accessibility label") } @@ -142,13 +142,13 @@ extension PresetExpectedEndTime { } } -extension PresetDurationType { +extension PresetDuration { var localizedTitle: String { switch self { case .untilCarbsEntered: return NSLocalizedString("until carbs added", comment: "Preset card pre-meal duration") case .indefinite: - return NSLocalizedString("indefinite", comment: "Preset card indefinite duration") + return NSLocalizedString("until turned off", comment: "Preset card indefinite duration") case .duration(let duration): let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute] @@ -163,7 +163,7 @@ extension PresetDurationType { case .untilCarbsEntered: return NSLocalizedString("Active until carbs are added", comment: "Presets card pre-meal duration accessibility label") case .indefinite: - return NSLocalizedString("Active indefinitely", comment: "Presets card indefinite duration accessibility label") + return NSLocalizedString("Active until turned off", comment: "Presets card indefinite duration accessibility label") case .duration(let duration): let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute] diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 432e30b7d4..1156e1afc9 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -11,34 +11,22 @@ import LoopKitUI import SwiftUI struct PresetDetentView: View { - + enum Operation { case start case end } @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.settingsManager) private var settingsManager + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @Environment(\.dismiss) private var dismiss let preset: SelectablePreset - let viewModel: PresetsViewModel - - let activeOverride: TemporaryScheduleOverride? + let didTapEdit: () -> Void - init(viewModel: PresetsViewModel, preset: SelectablePreset) { - self.viewModel = viewModel - self.preset = preset - self.activeOverride = viewModel.temporaryPresetsManager.activeOverride - } - - - init?(viewModel: PresetsViewModel) { - guard let preset = viewModel.pendingPreset else { return nil } - self.init(viewModel: viewModel, preset: preset) - } - var operation: Operation { - if activeOverride?.presetId == preset.id { + if temporaryPresetsManager.activeOverride?.presetId == preset.id { return .end } else { return .start @@ -52,7 +40,7 @@ struct PresetDetentView: View { case .start: Text("Duration: \(preset.duration.localizedTitle)") case .end: - if let activeOverride { + if let activeOverride = temporaryPresetsManager.activeOverride { if activeOverride.presetId == preset.id { switch activeOverride.duration { case .finite: @@ -77,15 +65,15 @@ struct PresetDetentView: View { switch operation { case .start: Button("Start Preset") { - viewModel.startPreset(preset) + temporaryPresetsManager.startPreset(preset) dismiss() } .buttonStyle(ActionButtonStyle()) - .disabled(viewModel.activePreset != nil && preset.id != viewModel.activePreset?.id) + .disabled(temporaryPresetsManager.activeOverride != nil && preset.id != temporaryPresetsManager.activeOverride?.presetId) .accessibilityIdentifier("button_startPreset") case .end: Button("End Preset") { - viewModel.endPreset() + temporaryPresetsManager.endPreset() dismiss() } .buttonStyle(ActionButtonStyle(.destructive)) @@ -93,9 +81,7 @@ struct PresetDetentView: View { if preset.duration != .untilCarbsEntered { NavigationLink("Adjust Preset Duration") { - if let activeOverride { - EditOverrideDurationView(override: activeOverride, viewModel: viewModel) - } + EditPresetDurationView() } .buttonStyle(ActionButtonStyle(.tertiary)) .accessibilityIdentifier("button_adjustPresetDuration") @@ -112,7 +98,11 @@ struct PresetDetentView: View { } @State var sheetContentHeight: Double = 0 - + + var settingsImpact: TherapySettings.InsulinMultiplierImpact { + settingsManager.therapySettings.impact(for: preset.insulinSensitivityMultiplier ?? 1.0) + } + var body: some View { NavigationStack { VStack(spacing: 24) { @@ -125,7 +115,7 @@ struct PresetDetentView: View { if operation == .start { Button { dismiss() - viewModel.editPreset.append(preset.id) + didTapEdit() } label: { Group { Text(Image(systemName: "pencil")) + Text(" ") + Text("Edit Preset") @@ -142,8 +132,8 @@ struct PresetDetentView: View { PresetStatsView( insulinSensitivityMultiplier: preset.insulinSensitivityMultiplier, correctionRange: preset.correctionRange, - guardrail: preset.guardrail, - therapySettingsImpactDisplayState: operation == .end ? .show(viewModel.impactForPreset(preset)) : .hide + guardrail: settingsManager.guardrailForPreset(preset), + therapySettingsImpactDisplayState: operation == .end ? .show(settingsImpact) : .hide ) actionArea diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift index 55ab3ecce0..105635ac89 100644 --- a/Loop/Views/Presets/Components/PresetStatsView.swift +++ b/Loop/Views/Presets/Components/PresetStatsView.swift @@ -12,12 +12,11 @@ import LoopKitUI import SwiftUI struct PresetStatsView: View { - enum TherapySettingsImpactDisplayState { case hide case show(TherapySettings.InsulinMultiplierImpact) } - + @Environment(\.guidanceColors) private var guidanceColors @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference diff --git a/Loop/Views/Presets/Components/RepeatOptionsView.swift b/Loop/Views/Presets/Components/RepeatOptionsView.swift new file mode 100644 index 0000000000..dce507d3dc --- /dev/null +++ b/Loop/Views/Presets/Components/RepeatOptionsView.swift @@ -0,0 +1,46 @@ +// +// RepeatOptionsView.swift +// Loop +// +// Created by Pete Schwamb on 3/14/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// +import SwiftUI + +struct RepeatOptionView: View { + let repeatOptions: PresetScheduleRepeatOptions + + @ScaledMetric var dayTextSize: Double = 12 + + private var selectedDays: [PresetScheduleRepeatOptions] { + PresetScheduleRepeatOptions.allCases.filter { repeatOptions.contains($0) } + } + + private var isSingleDay: Bool { + selectedDays.count == 1 + } + + var body: some View { + if repeatOptions == .none { + Text(repeatOptions.description) + .tint(.secondary) + } else if isSingleDay { + Text(selectedDays[0].description) + .foregroundColor(.secondary) + } else { + HStack(spacing: 4) { + ForEach(PresetScheduleRepeatOptions.allCases, id: \.rawValue) { day in + Text(String(day.veryShortDescription)) + .font(.system(size: dayTextSize)) + .frame(width: dayTextSize+8, height: dayTextSize+8) + .background( + Circle() + .fill(repeatOptions.contains(day) ? Color.accentColor : Color.gray.opacity(0.2)) + ) + .foregroundColor(repeatOptions.contains(day) ? .white : .gray) + } + } + } + } +} + diff --git a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift new file mode 100644 index 0000000000..7a9b0800e5 --- /dev/null +++ b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift @@ -0,0 +1,284 @@ +// +// CreatePresetNameAndScheduledEdit.swift +// Loop +// +// Created by Pete Schwamb on 3/5/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +import LoopKitUI +import SwiftUI + +enum RepeatOption: CaseIterable { + case never + case weekly +} + +extension RepeatOption: CustomStringConvertible { + var description: String { + switch self { + case .never: + NSLocalizedString( + "Never", + comment: "Repeat option never for a preset schedule" + ) + case .weekly: + NSLocalizedString( + "Weekly", + comment: "Repeat option weekly for a preset schedule" + ) + } + } +} + +struct CreatePresetNameAndScheduledEdit: View { + @Environment(\.dismiss) private var dismiss + + @Binding var preset: NewCustomPreset + @Binding var path: NavigationPath + + @State private var isDurationPickerExpanded = false + + @FocusState private var isTextFieldFocused: Bool + + @State private var selectedRepeatOption: RepeatOption = .never + @State private var showingDayPicker: Bool = false + + var onCancel: () -> Void + + var body: some View { + CardSectionScrollView { + CardSection { + // Save Preset Toggle + HStack { + Text("Save Preset") + .font(.body) + + Spacer() + + Toggle("", isOn: $preset.savePreset.animation()) + .toggleStyle(SwitchToggleStyle(tint: .green)) + .labelsHidden() + .padding(.vertical, -6) + } + } + + Text("Toggle off for a single use preset") + .font(.footnote) + .foregroundColor(.secondary) + .padding(.horizontal, 10) + + // Name Field + if preset.savePreset { + CardSection { + HStack { + Text("Name") + .font(.body) + + Spacer() + + TextField("", text: $preset.name, prompt: Text("Required")) + .multilineTextAlignment(.trailing) + .focused($isTextFieldFocused) + .foregroundColor(.secondary) + } + } + } + + // Duration Section + CardSection { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Duration") + .foregroundColor(.primary) + Spacer() + Group { + if let duration = preset.duration { + Text(duration.localizedTitle) + Image(systemName: "chevron.right") + } else { + Text("Required") + .foregroundStyle(.placeholder) + } + } + .foregroundColor(.secondary) + } + .contentShape(Rectangle()) + .onTapGesture { + isTextFieldFocused = false + withAnimation() { + isDurationPickerExpanded.toggle() + } + } + + if isDurationPickerExpanded { + DurationPickerView( + durationType: Binding( + get: { + return preset.duration ?? .duration(0) + }, + set: { duration in + preset.duration = duration + } + ) + ) + } + } + } + + // Schedule Toggle + if preset.savePreset { + CardSection { + HStack { + Text("Schedule") + .font(.body) + + Spacer() + + Toggle("", isOn: Binding(get: { + return preset.startDate != nil + }, set: { newValue in + withAnimation { + if newValue { + preset.startDate = Date().addingTimeInterval(.hours(1)) + } else { + preset.startDate = nil + preset.repeatOptions = nil + } + } + })) + .toggleStyle(SwitchToggleStyle(tint: .green)) + .labelsHidden() + .padding(.vertical, -4) + } + + if preset.startDate != nil { + Divider() + HStack { + if selectedRepeatOption == .never { + Text("Date") + } else { + Text("Start Date") + } + Spacer() + DatePicker( + "", + selection: Binding(get: { + preset.startDate ?? Date() + }, set: { newValue in + preset.startDate = newValue + }), + in: Date()..., + displayedComponents: [.date, .hourAndMinute] + ) + } + Divider() + .padding(.top, -4) + HStack { + Text("Repeat") + Spacer() + Picker("Repeat", selection: $selectedRepeatOption.animation()) { + ForEach(RepeatOption.allCases, id: \.self) { option in + Text(String(describing: option)) + } + } + .tint(.secondary) + .pickerStyle(MenuPickerStyle()) + .padding(.trailing, -8) + } + + if selectedRepeatOption == .weekly { + Divider() + .padding(.top, -4) + HStack { + Text("Selected days") + .foregroundColor(.primary) + HStack { + Spacer() + RepeatOptionView(repeatOptions: preset.repeatOptions ?? .none) + .padding(.vertical, 6) + .onTapGesture { + withAnimation { + showingDayPicker = true + } + } + } + .popover(isPresented: $showingDayPicker) { + DayPickerPopup(selectedDays: Binding( + get: { + preset.repeatOptions ?? .none + }, set: { newValue in + preset.repeatOptions = newValue.union(requiredRepeatOption ?? .none) + })) + .cornerRadius(12) + .presentationCompactAdaptation(.popover) + } + } + } + } + } + if preset.repeatOptions != nil { + Text(preset.scheduleDescription()) + .font(.footnote) + .foregroundColor(.secondary) + .padding(.horizontal, 10) + } + } + } actionArea: { + Button("Continue") { + path.append(CreatePresetPage.summary) + } + .disabled(!allowSave) + .buttonStyle(ActionButtonStyle(.primary)) + .padding() + } + .onChange(of: selectedRepeatOption, { oldValue, newValue in + if newValue == .weekly { + assignRepeatDays() + } + }) + .onChange(of: preset.startDate, { oldValue, newValue in + if newValue != nil { + assignRepeatDays() + } + }) + + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Create a Preset") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { + onCancel() + } + } + } + } + + private var requiredRepeatOption: PresetScheduleRepeatOptions? { + guard let startDate = preset.startDate else { return nil } + guard selectedRepeatOption == .weekly else { return nil } + return .allCases[Calendar.current.component(.weekday, from: startDate) - 1] + } + + func assignRepeatDays() { + guard let requiredRepeatOption else { + return + } + preset.repeatOptions = requiredRepeatOption + } + + var allowSave: Bool { + return (!preset.savePreset && preset.duration != nil) || (preset.savePreset && !preset.name.isEmpty && preset.duration != nil) + } +} + +// Preview Provider +struct PresetCreationView_Previews: PreviewProvider { + @State static var preset: NewCustomPreset = .init() + @State static var path: NavigationPath = .init() + + static var previews: some View { + CreatePresetNameAndScheduledEdit(preset: $preset, path: $path, onCancel: {}) + } +} diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift new file mode 100644 index 0000000000..fc7bdf3b41 --- /dev/null +++ b/Loop/Views/Presets/CreatePresetView.swift @@ -0,0 +1,147 @@ +// +// CreatePresetView.swift +// Loop +// +// Created by Pete Schwamb on 2/15/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopAlgorithm +import LoopKitUI +import LoopKit + + +enum CreatePresetPage: Hashable { + case correctionRange + case nameAndSchedule + case summary +} + +struct SettingAdjustmentPreview: View { + let value: LoopQuantity + let displayUnit: LoopUnit + let name: String + private let formatter: QuantityFormatter + private let highlighted: Bool + + init(value: LoopQuantity, displayUnit: LoopUnit, name: String, highlighted: Bool = false) { + self.value = value + self.displayUnit = displayUnit + self.name = name + self.formatter = QuantityFormatter(for: displayUnit) + self.highlighted = highlighted + } + + var valueRow: some View { + Text(formatter.string(from: value, includeUnit: false) ?? "NA") + .bold() + Text(" ") + + Text(displayUnit.shortLocalizedUnitString()) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if highlighted { + valueRow.foregroundColor(.insulin) + } else { + valueRow + } + Text(name) + } + } +} + +struct CreatePresetView: View { + @Environment(\.settingsManager) private var settingsManager + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager + @Environment(\.dismiss) private var dismiss + + @State private var path = NavigationPath() + @State private var preset = NewCustomPreset() + @State private var navigateToRangeEdit: Bool = false + + var scheduledRange: ClosedRange? { + settingsManager.settings.glucoseTargetRangeSchedule?.quantityRange(at: Date()) + } + + var body: some View { + NavigationStack(path: $path) { + VStack(spacing: 0) { + Form { + InsulinScaleAdjustView(insulinMultiplier: $preset.insulinMultiplier) + } + + actionArea + } + .edgesIgnoringSafeArea(.bottom) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .navigationDestination(for: CreatePresetPage.self) { page in + switch page { + case .correctionRange: + Group { + if let scheduledRange { + NewPresetRangeEdit( + preset: $preset, + path: $path, + guardrail: Guardrail.correctionRange, + scheduledRange: scheduledRange, + onCancel: { dismiss() } + ) + } + } + case .nameAndSchedule: + CreatePresetNameAndScheduledEdit(preset: $preset, path: $path, onCancel: { dismiss() }) + case .summary: + if let scheduledRange { + ReviewNewPresetView( + preset: $preset, + path: $path, + scheduledRange: scheduledRange, + onCancel: { dismiss() }, + onComplete: { startPreset in + dismiss() + if let temporaryScheduleOverride = preset.temporaryScheduleOverride { + if preset.savePreset, case .preset(let preset) = temporaryScheduleOverride.context { + settingsManager.createPreset(preset) + } + if startPreset { + temporaryPresetsManager.scheduleOverride = temporaryScheduleOverride + } + } + } + ) + } + } + } + .navigationTitle("Create a preset") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { + dismiss() + } + } + } + } + } + + private var actionArea: some View { + VStack(spacing: 0) { + actionButton + } + .background(Color(.secondarySystemGroupedBackground).shadow(radius: 5)) + } + + private var actionButton: some View { + Button("Continue") { + path.append(CreatePresetPage.correctionRange) + } + .buttonStyle(ActionButtonStyle(.primary)) + .padding() + } + +} + +#Preview { + CreatePresetView() +} diff --git a/Loop/Views/Presets/DurationPickerView.swift b/Loop/Views/Presets/DurationPickerView.swift index e4edee92b6..fc3af3250f 100644 --- a/Loop/Views/Presets/DurationPickerView.swift +++ b/Loop/Views/Presets/DurationPickerView.swift @@ -9,14 +9,14 @@ import SwiftUI struct DurationPickerView: View { - @Binding var durationType: PresetDurationType + @Binding var durationType: PresetDuration @State private var lastUsedDuration: TimeInterval // Available values (respecting min 5min and max 8hr constraints) private let availableHours = Array(0...8) private let availableMinutes = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55] - init(durationType: Binding) { + init(durationType: Binding) { self._durationType = durationType // Initialize lastUsedDuration based on current durationType or default to 1 hour @@ -81,16 +81,7 @@ struct DurationPickerView: View { } var body: some View { - VStack(alignment: .center, spacing: 24) { - HStack { - Text("Duration") - .font(.system(size: 17, weight: .regular)) - Spacer() - Text("Required") - .font(.system(size: 17, weight: .regular)) - .foregroundColor(.gray) - } - + VStack { HStack(spacing: 16) { HStack(spacing: 8) { Picker("Hours", selection: hours) { @@ -127,24 +118,20 @@ struct DurationPickerView: View { } } .padding(.horizontal) - .onChange(of: hours.wrappedValue) { _ in + .onChange(of: hours.wrappedValue) { _, _ in enforceConstraints() } - .onChange(of: minutes.wrappedValue) { _ in + .onChange(of: minutes.wrappedValue) { _, _ in enforceConstraints() } HStack { Text("Until I turn off") - .font(.system(size: 17, weight: .regular)) Spacer() Toggle("", isOn: isIndefinite) .labelsHidden() } } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(10) } private func enforceConstraints() { diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 284f068fbc..99fde5ac5e 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -12,105 +12,32 @@ import SwiftUI import LoopKitUI import LoopAlgorithm -struct CompactSection: View { - let header: Header? - let footer: Footer? - let content: Content - - // Initializer for custom view header - init(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> Header, @ViewBuilder footer: () -> Footer) { - self.content = content() - self.header = header() - self.footer = footer() - } - - // Initializer for string header - init(_ headerText: String? = nil, @ViewBuilder content: () -> Content, footerText: String? = nil) where Header == Text, Footer == Text { - self.content = content() - self.header = headerText.map { Text($0) } - self.footer = footerText.map { Text($0) } - } - - // Initializer for no header - init(@ViewBuilder content: () -> Content) where Header == Text, Footer == Text { - self.content = content() - self.header = nil - self.footer = nil - } - - var body: some View { - Section { - content - } header: { - if let header { - header - .padding([.leading, .trailing], -10) - } - } footer: { - if let footer { - footer - } - } - .listRowInsets(EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)) - } -} - - struct EditPresetView: View { @Environment(\.dismiss) private var dismiss @Environment(\.guidanceColors) private var guidanceColors + @Environment(\.settingsManager) private var settingsManager @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - @State private var presetName: String @State private var preset: SelectablePreset + private var originalPreset: SelectablePreset private var scheduledRange: ClosedRange private var onSave: (SelectablePreset) throws -> Void @State private var showingPicker = false @State private var navigateToCorrectionRangeEditor = false + @FocusState private var isTextFieldFocused: Bool init(preset: SelectablePreset, scheduledRange: ClosedRange, onSave: @escaping ((SelectablePreset) throws -> Void)) { self.preset = preset self.originalPreset = preset - self.presetName = preset.name self.scheduledRange = scheduledRange self.onSave = onSave } - func boundText(for bound: LoopQuantity) -> Text { - let color = preset.guardrail.color(for: bound, guidanceColors: guidanceColors) - let text = displayGlucosePreference.format(bound, includeUnit: false) - switch preset.guardrail.classification(for: bound) { - case .withinRecommendedRange: - return Text(text) - .foregroundColor(.accentColor) - .font(.system(size: 34, weight: .bold)) - case .outsideRecommendedRange: - return ( - Text(text) - .foregroundColor(color) - .font(.system(size: 34, weight: .bold)) - ) - } - } - - func correctionRangeLabel(range: ClosedRange) -> Text { - boundText(for: (preset.correctionRange ?? scheduledRange).lowerBound) + - Text("-").foregroundColor(.secondary) - .font(.system(size: 34, weight: .light)) - + - boundText(for: (preset.correctionRange ?? scheduledRange).upperBound) + - Text(" ") + - Text(displayGlucosePreference.unit.localizedShortUnitString) - .font(.system(.body)) - .foregroundColor(.secondary) - .baselineOffset(12) - } - var sensitivitySection: some View { - CompactSection("Temporary Settings Adjustments") { + CardSection("Temporary Settings Adjustments") { VStack(alignment: .leading, spacing: 8) { Text("Overall Insulin") .font(.system(.title3, weight: .semibold)) @@ -135,136 +62,90 @@ struct EditPresetView: View { .padding(.top, 4) } } - .listRowInsets(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)) } } - private var correctionRangeCrossedThresholds: [SafetyClassification.Threshold] { - guard let range = preset.correctionRange else { return [] } - - let guardrail = preset.guardrail - let thresholds: [SafetyClassification.Threshold] = [range.lowerBound, range.upperBound].compactMap { bound in - switch guardrail.classification(for: bound) { - case .withinRecommendedRange: - return nil - case .outsideRecommendedRange(let threshold): - return threshold + var body: some View { + CardSectionScrollView { + presetTitle + + sensitivitySection + + CardSection { + Button { + navigateToCorrectionRangeEditor = true; + } label: { + CorrectionRangePreview( + range: $preset.correctionRange, + guardrail: settingsManager.guardrailForPreset(preset), + scheduledRange: scheduledRange, + allowsScheduledRange: preset.canAdjustSensitivity, + showDisclosure: true + ) + } } - } - - return thresholds - } - - private var guardrailWarningIfNecessary: some View { - let crossedThresholds = self.correctionRangeCrossedThresholds - let severity = crossedThresholds.map { $0.severity }.max() - return Group { - if let severity, !crossedThresholds.isEmpty { - let color: Color = severity > .default ? guidanceColors.critical : guidanceColors.warning - HStack(alignment: .top, spacing: 12) { - Text(Image(systemName: "exclamationmark.triangle.fill")) - .foregroundColor(color) - Text(SafetyClassification.captionForCrossedThresholds(crossedThresholds, isRange: true)); + CardSection("Preset Details") { + HStack { + Text("Name") + Spacer() + if preset.canChangeName { + TextField("", text: $preset.name, prompt: Text("Required")) + .multilineTextAlignment(.trailing) + .focused($isTextFieldFocused) + .foregroundColor(.secondary) + } else { + Text(preset.name) + .foregroundColor(.secondary) + } } - .padding(12) - .background(color.opacity(0.1)) - .cornerRadius(12) } - } - } - - var correctionSection: some View { - CompactSection { - Button { - navigateToCorrectionRangeEditor = true; - } label: { - VStack(alignment: .center, spacing: 12) { - HStack { - Text("Correction Range") - .font(.system(size: 17, weight: .semibold)) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - }.padding(.bottom, 10) - VStack(spacing: 4) { - if let range = preset.correctionRange { - correctionRangeLabel(range: range) - Text("Adjusted Range") - } else { - correctionRangeLabel(range: scheduledRange) - Text("Scheduled Range") + CardSection( + content: { + Button(action: { + showingPicker = true + }) { + HStack { + Text("Duration") + .foregroundColor(.primary) + Spacer() + Text(preset.duration.localizedTitle) + .foregroundColor(.secondary) + if preset.canAdjustDuration { + Image(systemName: "chevron.right") + .foregroundColor(.gray) + } } - } - guardrailWarningIfNecessary - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.primary) - .padding(.bottom, 5) - .padding(.horizontal, 2) + }.disabled(!preset.canAdjustDuration) + }, + footerText: preset.canAdjustDuration ? nil : "Duration and Name not configurable for this preset." + ) + } + .sheet(isPresented: $showingPicker) { + VStack(alignment: .center, spacing: 24) { + HStack { + Text("Duration") + Spacer() + Text("Required") + .foregroundColor(.gray) } - .foregroundColor(.primary) + DurationPickerView(durationType: $preset.duration) + .presentationDetents([.height(300)]) } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(10) } .navigationDestination(isPresented: $navigateToCorrectionRangeEditor) { - EditPresetRangeView( + ExistingPresetRangeEdit( range: $preset.correctionRange, - guardrail: preset.guardrail, + guardrail: settingsManager.guardrailForPreset(preset), scheduledRange: scheduledRange, + allowsScheduledRange: preset.canAdjustSensitivity, isPreMeal: preset.isPreMeal ) } - } - - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - Form { - Section {} header: { - presetTitle - } - .listRowInsets(EdgeInsets(top: 5, leading: 0, bottom: 0, trailing: 0)) - .textCase(nil) - - sensitivitySection - - correctionSection - - CompactSection("PRESET DETAILS") { - HStack { - Text("Name") - Spacer() - Text(presetName) - .foregroundColor(.secondary) - } - } - - CompactSection( - content: { - Button(action: { - showingPicker = true - }) { - HStack { - Text("Duration") - .foregroundColor(.primary) - Spacer() - Text(preset.duration.localizedTitle) - .foregroundColor(.secondary) - if preset.canAdjustDuration { - Image(systemName: "chevron.right") - .foregroundColor(.gray) - } - } - }.disabled(!preset.canAdjustDuration) - }, - footerText: preset.canAdjustDuration ? nil : "Duration and Name not configurable for this preset.") - } - .listSectionSpacing(16) - } - .sheet(isPresented: $showingPicker) { - DurationPickerView(durationType: $preset.duration) - .presentationDetents([.height(300)]) - } .onChange(of: preset, { do { try onSave(preset) @@ -289,7 +170,7 @@ struct EditPresetView: View { .frame(width: UIFontMetrics.default.scaledValue(for: 34), height: UIFontMetrics.default.scaledValue(for: 34)) } - Text(presetName) + Text(preset.name) .font(.system(size: 34, weight: .semibold)) .foregroundColor(.primary) } diff --git a/Loop/Views/Presets/ExistingPresetRangeEdit.swift b/Loop/Views/Presets/ExistingPresetRangeEdit.swift new file mode 100644 index 0000000000..5f69d17075 --- /dev/null +++ b/Loop/Views/Presets/ExistingPresetRangeEdit.swift @@ -0,0 +1,130 @@ +// +// EditPresetRangeView.swift +// Loop +// +// Created by Pete Schwamb on 2/25/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopAlgorithm +import LoopKit +import LoopKitUI + +struct ExistingPresetRangeEdit: View { + @Environment(\.dismiss) private var dismiss + + @Binding var range: ClosedRange? + var guardrail: Guardrail + private var scheduledRange: ClosedRange + @State private var editedRange: ClosedRange? + private var allowsScheduledRange: Bool + private var isPreMeal: Bool = false + + init(range: Binding?>, guardrail: Guardrail, scheduledRange: ClosedRange, allowsScheduledRange: Bool = true, isPreMeal: Bool = false) { + self._range = range + self.editedRange = range.wrappedValue + self.guardrail = guardrail + self.scheduledRange = scheduledRange + self.allowsScheduledRange = allowsScheduledRange + self.isPreMeal = isPreMeal + } + + var body: some View { + CardSectionScrollView { + CardSection { + PresetRangeEditor( + range: $editedRange, + guardrail: guardrail, + scheduledRange: scheduledRange, + allowsScheduledRange: allowsScheduledRange, + isPreMeal: isPreMeal + ) + } + } actionArea: { + guardrailWarningIfNecessary + actionButton + } + .navigationBarBackButtonHidden(editedRange != range) + .navigationBarItems( + trailing: cancelButton + ) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Edit Preset") + } + + private var cancelButton: some View { + Group { + if editedRange != range { + Button("Cancel") { + dismiss() + } + .foregroundColor(.blue) + } + } + } + + + private var actionButton: some View { + Button("Save") { + range = editedRange + dismiss() + } + .disabled(editedRange == range) + .buttonStyle(ActionButtonStyle(.primary)) + .padding() + } + + + var crossedThresholds: [SafetyClassification.Threshold] { + if let range = editedRange ?? range { + let lowerBound = range.lowerBound + let upperBound = range.upperBound + return [lowerBound, upperBound].compactMap { (bound) -> SafetyClassification.Threshold? in + switch guardrail.classification(for: bound) { + case .withinRecommendedRange: + return nil + case .outsideRecommendedRange(let threshold): + return threshold + } + } + } else { + return [] + } + } + + var guardrailWarningIfNecessary: some View { + let crossedThresholds = self.crossedThresholds + return Group { + if !crossedThresholds.isEmpty { + CorrectionRangeGuardrailWarning(crossedThresholds: crossedThresholds) + } + }.padding() + } +} + +private struct CorrectionRangeGuardrailWarning: View { + var crossedThresholds: [SafetyClassification.Threshold] + + var body: some View { + assert(!crossedThresholds.isEmpty) + return GuardrailWarning( + therapySetting: .glucoseTargetRange, + title: crossedThresholds.count == 1 ? singularWarningTitle(for: crossedThresholds.first!) : multipleWarningTitle, + thresholds: crossedThresholds + ) + } + + private func singularWarningTitle(for threshold: SafetyClassification.Threshold) -> Text { + switch threshold { + case .minimum, .belowRecommended: + return Text("Low Correction Value", comment: "Title text for the low correction value warning") + case .aboveRecommended, .maximum: + return Text("High Correction Value", comment: "Title text for the high correction value warning") + } + } + + private var multipleWarningTitle: Text { + Text("Correction Values", comment: "Title text for multi-value correction value warning") + } +} diff --git a/Loop/Views/Presets/InsulinScaleInformationView.swift b/Loop/Views/Presets/InsulinScaleInformationView.swift new file mode 100644 index 0000000000..7af9286c54 --- /dev/null +++ b/Loop/Views/Presets/InsulinScaleInformationView.swift @@ -0,0 +1,142 @@ +// +// InsulinScaleInformationView.swift +// Loop +// +// Created by Pete Schwamb on 2/25/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +import SwiftUI + +struct InsulinScaleInformationView: View { + @State private var insulinPercentage: Double = 100 + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + // Header + VStack(alignment: .leading) { + Text("Overall Insulin") + .font(.largeTitle) + .fontWeight(.bold) + .padding(.vertical) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .background(Color(.systemBackground)) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Description Text + Text("Overall insulin should be adjusted when your body needs more or less insulin than usual.") + .padding(.top) + + Text("At 100%, your settings remain unchanged from your scheduled settings.") + + // What gets affected + VStack(alignment: .leading, spacing: 16) { + Text("Changing the percentage will affect:") + .fontWeight(.medium) + + BulletPoint(text: "Basal Rate") + BulletPoint(text: "Carb Ratio") + BulletPoint(text: "Insulin Sensitivity Factor (ISF)") + } + + // Decision guidance + VStack(alignment: .leading, spacing: 8) { + Text("Before deciding to adjust your overall insulin,") + Text("ask yourself, does my body need more or less than usual?") + .fontWeight(.bold) + } + + // Tip section + TipSection() + } + .padding() + } + + // Close button + VStack { + Button("Close") { + dismiss() + } + .font(.body.bold()) + .foregroundColor(.blue) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding() + } + .background(Color(.systemBackground)) + } + } +} + +struct BulletPoint: View { + let text: String + + var body: some View { + HStack(alignment: .top) { + Circle() + .fill(Color.blue.opacity(0.3)) + .frame(width: 8, height: 8) + .padding(.top, 6) + + Text(text) + .padding(.leading, 4) + } + } +} + +struct TipSection: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "lightbulb.fill") + .foregroundColor(.blue) + + Text("Tip") + .font(.headline) + .foregroundColor(.blue) + } + .padding(.bottom, 4) + + HStack(alignment: .top) { + Circle() + .fill(Color.blue.opacity(0.3)) + .frame(width: 8, height: 8) + .padding(.top, 6) + + VStack(alignment: .leading) { + Text("A percentage ") + + Text("below 100%").fontWeight(.semibold) + + Text(" tells the system you need less insulin") + } + } + + HStack(alignment: .top) { + Circle() + .fill(Color.blue.opacity(0.3)) + .frame(width: 8, height: 8) + .padding(.top, 6) + + VStack(alignment: .leading) { + Text("A percentage ") + + Text("above 100%").fontWeight(.semibold) + + Text(" indicates you need more insulin") + } + } + } + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + } +} + +struct OverallInsulinView_Previews: PreviewProvider { + static var previews: some View { + InsulinScaleInformationView() + } +} diff --git a/Loop/Views/Presets/NewCustomPreset.swift b/Loop/Views/Presets/NewCustomPreset.swift new file mode 100644 index 0000000000..e0d07901ca --- /dev/null +++ b/Loop/Views/Presets/NewCustomPreset.swift @@ -0,0 +1,181 @@ +// +// NewCustomPreset.swift +// Loop +// +// Created by Pete Schwamb on 2/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import UIKit +import LoopKit + +struct PresetScheduleRepeatOptions: OptionSet { + let rawValue: UInt8 + + static let none = PresetScheduleRepeatOptions([]) + static let sunday = PresetScheduleRepeatOptions(rawValue: 1 << 0) + static let monday = PresetScheduleRepeatOptions(rawValue: 1 << 1) + static let tuesday = PresetScheduleRepeatOptions(rawValue: 1 << 2) + static let wednesday = PresetScheduleRepeatOptions(rawValue: 1 << 3) + static let thursday = PresetScheduleRepeatOptions(rawValue: 1 << 4) + static let friday = PresetScheduleRepeatOptions(rawValue: 1 << 5) + static let saturday = PresetScheduleRepeatOptions(rawValue: 1 << 6) + + static let allCases: [PresetScheduleRepeatOptions] = [ + .sunday, + .monday, + .tuesday, + .wednesday, + .thursday, + .friday, + .saturday, + ] + + // Helper to map OptionSet to calendar weekday index (Sunday = 1 in Calendar) + private var calendarWeekdayIndex: Int? { + switch self { + case .sunday: return 1 + case .monday: return 2 + case .tuesday: return 3 + case .wednesday: return 4 + case .thursday: return 5 + case .friday: return 6 + case .saturday: return 7 + default: return nil + } + } +} + +extension PresetScheduleRepeatOptions: CustomStringConvertible { + var description: String { + let calendar = Calendar.current + let weekdaySymbols = calendar.weekdaySymbols + + if self == .none { + return NSLocalizedString("None", comment: "Preset schedule repeat option none") + } + + // Handle single day case + if let weekdayIndex = calendarWeekdayIndex { + return weekdaySymbols[weekdayIndex - 1] // -1 because array is 0-based + } + + // Handle multiple days + return NSLocalizedString("Multiple", comment: "Preset schedule repeat option multiple days") + } + + var veryShortDescription: String { + let calendar = Calendar.current + let weekdaySymbols = calendar.veryShortWeekdaySymbols + + if self == .none { + return NSLocalizedString("None", comment: "Preset schedule repeat option none") + } + + // Handle single day case + if let weekdayIndex = calendarWeekdayIndex { + return weekdaySymbols[weekdayIndex - 1] // -1 because array is 0-based + } + + // Handle multiple days + return NSLocalizedString("Multiple", comment: "Preset schedule repeat option multiple days") + } +} + +struct NewCustomPreset { + var savePreset: Bool = true + var insulinMultiplier: Double = 1 + var correctionRange: ClosedRange? + var name: String = "" + var duration: PresetDuration? + var startDate: Date? + var repeatOptions: PresetScheduleRepeatOptions? +} + +extension NewCustomPreset { + func scheduleDescription() -> String { + guard let startDate = startDate, let repeatOptions = repeatOptions else { + return "" + } + + // Handle case where no days are selected + if repeatOptions.isEmpty || repeatOptions == .none { + return "" + } + + // Get date formatter for time (will use user's locale) + let timeFormatter = DateFormatter() + timeFormatter.timeStyle = .short // Uses locale-appropriate short time format (e.g., "10:00 AM" or "10:00") + let timeString = timeFormatter.string(from: startDate) + + // Get all selected days + let selectedDays = PresetScheduleRepeatOptions.allCases + .filter { repeatOptions.contains($0) } + .map { $0.description } // Already localized via your existing description + + // Format the days string based on count + let daysString: String + switch selectedDays.count { + case 1: + daysString = selectedDays[0] + case 2: + daysString = String( + format: NSLocalizedString("%@ and %@", comment: "Format for two days"), + selectedDays[0], + selectedDays[1] + ) + default: + let lastDay = selectedDays.last ?? "" + let otherDays = selectedDays.dropLast().joined(separator: NSLocalizedString(", ", comment: "Separator for multiple days")) + daysString = String( + format: NSLocalizedString("%@, and %@", comment: "Format for three or more days"), + otherDays, + lastDay + ) + } + + // Combine with localized format string + return String( + format: NSLocalizedString("Repeats weekly on %@ at %@", comment: "Weekly repeat schedule format"), + daysString, + timeString + ) + } +} + +extension NewCustomPreset { + var temporaryScheduleOverride: TemporaryScheduleOverride? { + guard let duration else { + return nil + } + let overrideDuration = duration.presetDuration + + let settings = TemporaryScheduleOverrideSettings( + targetRange: correctionRange, + insulinNeedsScaleFactor: insulinMultiplier + ) + + let context: TemporaryScheduleOverride.Context + + if savePreset { + let preset = TemporaryScheduleOverridePreset( + symbol: "", + name: name, + settings: settings, + duration: overrideDuration + ) + context = .preset(preset) + } else { + context = .custom + } + return TemporaryScheduleOverride( + context: context, + settings: settings, + startDate: startDate ?? Date(), + duration: overrideDuration, + enactTrigger: .local, + syncIdentifier: UUID() + ) + } +} diff --git a/Loop/Views/Presets/NewPresetRangeEdit.swift b/Loop/Views/Presets/NewPresetRangeEdit.swift new file mode 100644 index 0000000000..f8a14c3dce --- /dev/null +++ b/Loop/Views/Presets/NewPresetRangeEdit.swift @@ -0,0 +1,124 @@ +// +// CreatePresetEditRangeView.swift +// Loop +// +// Created by Pete Schwamb on 2/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopAlgorithm +import LoopKit +import LoopKitUI + +struct NewPresetRangeEdit: View { + @Environment(\.dismiss) private var dismiss + + @Binding var preset: NewCustomPreset + @Binding var path: NavigationPath + var guardrail: Guardrail + var scheduledRange: ClosedRange + var onCancel: () -> Void + + @State private var editedRange: ClosedRange? + + var body: some View { + CardSectionScrollView { + CardSection { + PresetRangeEditor( + range: $editedRange, + guardrail: guardrail, + scheduledRange: scheduledRange, + isPreMeal: false + ) + } + } actionArea: { + guardrailWarningIfNecessary + actionButton + } + + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Create a Preset") + .navigationBarItems( + trailing: cancelButton + ) + } + + private var cancelButton: some View { + Button("Cancel") { + onCancel() + } + .foregroundColor(.blue) + } + + private var actionButtonText: String { + if editedRange == nil { + NSLocalizedString("Continue", comment: "Continue button for new preset range edit when range is not edited") + } else { + NSLocalizedString("Continue with adjusted range", comment: "Continue button for new preset range edit when range edited") + } + } + + private var actionButton: some View { + Button(actionButtonText) { + preset.correctionRange = editedRange + path.append(CreatePresetPage.nameAndSchedule) + } + .disabled(preset.insulinMultiplier == 1 && editedRange == nil) + .buttonStyle(ActionButtonStyle(.primary)) + .padding() + } + + + var crossedThresholds: [SafetyClassification.Threshold] { + if let range = editedRange ?? preset.correctionRange { + let lowerBound = range.lowerBound + let upperBound = range.upperBound + return [lowerBound, upperBound].compactMap { (bound) -> SafetyClassification.Threshold? in + switch guardrail.classification(for: bound) { + case .withinRecommendedRange: + return nil + case .outsideRecommendedRange(let threshold): + return threshold + } + } + } else { + return [] + } + } + + var guardrailWarningIfNecessary: some View { + let crossedThresholds = self.crossedThresholds + return Group { + if !crossedThresholds.isEmpty { + CorrectionRangeGuardrailWarning(crossedThresholds: crossedThresholds) + } + }.padding() + } +} + +private struct CorrectionRangeGuardrailWarning: View { + var crossedThresholds: [SafetyClassification.Threshold] + + var body: some View { + assert(!crossedThresholds.isEmpty) + return GuardrailWarning( + therapySetting: .glucoseTargetRange, + title: crossedThresholds.count == 1 ? singularWarningTitle(for: crossedThresholds.first!) : multipleWarningTitle, + thresholds: crossedThresholds + ) + } + + private func singularWarningTitle(for threshold: SafetyClassification.Threshold) -> Text { + switch threshold { + case .minimum, .belowRecommended: + return Text("Low Correction Value", comment: "Title text for the low correction value warning") + case .aboveRecommended, .maximum: + return Text("High Correction Value", comment: "Title text for the high correction value warning") + } + } + + private var multipleWarningTitle: Text { + Text("Correction Values", comment: "Title text for multi-value correction value warning") + } +} diff --git a/Loop/Views/Presets/PresetRangeEditor.swift b/Loop/Views/Presets/PresetRangeEditor.swift new file mode 100644 index 0000000000..e804ac350d --- /dev/null +++ b/Loop/Views/Presets/PresetRangeEditor.swift @@ -0,0 +1,185 @@ +// +// EditPressRangeView.swift +// Loop +// +// Created by Pete Schwamb on 12/17/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// +import SwiftUI +import LoopAlgorithm +import LoopKit +import LoopKitUI + +struct PresetRangeEditor: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.guidanceColors) private var guidanceColors + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + + @Binding var range: ClosedRange? + var guardrail: Guardrail + private var scheduledRange: ClosedRange + private var allowsScheduledRange: Bool + private var isPreMeal: Bool + + init(range: Binding?>, guardrail: Guardrail, scheduledRange: ClosedRange, allowsScheduledRange: Bool = true, isPreMeal: Bool) { + self._range = range + self.guardrail = guardrail + self.scheduledRange = scheduledRange + self.allowsScheduledRange = allowsScheduledRange + self.isPreMeal = isPreMeal + } + + var displayedRange: ClosedRange { + return range ?? scheduledRange + } + + func boundText(for bound: LoopQuantity) -> Text { + let color = guardrail.color(for: bound, guidanceColors: guidanceColors) + let text = displayGlucosePreference.format(bound, includeUnit: false) + switch guardrail.classification(for: bound) { + case .withinRecommendedRange: + return Text(text) + .foregroundColor(range == nil ? .secondary : .accentColor) + .font(.system(size: 42, weight: .semibold)) + case .outsideRecommendedRange: + return ( + Text(Image(systemName: "exclamationmark.triangle.fill")) + .font(.system(size: 29, weight: .regular)) + .baselineOffset(3.0) + .foregroundColor(color) + + Text(text) + .foregroundColor(color) + .font(.system(size: 42, weight: .semibold)) + ) + } + } + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 8) { + HStack { + Text("Correction Range") + .foregroundColor(.secondary) + .font(.system(size: 14)) + Image(systemName: "info.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.accentColor) + .frame(width: UIFontMetrics.default.scaledValue(for: 14), height: UIFontMetrics.default.scaledValue(for: 14)) + } + .padding(.top, 10) + + + Text("Set your correction range") + .font(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .padding(.top, 10) + + Text("To reduce the risk of highs or lows, you may want to set an adjusted range if you think your glucose will vary more than usual.") + .multilineTextAlignment(.center) + + if allowsScheduledRange { + Toggle("Use Scheduled Range", isOn: Binding(get: { + range == nil + }, set: { newValue in + withAnimation { + if (newValue) { + range = nil + } else { + range = scheduledRange + } + } + })) + .padding(.vertical) + } + } + + VStack(spacing: 0) { + if (range == nil) { + Text("Currently Scheduled Correction Range") + } else { + Text("Adjusted Range") + + } + + ( + boundText(for: (displayedRange).lowerBound) + + Text("-").foregroundColor(.secondary) + .font(.system(size: 42, weight: .light)) + + + boundText(for: (displayedRange).upperBound) + ) + + + Text("mg/dL") + .foregroundColor(.secondary) + } + + if range != nil { + Divider() + .animation(.default, value: range != nil) + + GlucoseRangePicker(range: Binding( + get: { displayedRange }, + set: { range = $0 }), + unit: displayGlucosePreference.unit, + minValue: nil, + guardrail: guardrail) + .padding(.vertical, -20) + } + + HStack(spacing: 8) { + Image(systemName: "info.circle") + .foregroundColor(.accentColor) + + tipText.font(.system(size: 14)) + } + .padding() + .overlay( /// apply a rounded border + RoundedRectangle(cornerRadius: 8) + .stroke(.gray, lineWidth: 1) + ) + .padding(.bottom) + } + .font(.subheadline) + } + + + private var tipText: some View { + Group { + if isPreMeal { + Text("To help avoid post-meal highs, set a range ") + + Text("lower") + .italic() + .bold() + + Text(" than your typical correction range.") + } else { + Text("To help avoid lows, set a range ") + + Text("higher") + .italic() + .bold() + + Text(" than your typical correction range.") + } + } + } + + + + var crossedThresholds: [SafetyClassification.Threshold] { + if let range = range { + let lowerBound = range.lowerBound + let upperBound = range.upperBound + return [lowerBound, upperBound].compactMap { (bound) -> SafetyClassification.Threshold? in + switch guardrail.classification(for: bound) { + case .withinRecommendedRange: + return nil + case .outsideRecommendedRange(let threshold): + return threshold + } + } + } else { + return [] + } + } +} diff --git a/Loop/Views/Presets/PresetsHistoryView.swift b/Loop/Views/Presets/PresetsHistoryView.swift index 73cebef132..6127146951 100644 --- a/Loop/Views/Presets/PresetsHistoryView.swift +++ b/Loop/Views/Presets/PresetsHistoryView.swift @@ -10,15 +10,9 @@ import LoopKit import SwiftUI struct PresetsHistoryView: View { - - let viewModel: PresetsViewModel - @State var history: TemporaryScheduleOverrideHistory - - init (viewModel: PresetsViewModel) { - self.viewModel = viewModel - self.history = TemporaryScheduleOverrideHistoryContainer.shared.fetch() - } - + @Environment(\.settingsManager) private var settingsManager + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager + let formatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] @@ -28,7 +22,7 @@ struct PresetsHistoryView: View { var overridesByDate: Dictionary { Dictionary( - grouping: history.recentEvents + grouping: temporaryPresetsManager.presetHistory.recentEvents .map(\.override) .filter({ !$0.isActive() }) .sorted(by: { $0.actualEndDate > $1.actualEndDate }) @@ -56,7 +50,7 @@ struct PresetsHistoryView: View { .font(.footnote) .foregroundStyle(.secondary) - if let preset = viewModel.allPresets.first(where: { $0.id == override.presetId }) { + if let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == override.presetId }) { HStack(spacing: 4) { switch preset.icon { case .emoji(let emoji): diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index e7d6889334..3c3c21f9f0 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -31,50 +31,59 @@ enum PresetSortOption: Int, CaseIterable { struct PresetsView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.settingsManager) private var settingsManager + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @Environment(\.dismiss) private var dismiss - @State private var viewModel: PresetsViewModel - @State private var editMode: EditMode = .inactive @State private var showingMenu: Bool = false - @State var showTraining: Bool = false + @State private var showTraining: Bool = false + @State private var presentCreateView: Bool = false + @State private var editPresetPath: [String] = [] + @State private var pendingPreset: SelectablePreset? - var isDescending: Bool { !viewModel.presetsSortAscending } + @AppStorage("presetsSortAscending") private var presetsSortAscending: Bool = true + @AppStorage("presetsSortOrder") private var selectedSortOption: PresetSortOption = .name + @AppStorage("hasCompletedPresetsTraining") private var hasCompletedTraining: Bool = false - init(viewModel: PresetsViewModel) { - self.viewModel = viewModel - } + var isDescending: Bool { !presetsSortAscending } var presetsSorted: [SelectablePreset] { - viewModel.allPresets - .filter { $0.id != viewModel.temporaryPresetsManager.activeOverride?.presetId } + temporaryPresetsManager.selectablePresets + .filter { $0.id != temporaryPresetsManager.activeOverride?.presetId } .sorted(by: { - switch (viewModel.selectedSortOption) { + switch (selectedSortOption) { case .name: return ($0.name.lowercased() < $1.name.lowercased()) != isDescending case .dateCreated: return ($0.dateCreated > $1.dateCreated) != isDescending default: - return ((viewModel.lastUsed(id: $0.id) ?? .distantPast) > (viewModel.lastUsed(id: $1.id) ?? .distantPast)) != isDescending + return ((temporaryPresetsManager.lastUsed(id: $0.id) ?? .distantPast) > (temporaryPresetsManager.lastUsed(id: $1.id) ?? .distantPast)) != isDescending } }) } + var scheduledRange: ClosedRange? { + settingsManager.therapySettings.glucoseTargetRangeSchedule?.quantityRange(at: Date()) + } + var body: some View { - NavigationStack(path: $viewModel.editPreset) { + NavigationStack(path: $editPresetPath) { ScrollView { VStack(spacing: 20) { - if !viewModel.hasCompletedTraining { + if !hasCompletedTraining { PresetsTrainingCard(showTraining: $showTraining) } - if let activePreset = viewModel.activePreset { + if let activePreset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == temporaryPresetsManager.activeOverride?.presetId }) + { PresetCard( activePreset, - expectedEndTime: viewModel.temporaryPresetsManager.activeOverride?.expectedEndTime + guardrail: settingsManager.guardrailForPreset(activePreset), + expectedEndTime: temporaryPresetsManager.activeOverride?.expectedEndTime ) .onTapGesture { - viewModel.pendingPreset = activePreset + pendingPreset = activePreset } } @@ -92,20 +101,24 @@ struct PresetsView: View { sortMenu } - Button(action: {}) { + Button(action: { + presentCreateView = true; + }) { Image(systemName: "plus") } - .disabled(!viewModel.hasCompletedTraining) + .disabled(!hasCompletedTraining) } LazyVStack(spacing: 12) { ForEach(presetsSorted) { preset in - PresetCard(preset) - .background(Color.white) - .cornerRadius(12) - .onTapGesture { - viewModel.pendingPreset = preset - } + PresetCard( + preset, + guardrail: settingsManager.guardrailForPreset(preset) + ) + .cornerRadius(12) + .onTapGesture { + pendingPreset = preset + } } } } @@ -115,7 +128,7 @@ struct PresetsView: View { Text("Support") .font(.title2.bold()) - NavigationLink(destination: PresetsHistoryView(viewModel: viewModel)) { + NavigationLink(destination: PresetsHistoryView()) { HStack { Image(systemName: "list.bullet") .foregroundColor(.white) @@ -136,7 +149,7 @@ struct PresetsView: View { .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) .frame(maxWidth: .infinity)) - if viewModel.hasCompletedTraining { + if hasCompletedTraining { Button { showTraining = true } label: { @@ -158,29 +171,33 @@ struct PresetsView: View { } } .padding() - .animation(.default, value: viewModel.hasCompletedTraining) - .animation(.default, value: viewModel.temporaryPresetsManager.activeOverride) + .animation(.default, value: hasCompletedTraining) + .animation(.default, value: temporaryPresetsManager.activeOverride) } .background(Color(UIColor.secondarySystemBackground)) .navigationTitle(Text("Presets", comment: "Presets screen title")) .navigationBarItems(trailing: dismissButton) .navigationDestination(for: String.self) { presetId in - EditPresetView(preset: viewModel.allPresets.first { $0.id == presetId }!, scheduledRange: viewModel.scheduledRange) { preset in - viewModel.savePreset(preset) + if let scheduledRange { + EditPresetView(preset: temporaryPresetsManager.selectablePresets.first { $0.id == presetId }!, scheduledRange: scheduledRange) { preset in + settingsManager.savePreset(preset) + } } } } - .sheet(item: $viewModel.pendingPreset) { preset in - PresetDetentView( - viewModel: viewModel, - preset: preset - ) + .sheet(item: $pendingPreset) { preset in + PresetDetentView(preset: preset, didTapEdit: { + editPresetPath.append(preset.id) + }) } .sheet(isPresented: $showTraining) { PresetsTrainingView { - viewModel.hasCompletedTraining = true + hasCompletedTraining = true } } + .sheet(isPresented: $presentCreateView) { + CreatePresetView() + } } private var sortMenu: some View { @@ -190,7 +207,7 @@ struct PresetsView: View { .font(.headline) Spacer() Button(action: { - viewModel.presetsSortAscending.toggle() + presetsSortAscending.toggle() }) { Image(systemName: "arrow.up.arrow.down") } @@ -201,11 +218,11 @@ struct PresetsView: View { ForEach(PresetSortOption.allCases, id: \.self) { option in Button(action: { - viewModel.selectedSortOption = option + selectedSortOption = option showingMenu = false }) { HStack { - if viewModel.selectedSortOption == option { + if selectedSortOption == option { Image(systemName: "checkmark") } else { Image(systemName: "checkmark") @@ -248,14 +265,14 @@ struct PresetsView: View { } extension PresetCard { - init (_ preset: SelectablePreset, expectedEndTime: PresetExpectedEndTime? = nil) { + init (_ preset: SelectablePreset, guardrail: Guardrail, expectedEndTime: PresetExpectedEndTime? = nil) { self.init( icon: preset.icon, presetName: preset.name, duration: preset.duration, insulinSensitivityMultiplier: preset.insulinSensitivityMultiplier, correctionRange: preset.correctionRange, - guardrail: preset.guardrail, + guardrail: guardrail, expectedEndTime: expectedEndTime ) } diff --git a/Loop/Views/Presets/ReviewNewPresetView.swift b/Loop/Views/Presets/ReviewNewPresetView.swift new file mode 100644 index 0000000000..695914f833 --- /dev/null +++ b/Loop/Views/Presets/ReviewNewPresetView.swift @@ -0,0 +1,208 @@ +// +// ReviewNewPresetView.swift +// Loop +// +// Created by Pete Schwamb on 3/6/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import LoopUI +import LoopAlgorithm + +struct ReviewNewPresetView: View { + @Environment(\.dismiss) private var dismiss + + @Binding var preset: NewCustomPreset + @Binding var path: NavigationPath + var scheduledRange: ClosedRange + var onCancel: () -> Void + var onComplete: (_ startPreset: Bool) -> Void + + // Add a timer to trigger updates + @State private var currentDate = Date() + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + // Computed property to check if start date is too soon + private var isStartDateTooSoon: Bool { + guard let startDate = preset.startDate, preset.savePreset else { return false } + return startDate < currentDate.addingTimeInterval(60) + } + + var body: some View { + CardSectionScrollView { + VStack(alignment: .leading) { + Text("New Preset") + .font(.largeTitle) + .fontWeight(.bold) + .padding(.top, 40) + } + + VStack(alignment: .leading, spacing: 10) { + Text("Review Settings") + .fontWeight(.semibold) + Text("Review your preset settings below. To make any changes, navigate back to the setting you’d like to edit. You can edit these settings after saving your preset as well.") + .font(.footnote) + } + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(RoundedRectangle(cornerRadius: 10) + .fill(Color.accentColor) + .frame(maxWidth: .infinity)) + .padding(.top, 10) + .clipped() + + + sensitivitySection + + CardSection { + CorrectionRangePreview(range: $preset.correctionRange, guardrail: Guardrail.correctionRange, scheduledRange: scheduledRange, allowsScheduledRange: true) + } + + // Name Field + if preset.savePreset { + CardSection { + HStack { + Text("Name") + .font(.body) + + Spacer() + + Text(preset.name) + .font(.body) + .foregroundColor(.secondary) + + } + } + } + + // Duration Section + CardSection { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Duration") + .foregroundColor(.primary) + Spacer() + Group { + if let duration = preset.duration { + Text(duration.localizedTitle) + } else { + Text("Required") + } + } + .foregroundColor(.secondary) + } + } + } + + // Schedule Toggle + if preset.savePreset, let startDate = preset.startDate { + CardSection { + HStack { + if preset.repeatOptions != nil { + Text("Start Date") + } else { + Text("Start at") + } + Spacer() + Text(DateFormatter.localizedString(from: startDate, dateStyle: .short, timeStyle: .short)) + .foregroundColor(.secondary) + } + if let repeatOptions = preset.repeatOptions { + Divider() + HStack { + Text("Repeat weekly on") + Spacer() + RepeatOptionView(repeatOptions: repeatOptions) + } + .padding(.vertical, 4) + } + } + + Text("Tidepool Loop will always ask you to confirm before turning on a scheduled preset.") + .font(.footnote) + .foregroundColor(.secondary) + .padding(.horizontal, 10) + .padding(.top, 4) + + } + } actionArea: { + if isStartDateTooSoon { + WarningView( + title: Text("Invalid Start Time"), + caption: Text("Start time must be at least 1 minute in the future.") + ) + .padding() + } + + Group { + if preset.savePreset, preset.startDate != nil { + Button("Save and Schedule for Later") { + onComplete(false) + } + .buttonStyle(ActionButtonStyle(.primary)) + .disabled(isStartDateTooSoon) + } else if preset.savePreset { + VStack { + Button("Start Preset") { + onComplete(true) + } + .buttonStyle(ActionButtonStyle(.primary)) + Button("Save for later") { + onComplete(false) + } + .buttonStyle(ActionButtonStyle(.secondary)) + } + } else { + Button("Start Preset") { + onComplete(true) + } + .buttonStyle(ActionButtonStyle(.primary)) + } + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Create a Preset") + .edgesIgnoringSafeArea([.top]) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { + onCancel() + } + } + } + // Update currentDate every second + .onReceive(timer) { _ in + currentDate = Date() + } + } + + var sensitivitySection: some View { + CardSection("Temporary Settings Adjustments") { + VStack(alignment: .leading, spacing: 8) { + Text("Overall Insulin") + .font(.headline) + .padding(.bottom, 10) + + + HStack { + Spacer() + VStack(alignment: .center) { + Text("\(Int(preset.insulinMultiplier * 100))%") + .font(.system(size: 34, weight: .semibold)) + .foregroundColor(.accentColor) + Text("of scheduled") + .foregroundColor(.primary) + } + Spacer() + } + } + .foregroundColor(.primary) + } + } + +} diff --git a/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift b/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift index f2467b1e2c..aa538079d6 100644 --- a/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift +++ b/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift @@ -14,6 +14,7 @@ import SwiftUI struct PresetsAndExerciseContentView: View { @Environment(\.appName) private var appName + @Environment(\.settingsManager) private var settingsManager @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference enum StepNumber { @@ -132,7 +133,7 @@ struct PresetsAndExerciseContentView: View { Text("Some people use this feature before exercise for a pre-programmed 1-hour, 2-hour, or indefinite length of time in an effort to decrease their risk of low glucose during exercise or other physical activity.", comment: "Presets and exercise training content, scheduling preset, paragraph 1") } } - + @ViewBuilder var stepFourView: some View { Text("Once saved, Omar’s completed preset will display in his Presets lists.", comment: "Presets and exercise training content, scheduling preset, paragraph 2") @@ -153,7 +154,7 @@ struct PresetsAndExerciseContentView: View { ), duration: TemporaryScheduleOverride.Duration.finite(1800) ) - ) + ), guardrail: .correctionRange ) } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index bc719f1fa9..5edc43c3f3 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -171,7 +171,7 @@ struct SettingsView: View { } public var presetsView: some View { - PresetsView(viewModel: viewModel.presetsViewModel) + PresetsView() } private func menuItemsForSection(name: String) -> some View { diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 066a3e74af..24738812b2 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -89,61 +89,6 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} } -@MainActor -@Observable -class StatusTableViewModel { - let alertPermissionsChecker: AlertPermissionsChecker - let alertMuter: AlertMuter - let deviceDataManager: DeviceDataManager - let supportManager: SupportManager - let testingScenariosManager: TestingScenariosManager? - let loopDataManager: LoopDataManager - let diagnosticReportGenerator: DiagnosticReportGenerator - let simulatedData: SimulatedData - let analyticsServicesManager: AnalyticsServicesManager - let servicesManager: ServicesManager - let carbStore: CarbStore - let doseStore: DoseStore - let criticalEventLogExportManager: CriticalEventLogExportManager - let bluetoothStateManager: BluetoothStateManager - let settingsManager: SettingsManager - let automaticDosingStatus: AutomaticDosingStatus - let onboardingManager: OnboardingManager - let temporaryPresetsManager: TemporaryPresetsManager - let settingsViewModel: SettingsViewModel - - var pendingPreset: SelectablePreset? { - didSet { - settingsViewModel.presetsViewModel.pendingPreset = pendingPreset - } - } - - let legacyPresetsEnabled: Bool - - init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, settingsViewModel: SettingsViewModel, legacyPresetsEnabled: Bool = false) { - self.alertPermissionsChecker = alertPermissionsChecker - self.alertMuter = alertMuter - self.automaticDosingStatus = automaticDosingStatus - self.deviceDataManager = deviceDataManager - self.onboardingManager = onboardingManager - self.supportManager = supportManager - self.testingScenariosManager = testingScenariosManager - self.temporaryPresetsManager = temporaryPresetsManager - self.settingsManager = settingsManager - self.loopDataManager = loopDataManager - self.diagnosticReportGenerator = diagnosticReportGenerator - self.simulatedData = simulatedData - self.analyticsServicesManager = analyticsServicesManager - self.servicesManager = servicesManager - self.carbStore = carbStore - self.doseStore = doseStore - self.criticalEventLogExportManager = criticalEventLogExportManager - self.bluetoothStateManager = bluetoothStateManager - self.settingsViewModel = settingsViewModel - self.legacyPresetsEnabled = legacyPresetsEnabled - } -} - struct StatusTableView: View { private let wrapped: WrappedStatusTableViewController @@ -188,7 +133,7 @@ struct StatusTableView: View { case .addCarbs, .bolus, .settings: // No active states for these actions return false case .presets: - return viewModel.settingsViewModel.presetsViewModel.activePreset != nil + return viewModel.temporaryPresetsManager.activeOverride != nil } } @@ -201,15 +146,14 @@ struct StatusTableView: View { var body: some View { wrappedView - .onChange(of: viewModel.settingsViewModel.presetsViewModel.activePreset) { _, _ in + .onChange(of: viewModel.temporaryPresetsManager.activeOverride) { _, _ in Task { await viewController.reloadData(animated: true) } } - .sheet(item: $viewModel.pendingPreset) { _ in - PresetDetentView( - viewModel: viewModel.settingsViewModel.presetsViewModel - ) + .sheet(item: $viewModel.pendingPreset) { preset in + // This is the active preset; edit disabled + PresetDetentView(preset: preset, didTapEdit: { }) } .toolbar { ToolbarItem(placement: .bottomBar) { diff --git a/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift b/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift index ee2c47606b..7b799b591b 100644 --- a/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift +++ b/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift @@ -141,7 +141,7 @@ class InAppModalAlertSchedulerTests: XCTestCase { XCTAssertEqual("FOREGROUND", alertController?.title) } - func testRemoveImmediateAlert() { + @MainActor func testRemoveImmediateAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) inAppModalAlertScheduler.scheduleAlert(alert) From e5505891f52b03e1f48cd904b5cf7ba7744c39d8 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 21 Mar 2025 16:47:17 -0500 Subject: [PATCH 230/421] Fix tests (#772) --- LoopTests/Managers/TemporaryPresetsManagerTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopTests/Managers/TemporaryPresetsManagerTests.swift b/LoopTests/Managers/TemporaryPresetsManagerTests.swift index a1e097c76f..be831ce387 100644 --- a/LoopTests/Managers/TemporaryPresetsManagerTests.swift +++ b/LoopTests/Managers/TemporaryPresetsManagerTests.swift @@ -11,7 +11,7 @@ import LoopKit @testable import Loop - +@MainActor class TemporaryPresetsManagerTests: XCTestCase { private let preMealRange = DoubleRange(minValue: 80, maxValue: 80).quantityRange(for: .milligramsPerDeciliter) private let targetRange = DoubleRange(minValue: 95, maxValue: 105) @@ -30,7 +30,7 @@ class TemporaryPresetsManagerTests: XCTestCase { override func setUp() async throws { let settingsProvider = MockSettingsProvider(settings: settings) - manager = await TemporaryPresetsManager(settingsProvider: settingsProvider) + manager = TemporaryPresetsManager(settingsProvider: settingsProvider) } func testPreMealOverride() { From dc1a6e960f3d265ed6acce7f622e5c28aa141d01 Mon Sep 17 00:00:00 2001 From: Petr Zywczok Date: Fri, 21 Mar 2025 10:02:29 +0100 Subject: [PATCH 231/421] [QAE-459] Add accessibility identifiers --- LoopUI/Views/CGMStatusHUDView.swift | 6 ++++++ LoopUI/Views/DeviceStatusHUDView.swift | 8 +++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/LoopUI/Views/CGMStatusHUDView.swift b/LoopUI/Views/CGMStatusHUDView.swift index 2f53c0f89d..47af3dd61e 100644 --- a/LoopUI/Views/CGMStatusHUDView.swift +++ b/LoopUI/Views/CGMStatusHUDView.swift @@ -140,6 +140,12 @@ public final class CGMStatusHUDView: DeviceStatusHUDView, NibLoadable { presentStatusHighlight(viewModel.statusHighlight) accessibilityValue = viewModel.accessibilityString + accessibilityIdentifier = + if viewModel.glucoseValueString == "LOW" || viewModel.glucoseValueString == "HIGH" { + "glucoseHUDView_\(viewModel.glucoseValueString)" + } else { + "glucoseHUDView" + } } func updateTrendIcon() { diff --git a/LoopUI/Views/DeviceStatusHUDView.swift b/LoopUI/Views/DeviceStatusHUDView.swift index 32d7aea767..af0a9bfd99 100644 --- a/LoopUI/Views/DeviceStatusHUDView.swift +++ b/LoopUI/Views/DeviceStatusHUDView.swift @@ -51,10 +51,12 @@ import LoopKitUI resetProgress() return } - + progressView.isHidden = false progressView.progress = Float(lifecycleProgress.percentComplete.clamped(to: 0...1)) progressView.tintColor = lifecycleProgress.progressState.color + progressView.accessibilityIdentifier = + "progressBar_State_\(lifecycleProgress.progressState.rawValue)" } } @@ -97,8 +99,8 @@ import LoopKitUI } private func presentStatusHighlight(withMessage message: String, - image: UIImage?, - color: UIColor) + image: UIImage?, + color: UIColor) { statusHighlightView.messageLabel.text = message statusHighlightView.messageLabel.tintColor = .label From 2c8f48e6f7bf53fcccdf03f786d906352511e9b7 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Sat, 29 Mar 2025 13:01:33 -0700 Subject: [PATCH 232/421] [LOOP-5311] Fix withObservationTracking for LoopCircleView (#773) --- Loop/Extensions/Publisher.swift | 37 ++++++++-------- Loop/Managers/LoopAppManager.swift | 13 +++--- Loop/Managers/LoopDataManager.swift | 44 ++++++++----------- Loop/Models/AutomaticDosingStatus.swift | 7 +-- .../StatusTableViewController.swift | 8 ++-- 5 files changed, 50 insertions(+), 59 deletions(-) diff --git a/Loop/Extensions/Publisher.swift b/Loop/Extensions/Publisher.swift index 16f93d073d..c2fa3bbd78 100644 --- a/Loop/Extensions/Publisher.swift +++ b/Loop/Extensions/Publisher.swift @@ -7,34 +7,35 @@ // import Combine +import Foundation import Observation +public func withObservationTracking(of value: @escaping @autoclosure () -> T, execute: @escaping (T) -> Void) { + Observation.withObservationTracking { + execute(value()) + } onChange: { + RunLoop.current.perform { + withObservationTracking(of: value(), execute: execute) + } + } +} + + enum ObservablePublishers { static func tracking( _ object: Object, keyPath: KeyPath ) -> AnyPublisher { let subject = PassthroughSubject() - - Task { - while true { - // Get the value and track access - let initialValue = withObservationTracking { - object[keyPath: keyPath] - } onChange: { - // When change happens, continue the loop - Task { @MainActor in - subject.send(object[keyPath: keyPath]) - } - } - - // Send initial value - subject.send(initialValue) - - // Wait until the next change - try? await Task.sleep(for: .seconds(100)) + + withObservationTracking(of: object[keyPath: keyPath]) { newValue in + // When change happens, continue the loop + Task { @MainActor in + subject.send(newValue) } } + + subject.send(object[keyPath: keyPath]) return subject.eraseToAnyPublisher() } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 538be5efbb..20dbbe7ba3 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -499,13 +499,9 @@ class LoopAppManager: NSObject { analyticsServicesManager.application(didFinishLaunchingWithOptions: launchOptions) - let dosingEnablePublisher = ObservablePublishers.tracking(settingsManager, keyPath: \.dosingEnabled) - - automaticDosingStatus.$isAutomaticDosingAllowed - .combineLatest(dosingEnablePublisher) - .map { $0 && $1 } - .assign(to: \.automaticDosingStatus.automaticDosingEnabled, on: self) - .store(in: &cancellables) + withObservationTracking(of: self.automaticDosingStatus.isAutomaticDosingAllowed && self.settingsManager.dosingEnabled) { [weak self] enabled in + self?.automaticDosingStatus.automaticDosingEnabled = enabled + } state = state.next @@ -582,7 +578,8 @@ class LoopAppManager: NSObject { criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, + initialDosingEnabled: self.settingsManager.settings.dosingEnabled, + automaticDosingStatus: self.automaticDosingStatus, automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, lastLoopCompletion: loopDataManager.$lastLoopCompleted, mostRecentGlucoseDataDate: loopDataManager.$publishedMostRecentGlucoseDataDate, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index efe33a2499..fd475cb0d9 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -243,34 +243,28 @@ final class LoopDataManager: ObservableObject { // 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. - automaticDosingStatus.$automaticDosingEnabled - .removeDuplicates() - .dropFirst() - .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) - }) + + withObservationTracking(of: automaticDosingStatus.automaticDosingEnabled) { [weak self] enabled in + 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 { + temporaryPresetsManager.clearOverride(matching: .preMeal) + Task { + try? await self?.cancelActiveTempBasal(for: .automaticDosingDisabled) } - if !enabled { - self.temporaryPresetsManager.clearOverride(matching: .preMeal) - Task { - try? await self.cancelActiveTempBasal(for: .automaticDosingDisabled) - } - } else { - Task { - await self.updateDisplayState() - } + } else { + Task { + await self?.updateDisplayState() } } - .store(in: &cancellables) + } } // MARK: - Calculation state diff --git a/Loop/Models/AutomaticDosingStatus.swift b/Loop/Models/AutomaticDosingStatus.swift index c5b66e955c..87cf764d12 100644 --- a/Loop/Models/AutomaticDosingStatus.swift +++ b/Loop/Models/AutomaticDosingStatus.swift @@ -8,9 +8,10 @@ import Foundation -public class AutomaticDosingStatus: ObservableObject { - @Published public var automaticDosingEnabled: Bool - @Published public var isAutomaticDosingAllowed: Bool +@Observable +public class AutomaticDosingStatus { + public var automaticDosingEnabled: Bool + public var isAutomaticDosingAllowed: Bool public init(automaticDosingEnabled: Bool, isAutomaticDosingAllowed: Bool) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index b204db83d0..5dfc4d5316 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -162,11 +162,9 @@ final class StatusTableViewController: LoopChartsTableViewController { }, ] - automaticDosingStatus.$automaticDosingEnabled - .receive(on: DispatchQueue.main) - .dropFirst() - .sink { self.automaticDosingStatusChanged($0) } - .store(in: &cancellables) + withObservationTracking(of: self.automaticDosingStatus.automaticDosingEnabled) { [weak self] enabled in + self?.automaticDosingStatusChanged(enabled) + } alertMuter.$configuration .removeDuplicates() From 218eac30cf36f56e58a582f5ae10a10fc2f66236 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 1 Apr 2025 11:13:09 -0500 Subject: [PATCH 233/421] LOOP-5233 Updates from design review (#774) * Updates from design review * Force popup to display above days --- .../Components/InsulinScaleAdjustView.swift | 32 ++++++++++++++----- .../CreatePresetNameAndScheduledEdit.swift | 2 +- Loop/Views/Presets/CreatePresetView.swift | 15 +++++++-- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift index c7e0c85c74..45a813cce1 100644 --- a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift +++ b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift @@ -40,7 +40,7 @@ public struct InsulinScaleAdjustView: View { if let baseQuantity = settingsManager.settings.insulinSensitivitySchedule?.quantity(at: Date()) { let value = baseQuantity.doubleValue(for: .milligramsPerDeciliter) let adjustedValue = value / insulinMultiplier - return LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: adjustedValue) + return LoopQuantity(unit: .milligramsPerDeciliterPerInternationalUnit, doubleValue: adjustedValue) } else { return nil } @@ -62,6 +62,8 @@ public struct InsulinScaleAdjustView: View { } .buttonStyle(BorderlessButtonStyle()) } + .padding(.top, -5) + Text("Set your overall insulin needs") .font(.title2) @@ -139,38 +141,52 @@ public struct InsulinScaleAdjustView: View { Text("Note: These example values are based on your current settings. Values may be different when you enable the preset.") .font(.footnote) .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) } .font(.subheadline) .multilineTextAlignment(.leading) } + private var sensitivityUnit: LoopUnit { + switch displayGlucosePreference.unit { + case .milligramsPerDeciliter: + return .milligramsPerDeciliterPerInternationalUnit + case .millimolesPerLiter: + return .millimolesPerLiterPerInternationalUnit + default: + fatalError() + } + } + + private var exampleSettings: some View { Group { if let basalRate = basalRate, let carbRatio = carbRatio, let isf = isf { - HStack(spacing: 32) { + HStack(spacing: 0) { SettingAdjustmentPreview( value: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: basalRate), displayUnit: .internationalUnitsPerHour, name: "Basal Rate", highlighted: insulinPercentage != 100 ) - .frame(maxWidth: .infinity) + + Spacer() SettingAdjustmentPreview( - value: LoopQuantity(unit: .gram, doubleValue: carbRatio), - displayUnit: .gram, + value: LoopQuantity(unit: .gramsPerUnit, doubleValue: carbRatio), + displayUnit: .gramsPerUnit, name: "Carb Ratio", highlighted: insulinPercentage != 100 ) - .frame(maxWidth: .infinity) + + Spacer() SettingAdjustmentPreview( value: isf, - displayUnit: displayGlucosePreference.unit, + displayUnit: sensitivityUnit, name: "ISF", highlighted: insulinPercentage != 100 ) - .frame(maxWidth: .infinity) } } } diff --git a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift index 7a9b0800e5..b988a93f7e 100644 --- a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift +++ b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift @@ -204,7 +204,7 @@ struct CreatePresetNameAndScheduledEdit: View { } } } - .popover(isPresented: $showingDayPicker) { + .popover(isPresented: $showingDayPicker, arrowEdge: .bottom) { DayPickerPopup(selectedDays: Binding( get: { preset.repeatOptions ?? .none diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift index fc7bdf3b41..462347de4e 100644 --- a/Loop/Views/Presets/CreatePresetView.swift +++ b/Loop/Views/Presets/CreatePresetView.swift @@ -30,13 +30,22 @@ struct SettingAdjustmentPreview: View { self.displayUnit = displayUnit self.name = name self.formatter = QuantityFormatter(for: displayUnit) + if displayUnit == .internationalUnitsPerHour { + // Basal rates get special treatment here. Loop's default max for basal rate is 3 digits, + // to support pumps that support that. The value shown here does not represent an actual + // set basal rate, but rather a value computed by loop, used in computing insulin effects, + // and is somewhat independent of pump supported rates. 2 digits is generally enough + // precision here. + self.formatter.numberFormatter.maximumFractionDigits = 2 + } self.highlighted = highlighted } var valueRow: some View { - Text(formatter.string(from: value, includeUnit: false) ?? "NA") + (Text(formatter.string(from: value, includeUnit: false) ?? "NA") .bold() + Text(" ") + - Text(displayUnit.shortLocalizedUnitString()) + Text(displayUnit.shortLocalizedUnitString())) + .fixedSize(horizontal: false, vertical: true) } var body: some View { @@ -114,7 +123,7 @@ struct CreatePresetView: View { } } } - .navigationTitle("Create a preset") + .navigationTitle("Create a Preset") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Cancel") { From 20f4c96265074432d09cd752194a3f4f960e4507 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 1 Apr 2025 13:24:33 -0500 Subject: [PATCH 234/421] fix width of panel on review screen (#775) --- Loop/Views/Presets/ReviewNewPresetView.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Loop/Views/Presets/ReviewNewPresetView.swift b/Loop/Views/Presets/ReviewNewPresetView.swift index 695914f833..3c0e582750 100644 --- a/Loop/Views/Presets/ReviewNewPresetView.swift +++ b/Loop/Views/Presets/ReviewNewPresetView.swift @@ -49,12 +49,10 @@ struct ReviewNewPresetView: View { .foregroundColor(.white) .padding(.horizontal, 16) .padding(.vertical, 10) - .background(RoundedRectangle(cornerRadius: 10) - .fill(Color.accentColor) - .frame(maxWidth: .infinity)) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.accentColor) + .cornerRadius(10) .padding(.top, 10) - .clipped() - sensitivitySection From a690172be9dfe41b85c54fc0fb91febf2608bb02 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 3 Apr 2025 11:54:10 -0500 Subject: [PATCH 235/421] Add UI warnings for insulin scale guardrails (#776) --- .../Components/InsulinScaleAdjustView.swift | 20 ++++++++- Loop/Views/Presets/CreatePresetView.swift | 41 +++++++++++++++++++ Loop/Views/Presets/PresetRangeEditor.swift | 19 --------- 3 files changed, 60 insertions(+), 20 deletions(-) diff --git a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift index 45a813cce1..819428903b 100644 --- a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift +++ b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift @@ -8,10 +8,12 @@ import SwiftUI import LoopAlgorithm +import LoopKit import LoopKitUI public struct InsulinScaleAdjustView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.guidanceColors) private var guidanceColors @Environment(\.settingsManager) private var settingsManager @State private var presentInfoView: Bool = false @@ -88,6 +90,20 @@ public struct InsulinScaleAdjustView: View { } } + var valueColor: Color { + switch Guardrail.presetInsulinNeeds.classification(for: .init(unit: .percent, doubleValue: insulinPercentage)) { + case .withinRecommendedRange: + return .insulin + case .outsideRecommendedRange(let threshold): + switch threshold { + case .minimum, .maximum: + return guidanceColors.critical + case .belowRecommended, .aboveRecommended: + return guidanceColors.warning + } + } + } + private var adjustInsulinControls: some View { HStack(spacing: 24) { Button(action: { @@ -104,7 +120,7 @@ public struct InsulinScaleAdjustView: View { Text("\(Int(insulinPercentage))%") .font(.system(size: 50, weight: .bold)) - .foregroundColor(.insulin) + .foregroundColor(valueColor) Button(action: { if insulinPercentage < 200 { @@ -128,8 +144,10 @@ public struct InsulinScaleAdjustView: View { if insulinPercentage < 100 { Text("This adjustment will make your settings weaker.") + .fixedSize(horizontal: false, vertical: true) } else if (insulinPercentage > 100) { Text("This adjustment will make your settings stronger.") + .fixedSize(horizontal: false, vertical: true) } else { Text("No change to insulin settings.") } diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift index 462347de4e..3f3a2f27d6 100644 --- a/Loop/Views/Presets/CreatePresetView.swift +++ b/Loop/Views/Presets/CreatePresetView.swift @@ -134,8 +134,28 @@ struct CreatePresetView: View { } } + var exceededThreshold: SafetyClassification.Threshold? { + switch Guardrail.presetInsulinNeeds.classification(for: .init(unit: .percent, doubleValue: preset.insulinMultiplier * 100)) { + case .withinRecommendedRange: + return nil + case .outsideRecommendedRange(let threshold): + return threshold + } + + } + + var guardrailWarningIfNecessary: some View { + Group { + if let threshold = exceededThreshold { + WarningView(title: threshold.warningTitle, caption: threshold.warningCaption, severity: threshold.severity) + .padding() + } + } + } + private var actionArea: some View { VStack(spacing: 0) { + guardrailWarningIfNecessary actionButton } .background(Color(.secondarySystemGroupedBackground).shadow(radius: 5)) @@ -148,9 +168,30 @@ struct CreatePresetView: View { .buttonStyle(ActionButtonStyle(.primary)) .padding() } +} + +extension SafetyClassification.Threshold { + public var warningTitle: Text { + switch self { + case .belowRecommended, .minimum: + return Text("Insulin adjustment is below the safety threshold") + case .aboveRecommended, .maximum: + return Text("Insulin adjustment is above the safety threshold") + } + } + + public var warningCaption: Text { + switch self { + case .belowRecommended, .minimum: + return Text("Using this adjustment may lead to an under delivery of insulin. Monitor your glucose while this preset is in use.") + case .aboveRecommended, .maximum: + return Text("Using this adjustment may lead to an over delivery of insulin. Monitor your glucose while this preset is in use.") + } + } } + #Preview { CreatePresetView() } diff --git a/Loop/Views/Presets/PresetRangeEditor.swift b/Loop/Views/Presets/PresetRangeEditor.swift index e804ac350d..57bcb8c506 100644 --- a/Loop/Views/Presets/PresetRangeEditor.swift +++ b/Loop/Views/Presets/PresetRangeEditor.swift @@ -163,23 +163,4 @@ struct PresetRangeEditor: View { } } } - - - - var crossedThresholds: [SafetyClassification.Threshold] { - if let range = range { - let lowerBound = range.lowerBound - let upperBound = range.upperBound - return [lowerBound, upperBound].compactMap { (bound) -> SafetyClassification.Threshold? in - switch guardrail.classification(for: bound) { - case .withinRecommendedRange: - return nil - case .outsideRecommendedRange(let threshold): - return threshold - } - } - } else { - return [] - } - } } From bddd31b30b6f78ac3ab1042d72f66f7c5bc4f827 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 7 Apr 2025 10:39:43 -0700 Subject: [PATCH 236/421] [LOOP-5280] Display Glucose Preference by InternationalUnit (#777) --- Common/Models/WatchContext.swift | 2 +- Loop/Models/SelectablePreset.swift | 8 ++ .../Components/InsulinScaleAdjustView.swift | 34 +----- .../Views/Presets/Components/PresetCard.swift | 4 +- .../Presets/Components/PresetDetentView.swift | 4 +- .../Presets/Components/PresetStatsView.swift | 102 +++--------------- Loop/Views/Presets/CreatePresetView.swift | 17 +-- Loop/Views/Presets/PresetsView.swift | 2 +- 8 files changed, 42 insertions(+), 131 deletions(-) diff --git a/Common/Models/WatchContext.swift b/Common/Models/WatchContext.swift index c35694390e..cc32c4d20c 100644 --- a/Common/Models/WatchContext.swift +++ b/Common/Models/WatchContext.swift @@ -125,7 +125,7 @@ final class WatchContext: RawRepresentable { raw["gc"] = glucoseCondition?.rawValue raw["gt"] = glucoseTrend?.rawValue if let glucoseTrendRate = glucoseTrendRate { - let unitPerMinute = unit.glucose(per: .minutes) + let unitPerMinute = unit.unitDivided(by: .minute) raw["gtru"] = unitPerMinute.unitString raw["gtrv"] = glucoseTrendRate.doubleValue(for: unitPerMinute) } diff --git a/Loop/Models/SelectablePreset.swift b/Loop/Models/SelectablePreset.swift index 0d20573610..1933b3948d 100644 --- a/Loop/Models/SelectablePreset.swift +++ b/Loop/Models/SelectablePreset.swift @@ -204,6 +204,14 @@ enum SelectablePreset: Hashable, Identifiable { return nil } } + + var insulinMultiplier: Double? { + guard let insulinSensitivityMultiplier else { + return nil + } + + return 1.0 / insulinSensitivityMultiplier + } var canAdjustSensitivity: Bool { switch self { diff --git a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift index 819428903b..e70d377c22 100644 --- a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift +++ b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift @@ -24,30 +24,6 @@ public struct InsulinScaleAdjustView: View { get { return (insulinMultiplier * 100).rounded() } } - var basalRate: Double? { - if let baseValue = settingsManager.settings.basalRateSchedule?.value(at: Date()) { - return baseValue * insulinMultiplier - } else { - return nil - } - } - var carbRatio: Double? { - if let baseValue = settingsManager.settings.carbRatioSchedule?.value(at: Date()) { - return baseValue / insulinMultiplier - } else { - return nil - } - } - var isf: LoopQuantity? { - if let baseQuantity = settingsManager.settings.insulinSensitivitySchedule?.quantity(at: Date()) { - let value = baseQuantity.doubleValue(for: .milligramsPerDeciliter) - let adjustedValue = value / insulinMultiplier - return LoopQuantity(unit: .milligramsPerDeciliterPerInternationalUnit, doubleValue: adjustedValue) - } else { - return nil - } - } - public var body: some View { // Header Section VStack(spacing: 16) { @@ -179,10 +155,11 @@ public struct InsulinScaleAdjustView: View { private var exampleSettings: some View { Group { - if let basalRate = basalRate, let carbRatio = carbRatio, let isf = isf { + let impact = settingsManager.therapySettings.impact(for: insulinMultiplier) + if let basalRate = impact.basalRate, let carbRatio = impact.carbRatio, let isf = impact.isf { HStack(spacing: 0) { SettingAdjustmentPreview( - value: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: basalRate), + value: basalRate, displayUnit: .internationalUnitsPerHour, name: "Basal Rate", highlighted: insulinPercentage != 100 @@ -191,8 +168,7 @@ public struct InsulinScaleAdjustView: View { Spacer() SettingAdjustmentPreview( - value: LoopQuantity(unit: .gramsPerUnit, doubleValue: carbRatio), - displayUnit: .gramsPerUnit, + value: carbRatio, name: "Carb Ratio", highlighted: insulinPercentage != 100 ) @@ -201,7 +177,7 @@ public struct InsulinScaleAdjustView: View { SettingAdjustmentPreview( value: isf, - displayUnit: sensitivityUnit, + displayUnit: displayGlucosePreference.unit.unitDivided(by: .internationalUnit), name: "ISF", highlighted: insulinPercentage != 100 ) diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index d66509a451..d790192ac0 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -19,7 +19,7 @@ struct PresetCard: View { let icon: PresetIcon let presetName: String let duration: PresetDuration - let insulinSensitivityMultiplier: Double? + let insulinMultiplier: Double? let correctionRange: ClosedRange? let guardrail: Guardrail? let expectedEndTime: PresetExpectedEndTime? @@ -95,7 +95,7 @@ struct PresetCard: View { .padding(.horizontal, -10) PresetStatsView( - insulinSensitivityMultiplier: insulinSensitivityMultiplier, + insulinMultiplier: insulinMultiplier, correctionRange: correctionRange, guardrail: guardrail, therapySettingsImpactDisplayState: .hide diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 1156e1afc9..f643467da4 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -100,7 +100,7 @@ struct PresetDetentView: View { @State var sheetContentHeight: Double = 0 var settingsImpact: TherapySettings.InsulinMultiplierImpact { - settingsManager.therapySettings.impact(for: preset.insulinSensitivityMultiplier ?? 1.0) + settingsManager.therapySettings.impact(for: preset.insulinMultiplier ?? 1.0) } var body: some View { @@ -130,7 +130,7 @@ struct PresetDetentView: View { Divider() PresetStatsView( - insulinSensitivityMultiplier: preset.insulinSensitivityMultiplier, + insulinMultiplier: preset.insulinMultiplier, correctionRange: preset.correctionRange, guardrail: settingsManager.guardrailForPreset(preset), therapySettingsImpactDisplayState: operation == .end ? .show(settingsImpact) : .hide diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift index 105635ac89..0da1c1606c 100644 --- a/Loop/Views/Presets/Components/PresetStatsView.swift +++ b/Loop/Views/Presets/Components/PresetStatsView.swift @@ -20,7 +20,7 @@ struct PresetStatsView: View { @Environment(\.guidanceColors) private var guidanceColors @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - let insulinSensitivityMultiplier: Double? + let insulinMultiplier: Double? let correctionRange: ClosedRange? let guardrail: Guardrail? let therapySettingsImpactDisplayState: TherapySettingsImpactDisplayState @@ -38,7 +38,7 @@ struct PresetStatsView: View { .foregroundColor(.secondary) .accessibilitySortPriority(2) - let percent = numberFormatter.string(from: 1.0/(insulinSensitivityMultiplier ?? 1))! + let percent = numberFormatter.string(from: insulinMultiplier ?? 1)! Group { Text(percent).bold() + Text(" of scheduled") } .font(.subheadline) .accessibilitySortPriority(1) @@ -132,72 +132,6 @@ struct PresetStatsView: View { .accessibilityElement(children: .contain) } - @ViewBuilder - func basalRateView(basalRateValue: String, condensed: Bool) -> some View { - let label = Text("Basal Rate").font(.subheadline) - let value = Group { - Text(basalRateValue).bold() + - Text(" \(LoopUnit.internationalUnitsPerHour.unitString)") - }.font(.subheadline) - - if condensed { - HStack { - label + Text(": ") - value - } - } else { - VStack(alignment: .leading, spacing: 4) { - value - label - } - } - } - - @ViewBuilder - func carbRatioView(carbRatioValue: String, condensed: Bool) -> some View { - let label = Text("Carb Ratio").font(.subheadline) - let value = Group { - Text(carbRatioValue).bold() + - Text(" \(LoopUnit.gram.unitString)") - }.font(.subheadline) - - if condensed { - HStack { - label + Text(": ") - value - } - } else { - VStack(alignment: .leading, spacing: 4) { - value - label - } - } - } - - @ViewBuilder - func isfView(isfValue: String, condensed: Bool) -> some View { - let label = Text("ISF").font(.subheadline) - let value = Group { - Text(isfValue).bold() + - Text(" \(displayGlucosePreference.unit.unitString)") - }.font(.subheadline) - - if condensed { - HStack { - label + Text(": ") - value - } - } else { - VStack(alignment: .leading, spacing: 4) { - value - label - } - } - } - - private let basalRateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) - private let carbRatioFormatter = QuantityFormatter(for: .gram) - var body: some View { VStack(alignment: .leading, spacing: 24) { ViewThatFits(in: .horizontal) { @@ -213,7 +147,7 @@ struct PresetStatsView: View { } } - if case let .show(insulinMultiplierImpact) = therapySettingsImpactDisplayState, (insulinSensitivityMultiplier ?? 1) != 1 { + if case let .show(insulinMultiplierImpact) = therapySettingsImpactDisplayState, (insulinMultiplier ?? 1) != 1, let basalRate = insulinMultiplierImpact.basalRate, let carbRatio = insulinMultiplierImpact.carbRatio, let isf = insulinMultiplierImpact.isf { VStack(alignment: .leading, spacing: 8) { Text("Settings Impact") .font(.subheadline) @@ -221,33 +155,23 @@ struct PresetStatsView: View { ViewThatFits(in: .horizontal) { HStack(spacing: 0) { - if let basalRate = insulinMultiplierImpact.basalRate, let basalRateValue = basalRateFormatter.string(from: basalRate, includeUnit: false) { - basalRateView(basalRateValue: basalRateValue, condensed: false) - Spacer() - } + SettingAdjustmentPreview(value: basalRate, displayUnit: .internationalUnitsPerHour, name: "Basal Rate", highlighted: false) - if let carbRatio = insulinMultiplierImpact.carbRatio, let carbRatioValue = carbRatioFormatter.string(from: carbRatio, includeUnit: false) { - carbRatioView(carbRatioValue: carbRatioValue, condensed: false) - Spacer() - } + Spacer() + + SettingAdjustmentPreview(value: carbRatio, name: "Carb Ratio", highlighted: false) + + Spacer() - if let isf = insulinMultiplierImpact.isf, let isfValue = displayGlucosePreference.formatter.string(from: isf, includeUnit: false) { - isfView(isfValue: isfValue, condensed: false) - } + SettingAdjustmentPreview(value: isf, displayUnit: displayGlucosePreference.unit.unitDivided(by: .internationalUnit), name: "ISF", highlighted: false) } VStack(alignment: .leading, spacing: 8) { - if let basalRate = insulinMultiplierImpact.basalRate, let basalRateValue = basalRateFormatter.string(from: basalRate, includeUnit: false) { - basalRateView(basalRateValue: basalRateValue, condensed: true) - } + SettingAdjustmentPreview(value: basalRate, displayUnit: .internationalUnitsPerHour, name: "Basal Rate", highlighted: false) - if let carbRatio = insulinMultiplierImpact.carbRatio, let carbRatioValue = carbRatioFormatter.string(from: carbRatio, includeUnit: false) { - carbRatioView(carbRatioValue: carbRatioValue, condensed: true) - } + SettingAdjustmentPreview(value: carbRatio, name: "Carb Ratio", highlighted: false) - if let isf = insulinMultiplierImpact.isf, let isfValue = displayGlucosePreference.formatter.string(from: isf, includeUnit: false) { - isfView(isfValue: isfValue, condensed: true) - } + SettingAdjustmentPreview(value: isf, displayUnit: displayGlucosePreference.unit.unitDivided(by: .internationalUnit), name: "ISF", highlighted: false) } } } diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift index 3f3a2f27d6..275783156d 100644 --- a/Loop/Views/Presets/CreatePresetView.swift +++ b/Loop/Views/Presets/CreatePresetView.swift @@ -22,27 +22,30 @@ struct SettingAdjustmentPreview: View { let value: LoopQuantity let displayUnit: LoopUnit let name: String - private let formatter: QuantityFormatter + private let valueFormatter: QuantityFormatter + private let unitFormatter: QuantityFormatter private let highlighted: Bool - init(value: LoopQuantity, displayUnit: LoopUnit, name: String, highlighted: Bool = false) { + init(value: LoopQuantity, displayUnit: LoopUnit? = nil, name: String, highlighted: Bool = false) { self.value = value - self.displayUnit = displayUnit + self.displayUnit = displayUnit ?? value.unit self.name = name - self.formatter = QuantityFormatter(for: displayUnit) - if displayUnit == .internationalUnitsPerHour { + self.valueFormatter = QuantityFormatter(for: value.unit) + self.unitFormatter = QuantityFormatter(for: self.displayUnit) + if self.displayUnit == .internationalUnitsPerHour { // Basal rates get special treatment here. Loop's default max for basal rate is 3 digits, // to support pumps that support that. The value shown here does not represent an actual // set basal rate, but rather a value computed by loop, used in computing insulin effects, // and is somewhat independent of pump supported rates. 2 digits is generally enough // precision here. - self.formatter.numberFormatter.maximumFractionDigits = 2 + self.valueFormatter.numberFormatter.maximumFractionDigits = 2 } + self.highlighted = highlighted } var valueRow: some View { - (Text(formatter.string(from: value, includeUnit: false) ?? "NA") + (Text(valueFormatter.string(from: value, includeUnit: false) ?? "NA") .bold() + Text(" ") + Text(displayUnit.shortLocalizedUnitString())) .fixedSize(horizontal: false, vertical: true) diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 3c3c21f9f0..20143952ba 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -270,7 +270,7 @@ extension PresetCard { icon: preset.icon, presetName: preset.name, duration: preset.duration, - insulinSensitivityMultiplier: preset.insulinSensitivityMultiplier, + insulinMultiplier: preset.insulinMultiplier, correctionRange: preset.correctionRange, guardrail: guardrail, expectedEndTime: expectedEndTime From 82c9158e6b8b230c9af2071660fe13774ff9b2b7 Mon Sep 17 00:00:00 2001 From: Petr Zywczok Date: Mon, 24 Mar 2025 07:44:24 +0100 Subject: [PATCH 237/421] [QAE-466] Add test ID for Carb Entry view --- Loop/View Controllers/CarbAbsorptionViewController.swift | 1 + Loop/Views/BolusProgressTableViewCell.swift | 6 ++++++ Loop/Views/CarbEntryView.swift | 1 + 3 files changed, 8 insertions(+) diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index 8e7fc59406..92c81baceb 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -293,6 +293,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif case .entries: let unit = LoopUnit.gram let cell = tableView.dequeueReusableCell(withIdentifier: CarbEntryTableViewCell.className, for: indexPath) as! CarbEntryTableViewCell + cell.accessibilityIdentifier = "cell_CarbEntry" // Entry value let status = carbStatuses[indexPath.row] diff --git a/Loop/Views/BolusProgressTableViewCell.swift b/Loop/Views/BolusProgressTableViewCell.swift index 3fa7149648..61f3e25340 100644 --- a/Loop/Views/BolusProgressTableViewCell.swift +++ b/Loop/Views/BolusProgressTableViewCell.swift @@ -94,10 +94,12 @@ public class BolusProgressTableViewCell: UITableViewCell { tapToStopLabel.isHidden = true progressLabel.text = NSLocalizedString("Starting Bolus", comment: "The title of the cell indicating a bolus is being sent") + progressLabel.accessibilityIdentifier = "text_BolusStarting" case let .bolusing(delivered, totalVolume): progressIndicator.isHidden = false activityIndicator.isHidden = true tapToStopLabel.isHidden = false + tapToStopLabel.accessibilityIdentifier = "text_TapToStop" let totalUnitsQuantity = LoopQuantity(unit: .internationalUnit, doubleValue: totalVolume) let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" @@ -107,6 +109,7 @@ public class BolusProgressTableViewCell: UITableViewCell { let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" progressLabel.text = String(format: NSLocalizedString("Bolused %1$@ of %2$@", comment: "The format string for bolus progress. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) + progressLabel.accessibilityIdentifier = "text_BolusingProgress" let progress = delivered / totalVolume @@ -115,6 +118,7 @@ public class BolusProgressTableViewCell: UITableViewCell { } } else { progressLabel.text = String(format: NSLocalizedString("Bolusing %1$@", comment: "The format string for bolus in progress showing total volume. (1: total volume)"), totalUnitsString) + progressLabel.accessibilityIdentifier = "text_BolusingProgress" } case .canceling: progressIndicator.isHidden = true @@ -122,6 +126,7 @@ public class BolusProgressTableViewCell: UITableViewCell { tapToStopLabel.isHidden = true progressLabel.text = NSLocalizedString("Canceling Bolus", comment: "The title of the cell indicating a bolus is being canceled") + progressLabel.accessibilityIdentifier = "text_BolusCanceling" case let .canceled(delivered, totalVolume): progressIndicator.isHidden = true activityIndicator.isHidden = true @@ -134,6 +139,7 @@ public class BolusProgressTableViewCell: UITableViewCell { let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" progressLabel.text = String(format: NSLocalizedString("Bolus Canceled: Delivered %1$@ of %2$@", comment: "The title of the cell indicating a bolus has been canceled. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) + progressLabel.accessibilityIdentifier = "text_BolusCanceled" } } diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index 649d645599..c2ff5580b3 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -46,6 +46,7 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { ToolbarItem(placement: .navigationBarTrailing) { continueButton + .accessibilityIdentifier("button_Continue") } } } From 0ca22b477213fff78154aa4cccd1e152f019867c Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Thu, 10 Apr 2025 10:59:07 -0700 Subject: [PATCH 238/421] Adding Identifiers Adding Accessibility Identifiers to elements within the Simple Bolus Calculator and Simple Meal Calculator screens. --- Loop/Views/SimpleBolusView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index 6cb8f33de8..0b1f6da3df 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -133,6 +133,7 @@ struct SimpleBolusView: View { .padding([.top, .bottom], 5) .fixedSize() .modifier(LabelBackground()) + .accessibilityIdentifier("Carbohydrates Field") } } @@ -154,6 +155,7 @@ struct SimpleBolusView: View { .onAppear { shouldGlucoseEntryBecomeFirstResponder = true } + .accessibilityIdentifier("Current Glucose Field") glucoseUnitsLabel } @@ -172,6 +174,7 @@ struct SimpleBolusView: View { .font(.title) .foregroundColor(Color(.label)) .padding([.top, .bottom], 4) + .accessibilityIdentifier("Recommended Bolus Amount") bolusUnitsLabel } } @@ -216,6 +219,7 @@ struct SimpleBolusView: View { } .fixedSize() .modifier(LabelBackground()) + .accessibilityIdentifier("Bolus Field") } } From ef2a239111e536921ce8ecc102bda3976a724400 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 11 Apr 2025 12:21:42 -0500 Subject: [PATCH 239/421] Add instructions when user has not set insulin adjustment or correction range (#780) --- Loop/Views/Presets/NewPresetRangeEdit.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Loop/Views/Presets/NewPresetRangeEdit.swift b/Loop/Views/Presets/NewPresetRangeEdit.swift index f8a14c3dce..7afa1c7ae3 100644 --- a/Loop/Views/Presets/NewPresetRangeEdit.swift +++ b/Loop/Views/Presets/NewPresetRangeEdit.swift @@ -33,6 +33,26 @@ struct NewPresetRangeEdit: View { ) } } actionArea: { + if preset.insulinMultiplier == 1 && editedRange == nil { + HStack { + VStack(alignment: .leading, spacing: 0) { + Text("Set an Adjusted Correction Range") + .font(Font(UIFont.preferredFont(forTextStyle: .title3))) + .bold() + .padding(.vertical) + + Text("With overall insulin needs at 100%, an adjusted correction range is required.") + .font(.callout) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + + Spacer() + } + .padding(.horizontal) + + } guardrailWarningIfNecessary actionButton } From 79f575289cf1ea44ec0de6294df955ece900f0b9 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 11 Apr 2025 15:33:25 -0500 Subject: [PATCH 240/421] Add correction range info screen (#781) --- Loop.xcodeproj/project.pbxproj | 4 + .../CorrectionRangeInformationView.swift | 86 +++++++++++++++++++ .../Presets/InsulinScaleInformationView.swift | 24 +++--- Loop/Views/Presets/NewPresetRangeEdit.swift | 2 +- Loop/Views/Presets/PresetRangeEditor.swift | 14 +-- 5 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 Loop/Views/Presets/CorrectionRangeInformationView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 697c9f348c..0aeae83775 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -430,6 +430,7 @@ C10509792D8B636900118A37 /* Environment+TemporaryPresetsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509782D8B635F00118A37 /* Environment+TemporaryPresetsManager.swift */; }; C105097B2D8B947B00118A37 /* SelectablePreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105097A2D8B947700118A37 /* SelectablePreset.swift */; }; C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C110888C2A3913C600BA4898 /* BuildDetails.swift */; }; + C11445B22DA98A3400034864 /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */; }; C11613492983096D00777E7C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C11613472983096D00777E7C /* InfoPlist.strings */; }; C116134C2983096D00777E7C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C116134A2983096D00777E7C /* Localizable.strings */; }; C11B9D5B286778A800500CF8 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C11B9D5A286778A800500CF8 /* SwiftCharts */; }; @@ -1384,6 +1385,7 @@ C105097A2D8B947700118A37 /* SelectablePreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectablePreset.swift; sourceTree = ""; }; C1092BFD29F8116700AE3D1C /* apply-info-customizations.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "apply-info-customizations.sh"; sourceTree = ""; }; C110888C2A3913C600BA4898 /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = ""; }; + C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; C11613482983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; C116134B2983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; C116134D2983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/ckcomplication.strings; sourceTree = ""; }; @@ -2538,6 +2540,7 @@ 84E8BBAF2CC979300078E6CF /* Presets */ = { isa = PBXGroup; children = ( + C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */, C105095A2D78D32C00118A37 /* CreatePresetNameAndScheduledEdit.swift */, C1AC03952D6E07D3004D4D2B /* CreatePresetView.swift */, C14F68C82D4AC54000BC3B8D /* DurationPickerView.swift */, @@ -3623,6 +3626,7 @@ 84C170ED2CCA362A0098E52F /* ImpactView.swift in Sources */, A9CBE45A248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift in Sources */, A9C62D8A2331703100535612 /* ServicesManager.swift in Sources */, + C11445B22DA98A3400034864 /* CorrectionRangeInformationView.swift in Sources */, 43DBF0531C93EC8200B3C386 /* DeviceDataManager.swift in Sources */, A9347F2F24E7508A00C99C34 /* WatchHistoricalCarbs.swift in Sources */, C10509792D8B636900118A37 /* Environment+TemporaryPresetsManager.swift in Sources */, diff --git a/Loop/Views/Presets/CorrectionRangeInformationView.swift b/Loop/Views/Presets/CorrectionRangeInformationView.swift new file mode 100644 index 0000000000..ea20e70ec0 --- /dev/null +++ b/Loop/Views/Presets/CorrectionRangeInformationView.swift @@ -0,0 +1,86 @@ +// +// InsulinScaleInformationView.swift +// Loop +// +// Created by Pete Schwamb on 2/25/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +import SwiftUI + +struct CorrectionRangeInformationView: View { + @State private var insulinPercentage: Double = 100 + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + // Close button + VStack { + Button("Close") { + dismiss() + } + .font(.title3) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding() + } + .background(Color(.systemBackground)) + + // Header + VStack(alignment: .leading) { + Text("Correction Range") + .font(.largeTitle) + .fontWeight(.bold) + .padding(.vertical) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .background(Color(.systemBackground)) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Description Text + (Text("Correction range is a ") + Text("safety").fontWeight(.semibold) + Text(" setting. Adjusting it can help reduce the risk of low glucose if you expect unusual fluctuations.")) + .padding(.top) + Text("Set the glucose value (or values) you want Tidepool Loop to aim for in adjusting your basal insulin.") + Text("You do not have to set a new correction range for each preset, but before deciding to adjust your correction range, ") + + Text("ask yourself, am I more likely to go high or low during this event?") + .fontWeight(.semibold) + + // Tip section + CorrectionRangeTipSection() + } + .padding() + } + } + } +} + +struct CorrectionRangeTipSection: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "lightbulb.fill") + .foregroundColor(.blue) + + Text("Tip") + .font(.headline) + .foregroundColor(.blue) + } + .padding(.bottom, 4) + + Text("To help avoid lows, set a range higher than your typical correction range.") + } + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + } +} + +struct CorrectionRangeInformationView_Previews: PreviewProvider { + static var previews: some View { + CorrectionRangeInformationView() + } +} diff --git a/Loop/Views/Presets/InsulinScaleInformationView.swift b/Loop/Views/Presets/InsulinScaleInformationView.swift index 7af9286c54..42e9a0da5c 100644 --- a/Loop/Views/Presets/InsulinScaleInformationView.swift +++ b/Loop/Views/Presets/InsulinScaleInformationView.swift @@ -10,11 +10,21 @@ import SwiftUI struct InsulinScaleInformationView: View { - @State private var insulinPercentage: Double = 100 @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 0) { + // Close button + VStack { + Button("Close") { + dismiss() + } + .font(.title3) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding() + } + .background(Color(.systemBackground)) + // Header VStack(alignment: .leading) { Text("Overall Insulin") @@ -58,18 +68,6 @@ struct InsulinScaleInformationView: View { } .padding() } - - // Close button - VStack { - Button("Close") { - dismiss() - } - .font(.body.bold()) - .foregroundColor(.blue) - .frame(maxWidth: .infinity, alignment: .trailing) - .padding() - } - .background(Color(.systemBackground)) } } } diff --git a/Loop/Views/Presets/NewPresetRangeEdit.swift b/Loop/Views/Presets/NewPresetRangeEdit.swift index 7afa1c7ae3..4f0b08f258 100644 --- a/Loop/Views/Presets/NewPresetRangeEdit.swift +++ b/Loop/Views/Presets/NewPresetRangeEdit.swift @@ -73,7 +73,7 @@ struct NewPresetRangeEdit: View { private var actionButtonText: String { if editedRange == nil { - NSLocalizedString("Continue", comment: "Continue button for new preset range edit when range is not edited") + NSLocalizedString("Continue with scheduled range", comment: "Continue button for new preset range edit when range is not edited") } else { NSLocalizedString("Continue with adjusted range", comment: "Continue button for new preset range edit when range edited") } diff --git a/Loop/Views/Presets/PresetRangeEditor.swift b/Loop/Views/Presets/PresetRangeEditor.swift index 57bcb8c506..7f1c07abd3 100644 --- a/Loop/Views/Presets/PresetRangeEditor.swift +++ b/Loop/Views/Presets/PresetRangeEditor.swift @@ -15,6 +15,7 @@ struct PresetRangeEditor: View { @Environment(\.guidanceColors) private var guidanceColors @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @State private var presentInfoView: Bool = false @Binding var range: ClosedRange? var guardrail: Guardrail private var scheduledRange: ClosedRange @@ -61,11 +62,11 @@ struct PresetRangeEditor: View { Text("Correction Range") .foregroundColor(.secondary) .font(.system(size: 14)) - Image(systemName: "info.circle") - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(.accentColor) - .frame(width: UIFontMetrics.default.scaledValue(for: 14), height: UIFontMetrics.default.scaledValue(for: 14)) + Button(action: { + presentInfoView = true; + }) { + Image(systemName: "info.circle") + } } .padding(.top, 10) @@ -143,6 +144,9 @@ struct PresetRangeEditor: View { .padding(.bottom) } .font(.subheadline) + .sheet(isPresented: $presentInfoView) { + CorrectionRangeInformationView() + } } From 2d84db889b06ac32fbda4b26772ce6eec54948f1 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 16 Apr 2025 10:38:29 -0500 Subject: [PATCH 241/421] Fix capitalization (#782) --- Loop/Views/Presets/ReviewNewPresetView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/Presets/ReviewNewPresetView.swift b/Loop/Views/Presets/ReviewNewPresetView.swift index 3c0e582750..a4d161c702 100644 --- a/Loop/Views/Presets/ReviewNewPresetView.swift +++ b/Loop/Views/Presets/ReviewNewPresetView.swift @@ -149,7 +149,7 @@ struct ReviewNewPresetView: View { onComplete(true) } .buttonStyle(ActionButtonStyle(.primary)) - Button("Save for later") { + Button("Save for Later") { onComplete(false) } .buttonStyle(ActionButtonStyle(.secondary)) From 5e855118895e2ed8354df539349c35796ffb8e77 Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Fri, 11 Apr 2025 14:36:30 -0700 Subject: [PATCH 242/421] [QAE-484] Changed ID format and Added Bolus field --- Loop/Views/BolusEntryView.swift | 1 + Loop/Views/ManualEntryDoseView.swift | 1 + Loop/Views/SimpleBolusView.swift | 9 +++++---- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 9cca3cf1b4..48119c0e9d 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -263,6 +263,7 @@ struct BolusEntryView: View { ) bolusUnitsLabel } + .accessibilityIdentifier("textField_Bolus") } } diff --git a/Loop/Views/ManualEntryDoseView.swift b/Loop/Views/ManualEntryDoseView.swift index 7882827a7b..be48d1046a 100644 --- a/Loop/Views/ManualEntryDoseView.swift +++ b/Loop/Views/ManualEntryDoseView.swift @@ -204,6 +204,7 @@ struct ManualEntryDoseView: View { } } .accessibilityElement(children: .combine) + .accessibilityIdentifier("textField_Bolus") } private var bolusUnitsLabel: some View { diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index 0b1f6da3df..12968d2ca4 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -133,7 +133,7 @@ struct SimpleBolusView: View { .padding([.top, .bottom], 5) .fixedSize() .modifier(LabelBackground()) - .accessibilityIdentifier("Carbohydrates Field") + .accessibilityIdentifier("textField_Carbohydrates") } } @@ -155,7 +155,7 @@ struct SimpleBolusView: View { .onAppear { shouldGlucoseEntryBecomeFirstResponder = true } - .accessibilityIdentifier("Current Glucose Field") + .accessibilityIdentifier("textField_CurrentGlucose") glucoseUnitsLabel } @@ -174,7 +174,7 @@ struct SimpleBolusView: View { .font(.title) .foregroundColor(Color(.label)) .padding([.top, .bottom], 4) - .accessibilityIdentifier("Recommended Bolus Amount") + .accessibilityIdentifier("staticText_RecommendedBolus") bolusUnitsLabel } } @@ -219,7 +219,7 @@ struct SimpleBolusView: View { } .fixedSize() .modifier(LabelBackground()) - .accessibilityIdentifier("Bolus Field") + .accessibilityIdentifier("textField_Bolus") } } @@ -280,6 +280,7 @@ struct SimpleBolusView: View { .disabled(viewModel.actionButtonDisabled) .buttonStyle(ActionButtonStyle(.primary)) .padding() + .accessibilityIdentifier("button_bolusAction") } private func alert(for alert: SimpleBolusViewModel.Alert) -> SwiftUI.Alert { From d3ac17787ea6dbd49dbd0298aa757171ac894120 Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Fri, 11 Apr 2025 14:36:30 -0700 Subject: [PATCH 243/421] [QAE-484] Changed ID format and Added Bolus field --- Loop/Views/BolusEntryView.swift | 1 + Loop/Views/ManualEntryDoseView.swift | 1 + Loop/Views/SimpleBolusView.swift | 9 +++++---- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 9cca3cf1b4..48119c0e9d 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -263,6 +263,7 @@ struct BolusEntryView: View { ) bolusUnitsLabel } + .accessibilityIdentifier("textField_Bolus") } } diff --git a/Loop/Views/ManualEntryDoseView.swift b/Loop/Views/ManualEntryDoseView.swift index 7882827a7b..be48d1046a 100644 --- a/Loop/Views/ManualEntryDoseView.swift +++ b/Loop/Views/ManualEntryDoseView.swift @@ -204,6 +204,7 @@ struct ManualEntryDoseView: View { } } .accessibilityElement(children: .combine) + .accessibilityIdentifier("textField_Bolus") } private var bolusUnitsLabel: some View { diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index 0b1f6da3df..12968d2ca4 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -133,7 +133,7 @@ struct SimpleBolusView: View { .padding([.top, .bottom], 5) .fixedSize() .modifier(LabelBackground()) - .accessibilityIdentifier("Carbohydrates Field") + .accessibilityIdentifier("textField_Carbohydrates") } } @@ -155,7 +155,7 @@ struct SimpleBolusView: View { .onAppear { shouldGlucoseEntryBecomeFirstResponder = true } - .accessibilityIdentifier("Current Glucose Field") + .accessibilityIdentifier("textField_CurrentGlucose") glucoseUnitsLabel } @@ -174,7 +174,7 @@ struct SimpleBolusView: View { .font(.title) .foregroundColor(Color(.label)) .padding([.top, .bottom], 4) - .accessibilityIdentifier("Recommended Bolus Amount") + .accessibilityIdentifier("staticText_RecommendedBolus") bolusUnitsLabel } } @@ -219,7 +219,7 @@ struct SimpleBolusView: View { } .fixedSize() .modifier(LabelBackground()) - .accessibilityIdentifier("Bolus Field") + .accessibilityIdentifier("textField_Bolus") } } @@ -280,6 +280,7 @@ struct SimpleBolusView: View { .disabled(viewModel.actionButtonDisabled) .buttonStyle(ActionButtonStyle(.primary)) .padding() + .accessibilityIdentifier("button_bolusAction") } private func alert(for alert: SimpleBolusViewModel.Alert) -> SwiftUI.Alert { From 94b8d3172e106c47be44d0d185041e8a1437f3da Mon Sep 17 00:00:00 2001 From: Petr Zywczok Date: Fri, 18 Apr 2025 11:18:13 +0200 Subject: [PATCH 244/421] [QAE-483] Add test IDs for Carbs & Bolus --- Loop/View Controllers/StatusTableViewController.swift | 3 +++ Loop/Views/BolusEntryView.swift | 2 +- Loop/Views/CarbEntryView.swift | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 5dfc4d5316..4005ea8a51 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1217,8 +1217,10 @@ final class StatusTableViewController: LoopChartsTableViewController { subtitle.append(eventualGlucose) cell.setSubtitleLabel(label: subtitle) + cell.setTitleLabelAccessibilityIdentifier("Glucose") } else { cell.setSubtitleLabel(label: nil) + cell.setTitleLabelAccessibilityIdentifier("Glucose") } cell.doesNavigate = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled case .iob: @@ -1230,6 +1232,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case .cob: if let currentCOB = currentCOBDescription { cell.setSubtitleLabel(label: currentCOB) + cell.setTitleLabelAccessibilityIdentifier("ActiveCarbs_\(currentCOB.string)") } else { cell.setSubtitleLabel(label: nil) } diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 48119c0e9d..d7f9e793b7 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -89,7 +89,7 @@ struct BolusEntryView: View { Section { VStack(spacing: 8) { HStack(spacing: 0) { - activeCarbsLabel + activeCarbsLabel.accessibilityIdentifier("text_ActiveCarbs") Spacer(minLength: 8) activeInsulinLabel } diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index c2ff5580b3..05e62f2aff 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -57,6 +57,7 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { continueButton + .accessibilityIdentifier("button_Continue") } } } From c9eac38dbd6ff35b6ae8c437ac0ab42c31e2d2d2 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 23 Apr 2025 09:38:01 -0700 Subject: [PATCH 245/421] [LOOP-5301] Ignore Keyboard Inset StatusTableView (#785) --- Loop/Views/StatusTableView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 24738812b2..18c530a4a2 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -146,6 +146,7 @@ struct StatusTableView: View { var body: some View { wrappedView + .ignoresSafeArea(.keyboard, edges: .bottom) .onChange(of: viewModel.temporaryPresetsManager.activeOverride) { _, _ in Task { await viewController.reloadData(animated: true) From d6020bc2c21ee6cf7f54148557a95a2b6313f7a5 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 24 Apr 2025 07:47:29 -0500 Subject: [PATCH 246/421] LOOP-5315 Edit Custom Presets (#783) * Duration * Editing custom presets * Edit insulin needs * Delete preset * Delete preset --- Common/Models/LoopSettingsUserInfo.swift | 4 +- Loop.xcodeproj/project.pbxproj | 4 + ...osingDecisionStore+SimulatedCoreData.swift | 2 +- .../SettingsStore+SimulatedCoreData.swift | 2 +- Loop/Managers/SettingsManager.swift | 13 +- Loop/Managers/TemporaryPresetsManager.swift | 6 +- Loop/Models/SelectablePreset.swift | 92 ++++- .../StatusTableViewController.swift | 4 +- .../Presets/Components/DayPickerPopup.swift | 1 + .../Components/InsulinScaleAdjustView.swift | 2 +- .../Presets/Components/PresetDetentView.swift | 4 +- .../Components/RepeatOptionsView.swift | 1 + .../CreatePresetNameAndScheduledEdit.swift | 1 + Loop/Views/Presets/CreatePresetView.swift | 6 +- Loop/Views/Presets/EditPresetView.swift | 381 +++++++++++++----- .../ExistingPresetInsulinNeedsEdit.swift | 87 ++++ Loop/Views/Presets/NewCustomPreset.swift | 55 +-- Loop/Views/Presets/PresetsView.swift | 13 +- .../PresetsAndExerciseContentView.swift | 4 +- LoopCore/LoopSettings.swift | 8 +- .../TemporaryPresetsManagerTests.swift | 6 +- .../ViewModels/BolusEntryViewModelTests.swift | 4 +- .../Controllers/ActionHUDController.swift | 2 +- .../OverrideSelectionController.swift | 2 +- 24 files changed, 516 insertions(+), 188 deletions(-) create mode 100644 Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift diff --git a/Common/Models/LoopSettingsUserInfo.swift b/Common/Models/LoopSettingsUserInfo.swift index bf95b076b4..f788f50993 100644 --- a/Common/Models/LoopSettingsUserInfo.swift +++ b/Common/Models/LoopSettingsUserInfo.swift @@ -23,7 +23,7 @@ struct LoopSettingsUserInfo: Equatable { } return TemporaryScheduleOverride( context: .preMeal, - settings: TemporaryScheduleOverrideSettings(targetRange: preMealTargetRange), + settings: TemporaryPresetSettings(targetRange: preMealTargetRange), startDate: date, duration: .finite(duration), enactTrigger: .local, @@ -59,7 +59,7 @@ struct LoopSettingsUserInfo: Equatable { return TemporaryScheduleOverride( context: .legacyWorkout, - settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), + settings: TemporaryPresetSettings(targetRange: legacyWorkoutTargetRange), startDate: date, duration: duration.isInfinite ? .indefinite : .finite(duration), enactTrigger: .local, diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 0aeae83775..7281e9fe1e 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -431,6 +431,7 @@ C105097B2D8B947B00118A37 /* SelectablePreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105097A2D8B947700118A37 /* SelectablePreset.swift */; }; C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C110888C2A3913C600BA4898 /* BuildDetails.swift */; }; C11445B22DA98A3400034864 /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */; }; + C11445B42DB2EBE400034864 /* ExistingPresetInsulinNeedsEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11445B32DB2EBDC00034864 /* ExistingPresetInsulinNeedsEdit.swift */; }; C11613492983096D00777E7C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C11613472983096D00777E7C /* InfoPlist.strings */; }; C116134C2983096D00777E7C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C116134A2983096D00777E7C /* Localizable.strings */; }; C11B9D5B286778A800500CF8 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C11B9D5A286778A800500CF8 /* SwiftCharts */; }; @@ -1386,6 +1387,7 @@ C1092BFD29F8116700AE3D1C /* apply-info-customizations.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "apply-info-customizations.sh"; sourceTree = ""; }; C110888C2A3913C600BA4898 /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = ""; }; C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; + C11445B32DB2EBDC00034864 /* ExistingPresetInsulinNeedsEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExistingPresetInsulinNeedsEdit.swift; sourceTree = ""; }; C11613482983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; C116134B2983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; C116134D2983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/ckcomplication.strings; sourceTree = ""; }; @@ -2540,6 +2542,7 @@ 84E8BBAF2CC979300078E6CF /* Presets */ = { isa = PBXGroup; children = ( + C11445B32DB2EBDC00034864 /* ExistingPresetInsulinNeedsEdit.swift */, C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */, C105095A2D78D32C00118A37 /* CreatePresetNameAndScheduledEdit.swift */, C1AC03952D6E07D3004D4D2B /* CreatePresetView.swift */, @@ -3719,6 +3722,7 @@ C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */, 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */, + C11445B42DB2EBE400034864 /* ExistingPresetInsulinNeedsEdit.swift in Sources */, E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, C10509612D7B3DF400118A37 /* CardSectionScrollView.swift in Sources */, diff --git a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift index e4a007d4a6..b0e55f7267 100644 --- a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift @@ -64,7 +64,7 @@ fileprivate extension StoredDosingDecision { let reason = "simulatedCoreData" let settings = StoredDosingDecision.Settings(syncIdentifier: UUID(uuidString: "18CF3948-0B3D-4B12-8BFE-14986B0E6784")!) let scheduleOverride = TemporaryScheduleOverride(context: .preMeal, - settings: TemporaryScheduleOverrideSettings(unit: .milligramsPerDeciliter, + settings: TemporaryPresetSettings(unit: .milligramsPerDeciliter, targetRange: DoubleRange(minValue: 80.0, maxValue: 90.0), insulinNeedsScaleFactor: 1.5), diff --git a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift index 06633e1ddd..e3403e8e88 100644 --- a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift +++ b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift @@ -77,7 +77,7 @@ fileprivate extension StoredSettings { start: date.addingTimeInterval(-.minutes(30)), end: date.addingTimeInterval(.minutes(30)))) let preMealOverride = TemporaryScheduleOverride(context: .preMeal, - settings: TemporaryScheduleOverrideSettings(unit: .milligramsPerDeciliter, + settings: TemporaryPresetSettings(unit: .milligramsPerDeciliter, targetRange: DoubleRange(minValue: 80.0, maxValue: 90.0), insulinNeedsScaleFactor: 0.5), startDate: date.addingTimeInterval(-.minutes(30)), diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index 6a76f3078f..273fbb12a7 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -400,11 +400,22 @@ extension SettingsManager { } } - func createPreset(_ preset: TemporaryScheduleOverridePreset) { + func createPreset(_ preset: TemporaryPreset) { mutateLoopSettings { settings in settings.overridePresets.append(preset) } } + + func deletePreset(_ preset: SelectablePreset) { + switch(preset) { + case .preMeal, .legacyWorkout: + break // cannot delete these + case .custom(let preset): + mutateLoopSettings { settings in + settings.overridePresets = settings.overridePresets.filter { $0.id != preset.id } + } + } + } } @MainActor diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index a88d51cf9a..69486f2cdd 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -161,7 +161,7 @@ class TemporaryPresetsManager { case .legacyWorkout: return .legacyWorkout(range: range!, duration: override.duration.presetDurationType) case .custom: - let preset = TemporaryScheduleOverridePreset( + let preset = TemporaryPreset( id: override.syncIdentifier, symbol: "", name: "Single Use Preset", @@ -279,7 +279,7 @@ class TemporaryPresetsManager { } return TemporaryScheduleOverride( context: .preMeal, - settings: TemporaryScheduleOverrideSettings(targetRange: preMealTargetRange), + settings: TemporaryPresetSettings(targetRange: preMealTargetRange), startDate: date, duration: .finite(duration), enactTrigger: .local, @@ -299,7 +299,7 @@ class TemporaryPresetsManager { return TemporaryScheduleOverride( context: .legacyWorkout, - settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), + settings: TemporaryPresetSettings(targetRange: legacyWorkoutTargetRange), startDate: date, duration: duration, enactTrigger: .local, diff --git a/Loop/Models/SelectablePreset.swift b/Loop/Models/SelectablePreset.swift index 1933b3948d..781f54e5b1 100644 --- a/Loop/Models/SelectablePreset.swift +++ b/Loop/Models/SelectablePreset.swift @@ -86,7 +86,7 @@ extension PresetDuration: Hashable { enum SelectablePreset: Hashable, Identifiable { - case custom(TemporaryScheduleOverridePreset) + case custom(TemporaryPreset) case preMeal(range: ClosedRange) case legacyWorkout(range: ClosedRange, duration: PresetDuration) @@ -155,11 +155,61 @@ enum SelectablePreset: Hashable, Identifiable { case .legacyWorkout(let range, _): self = .legacyWorkout(range: range, duration: newValue) case .custom(var preset): - preset.settings = TemporaryScheduleOverrideSettings(targetRange: preset.settings.targetRange, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) + preset.settings = TemporaryPresetSettings(targetRange: preset.settings.targetRange, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) + switch newValue { + case .indefinite: + preset.duration = .indefinite + case .duration(let duration): + preset.duration = .finite(duration) + default: + break + } + self = .custom(preset) + } + } + } + + var scheduleStartDate: Date? { + get { + switch self { + case .custom(let preset): + return preset.scheduleStartDate + case .preMeal, .legacyWorkout: + return nil + } + } + set { + switch self { + case .custom(var preset): + preset.scheduleStartDate = newValue + self = .custom(preset) + default: + break + } + } + } + + var repeatOptions: PresetScheduleRepeatOptions { + get { + switch self { + case .custom(let preset): + return preset.repeatOptions ?? .none + case .preMeal, .legacyWorkout: + return .none + } + } + set { + switch self { + case .custom(var preset): + preset.repeatOptions = newValue + self = .custom(preset) + default: + break } } } + var name: String { get { switch self { @@ -192,7 +242,8 @@ enum SelectablePreset: Hashable, Identifiable { case .legacyWorkout(_, let duration): self = .legacyWorkout(range: newValue!, duration: duration) case .custom(var preset): - preset.settings = TemporaryScheduleOverrideSettings(targetRange: newValue, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) + preset.settings = TemporaryPresetSettings(targetRange: newValue, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) + self = .custom(preset) } } } @@ -205,12 +256,20 @@ enum SelectablePreset: Hashable, Identifiable { } } - var insulinMultiplier: Double? { - guard let insulinSensitivityMultiplier else { - return nil + var insulinNeedsScaleFactor: Double { + get { + if case .custom(let preset) = self { + return 1.0 / (preset.settings.insulinSensitivityMultiplier ?? 1) + } else { + return 1.0 + } + } + set { + if case .custom(var preset) = self { + preset.settings = TemporaryPresetSettings(targetRange: preset.settings.targetRange, insulinNeedsScaleFactor: newValue) + self = .custom(preset) + } } - - return 1.0 / insulinSensitivityMultiplier } var canAdjustSensitivity: Bool { @@ -240,6 +299,23 @@ enum SelectablePreset: Hashable, Identifiable { } } + var allowsScheduling: Bool { + switch self { + case .custom: + return true; + case .preMeal, .legacyWorkout: + return false; + } + } + + var canBeDeleted: Bool { + switch self { + case .custom: + return true; + case .preMeal, .legacyWorkout: + return false; + } + } var isPreMeal: Bool { if case .preMeal = self { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 4005ea8a51..e06922828f 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -2047,7 +2047,7 @@ extension StatusTableViewController: DoseProgressObserver { } extension StatusTableViewController: OverrideSelectionViewControllerDelegate { - func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didUpdatePresets presets: [TemporaryScheduleOverridePreset]) { + func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didUpdatePresets presets: [TemporaryPreset]) { settingsManager.mutateLoopSettings { settings in settings.overridePresets = presets } @@ -2057,7 +2057,7 @@ extension StatusTableViewController: OverrideSelectionViewControllerDelegate { temporaryPresetsManager.scheduleOverride = override } - func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmPreset preset: TemporaryScheduleOverridePreset) { + func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmPreset preset: TemporaryPreset) { let intent = EnableOverridePresetIntent() intent.overrideName = preset.name diff --git a/Loop/Views/Presets/Components/DayPickerPopup.swift b/Loop/Views/Presets/Components/DayPickerPopup.swift index 68475d037c..2953aa39e9 100644 --- a/Loop/Views/Presets/Components/DayPickerPopup.swift +++ b/Loop/Views/Presets/Components/DayPickerPopup.swift @@ -7,6 +7,7 @@ // import SwiftUI +import LoopKit struct DayPickerPopup: View { @Binding var selectedDays: PresetScheduleRepeatOptions diff --git a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift index e70d377c22..8d786f7534 100644 --- a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift +++ b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift @@ -21,7 +21,7 @@ public struct InsulinScaleAdjustView: View { @Binding var insulinMultiplier: Double var insulinPercentage: Double { - get { return (insulinMultiplier * 100).rounded() } + get { return (insulinMultiplier * 20).rounded() * 5 } } public var body: some View { diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index f643467da4..d322cee00a 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -100,7 +100,7 @@ struct PresetDetentView: View { @State var sheetContentHeight: Double = 0 var settingsImpact: TherapySettings.InsulinMultiplierImpact { - settingsManager.therapySettings.impact(for: preset.insulinMultiplier ?? 1.0) + settingsManager.therapySettings.impact(for: preset.insulinNeedsScaleFactor) } var body: some View { @@ -130,7 +130,7 @@ struct PresetDetentView: View { Divider() PresetStatsView( - insulinMultiplier: preset.insulinMultiplier, + insulinMultiplier: preset.insulinNeedsScaleFactor, correctionRange: preset.correctionRange, guardrail: settingsManager.guardrailForPreset(preset), therapySettingsImpactDisplayState: operation == .end ? .show(settingsImpact) : .hide diff --git a/Loop/Views/Presets/Components/RepeatOptionsView.swift b/Loop/Views/Presets/Components/RepeatOptionsView.swift index dce507d3dc..f7e96ea90d 100644 --- a/Loop/Views/Presets/Components/RepeatOptionsView.swift +++ b/Loop/Views/Presets/Components/RepeatOptionsView.swift @@ -6,6 +6,7 @@ // Copyright © 2025 LoopKit Authors. All rights reserved. // import SwiftUI +import LoopKit struct RepeatOptionView: View { let repeatOptions: PresetScheduleRepeatOptions diff --git a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift index b988a93f7e..503e7ab326 100644 --- a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift +++ b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift @@ -7,6 +7,7 @@ // +import LoopKit import LoopKitUI import SwiftUI diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift index 275783156d..02c7c1235b 100644 --- a/Loop/Views/Presets/CreatePresetView.swift +++ b/Loop/Views/Presets/CreatePresetView.swift @@ -150,7 +150,7 @@ struct CreatePresetView: View { var guardrailWarningIfNecessary: some View { Group { if let threshold = exceededThreshold { - WarningView(title: threshold.warningTitle, caption: threshold.warningCaption, severity: threshold.severity) + WarningView(title: threshold.insulinNeedsScaleWarningTitle, caption: threshold.insulinNeedsScaleWarningCaption, severity: threshold.severity) .padding() } } @@ -174,7 +174,7 @@ struct CreatePresetView: View { } extension SafetyClassification.Threshold { - public var warningTitle: Text { + public var insulinNeedsScaleWarningTitle: Text { switch self { case .belowRecommended, .minimum: return Text("Insulin adjustment is below the safety threshold") @@ -183,7 +183,7 @@ extension SafetyClassification.Threshold { } } - public var warningCaption: Text { + public var insulinNeedsScaleWarningCaption: Text { switch self { case .belowRecommended, .minimum: return Text("Using this adjustment may lead to an under delivery of insulin. Monitor your glucose while this preset is in use.") diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 99fde5ac5e..6a1a98e271 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -18,141 +18,331 @@ struct EditPresetView: View { @Environment(\.settingsManager) private var settingsManager @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - @State private var preset: SelectablePreset - + enum Destination { + case editCorrectionRange + case editInsulinNeeds + } + @State private var destination: Destination? = nil + @State private var preset: SelectablePreset private var originalPreset: SelectablePreset private var scheduledRange: ClosedRange private var onSave: (SelectablePreset) throws -> Void + private var onDelete: (SelectablePreset) throws -> Void + + @State private var isDurationPickerExpanded = false + @State private var showingDayPicker: Bool = false + @State private var isConfirmingDelete = false - @State private var showingPicker = false - @State private var navigateToCorrectionRangeEditor = false @FocusState private var isTextFieldFocused: Bool - init(preset: SelectablePreset, scheduledRange: ClosedRange, onSave: @escaping ((SelectablePreset) throws -> Void)) { + init( + preset: SelectablePreset, + scheduledRange: ClosedRange, + onSave: @escaping ((SelectablePreset) throws -> Void), + onDelete: @escaping ((SelectablePreset) throws -> Void) + ) { self.preset = preset self.originalPreset = preset self.scheduledRange = scheduledRange self.onSave = onSave + self.onDelete = onDelete } var sensitivitySection: some View { - CardSection("Temporary Settings Adjustments") { - VStack(alignment: .leading, spacing: 8) { - Text("Overall Insulin") - .font(.system(.title3, weight: .semibold)) - - HStack { - Spacer() - VStack(alignment: .center) { - Text("\(Int((1.0 / (preset.insulinSensitivityMultiplier ?? 1)) * 100))%") - .font(.system(size: 34, weight: .semibold)) - .foregroundColor(.accentColor) - Text("of scheduled") - .foregroundColor(.primary) + Button { + destination = .editInsulinNeeds + } label: { + CardSection("Temporary Settings Adjustments") { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Overall Insulin") + .font(.headline) + if preset.canAdjustSensitivity { + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } + }.padding(.bottom, 10) + + HStack { + Spacer() + VStack(alignment: .center) { + Text("\(Int((1.0 / (preset.insulinSensitivityMultiplier ?? 1)) * 100))%") + .font(.system(size: 34, weight: .bold)) + .foregroundColor(.accentColor) + Text("of scheduled") + .foregroundColor(.primary) + } + Spacer() } - Spacer() - } - if (!preset.canAdjustSensitivity) { - (Text(Image(systemName: "info.circle")) + Text(" Overall insulin cannot be adjusted for this preset")) - .foregroundColor(.secondary) - .font(.footnote) - .italic() - .padding(.top, 4) + if (!preset.canAdjustSensitivity) { + (Text(Image(systemName: "info.circle")) + Text(" Overall insulin cannot be adjusted for this preset")) + .foregroundColor(.secondary) + .font(.footnote) + .italic() + .padding(.top, 4) + } } } + .foregroundColor(.primary) } } var body: some View { - CardSectionScrollView { - presetTitle - - sensitivitySection - - CardSection { - Button { - navigateToCorrectionRangeEditor = true; - } label: { - CorrectionRangePreview( - range: $preset.correctionRange, - guardrail: settingsManager.guardrailForPreset(preset), - scheduledRange: scheduledRange, - allowsScheduledRange: preset.canAdjustSensitivity, - showDisclosure: true - ) + ScrollViewReader { scrollViewProxy in + CardSectionScrollView { + presetTitle + + sensitivitySection + + CardSection { + Button { + destination = .editCorrectionRange + } label: { + CorrectionRangePreview( + range: $preset.correctionRange, + guardrail: settingsManager.guardrailForPreset(preset), + scheduledRange: scheduledRange, + allowsScheduledRange: preset.canAdjustSensitivity, + showDisclosure: true + ) + } } - } - CardSection("Preset Details") { - HStack { - Text("Name") - Spacer() - if preset.canChangeName { - TextField("", text: $preset.name, prompt: Text("Required")) - .multilineTextAlignment(.trailing) - .focused($isTextFieldFocused) - .foregroundColor(.secondary) - } else { - Text(preset.name) - .foregroundColor(.secondary) + CardSection("Preset Details") { + HStack { + Text("Name") + Spacer() + if preset.canChangeName { + TextField("", text: $preset.name, prompt: Text("Required")) + .multilineTextAlignment(.trailing) + .focused($isTextFieldFocused) + .foregroundColor(.secondary) + } else { + Text(preset.name) + .foregroundColor(.secondary) + } } } - } - CardSection( - content: { - Button(action: { - showingPicker = true - }) { + // Duration Section + if preset.canAdjustDuration { + CardSection { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Duration") + .foregroundColor(.primary) + Spacer() + Group { + Text(preset.duration.localizedTitle) + Image(systemName: "chevron.right") + } + .foregroundColor(.secondary) + } + .contentShape(Rectangle()) + .onTapGesture { + isTextFieldFocused = false + withAnimation { + isDurationPickerExpanded.toggle() + Task { + if isDurationPickerExpanded { + try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay + withAnimation { + scrollViewProxy.scrollTo("durationPicker", anchor: .bottom) + } + } + } + } + } + + if isDurationPickerExpanded { + DurationPickerView( + durationType: $preset.duration + ) + .id("durationPicker") // Assign an ID for scrolling + } + } + } + .id("durationSection") // Optional: ID for the entire duration section + } + + // Schedule Toggle + if preset.allowsScheduling { + CardSection { HStack { - Text("Duration") - .foregroundColor(.primary) + Text("Schedule") + .font(.body) + Spacer() - Text(preset.duration.localizedTitle) - .foregroundColor(.secondary) - if preset.canAdjustDuration { - Image(systemName: "chevron.right") - .foregroundColor(.gray) + + Toggle("", isOn: Binding(get: { + return preset.scheduleStartDate != nil + }, set: { newValue in + withAnimation { + if newValue { + preset.scheduleStartDate = Date().addingTimeInterval(.hours(1)) + Task { + try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay + withAnimation { + scrollViewProxy.scrollTo("repeatOption", anchor: .bottom) + } + } + } else { + preset.scheduleStartDate = nil + preset.repeatOptions = .none + } + } + })) + .toggleStyle(SwitchToggleStyle(tint: .green)) + .labelsHidden() + .padding(.vertical, -4) + } + + if preset.scheduleStartDate != nil { + Divider() + HStack { + if preset.repeatOptions != .none { + Text("Date") + } else { + Text("Start Date") + } + Spacer() + DatePicker( + "", + selection: Binding(get: { + preset.scheduleStartDate ?? Date() + }, set: { newValue in + preset.scheduleStartDate = newValue + }), + in: Date()..., + displayedComponents: [.date, .hourAndMinute] + ) + } + Divider() + .padding(.top, -4) + HStack { + Text("Repeat") + Spacer() + Picker("Repeat", selection: Binding( + get: { preset.repeatOptions == .none ? .never : .weekly }, + set: { newValue in + if newValue == .never { + preset.repeatOptions = .none + } else { + Task { + if let requiredRepeatOption { + preset.repeatOptions = requiredRepeatOption + } + try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay + withAnimation { + scrollViewProxy.scrollTo("selectedDays", anchor: .bottom) + } + } + } + } + ).animation()) { + ForEach(RepeatOption.allCases, id: \.self) { option in + Text(String(describing: option)) + } + } + .tint(.secondary) + .pickerStyle(MenuPickerStyle()) + .padding(.trailing, -8) + } + .id("repeatOption") // Assign an ID for scrolling + + + if preset.repeatOptions != .none { + Divider() + .padding(.top, -4) + HStack { + Text("Selected days") + .foregroundColor(.primary) + HStack { + Spacer() + RepeatOptionView(repeatOptions: preset.repeatOptions) + .padding(.vertical, 6) + .onTapGesture { + withAnimation { + showingDayPicker = true + } + } + } + .popover(isPresented: $showingDayPicker, arrowEdge: .bottom) { + DayPickerPopup(selectedDays: Binding( + get: { + preset.repeatOptions + }, set: { newValue in + preset.repeatOptions = newValue.union(requiredRepeatOption ?? .none) + })) + .cornerRadius(12) + .presentationCompactAdaptation(.popover) + } + } + .id("selectedDays") // Assign an ID for scrolling } } - }.disabled(!preset.canAdjustDuration) - }, - footerText: preset.canAdjustDuration ? nil : "Duration and Name not configurable for this preset." - ) - } - .sheet(isPresented: $showingPicker) { - VStack(alignment: .center, spacing: 24) { - HStack { - Text("Duration") - Spacer() - Text("Required") - .foregroundColor(.gray) + } + } + + if preset.canBeDeleted { + Button("Delete") { + isConfirmingDelete = true + } + .buttonStyle(ActionButtonStyle(.destructive)) + .padding(.top) } - DurationPickerView(durationType: $preset.duration) - .presentationDetents([.height(300)]) } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(10) } - .navigationDestination(isPresented: $navigateToCorrectionRangeEditor) { - ExistingPresetRangeEdit( - range: $preset.correctionRange, - guardrail: settingsManager.guardrailForPreset(preset), - scheduledRange: scheduledRange, - allowsScheduledRange: preset.canAdjustSensitivity, - isPreMeal: preset.isPreMeal - ) + + .navigationDestination(isPresented: Binding( + get: { destination != nil }, + set: { if !$0 { destination = nil } } + )) { + switch destination { + case .editInsulinNeeds: + ExistingPresetInsulinNeedsEdit(insulinScaleFactor: $preset.insulinNeedsScaleFactor) + case .editCorrectionRange: + ExistingPresetRangeEdit( + range: $preset.correctionRange, + guardrail: settingsManager.guardrailForPreset(preset), + scheduledRange: scheduledRange, + allowsScheduledRange: preset.canAdjustSensitivity, + isPreMeal: preset.isPreMeal + ) + case .none: + EmptyView() + } } - .onChange(of: preset, { + .onChange(of: preset) { do { try onSave(preset) } catch { print(error) } - }) + } + .alert(isPresented: $isConfirmingDelete) { + Alert( + title: Text("Delete “\(preset.name)”?"), + message: Text("Are you sure you want to delete this preset?"), + primaryButton: .default(Text("Go Back")), + secondaryButton: .destructive(Text("Yes, Delete").bold(), action: { + do { + try onDelete(preset) + dismiss() + } catch { + print(error) + } + }) + ) + } + } + + private var requiredRepeatOption: PresetScheduleRepeatOptions? { + guard let startDate = preset.scheduleStartDate else { return nil } + return .allCases[Calendar.current.component(.weekday, from: startDate) - 1] } var presetTitle: some View { @@ -175,5 +365,4 @@ struct EditPresetView: View { .foregroundColor(.primary) } } - } diff --git a/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift b/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift new file mode 100644 index 0000000000..c5bc5254aa --- /dev/null +++ b/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift @@ -0,0 +1,87 @@ +// +// ExistingPresetInsulinNeedsEdit.swift +// Loop +// +// Created by Pete Schwamb on 4/18/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopAlgorithm +import LoopKit +import LoopKitUI + +struct ExistingPresetInsulinNeedsEdit: View { + @Environment(\.dismiss) private var dismiss + + var guardrail: Guardrail + @Binding var scaleFactor: Double + @State var editedScale: Double + + init(insulinScaleFactor: Binding) { + + _scaleFactor = insulinScaleFactor + editedScale = insulinScaleFactor.wrappedValue + guardrail = Guardrail.presetInsulinNeeds + } + + var body: some View { + CardSectionScrollView { + CardSection { + InsulinScaleAdjustView(insulinMultiplier: $editedScale) + } + } actionArea: { + guardrailWarningIfNecessary + actionButton + } + .navigationBarBackButtonHidden(editedScale != scaleFactor) + .navigationBarItems( + trailing: cancelButton + ) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Edit Preset") + } + + private var cancelButton: some View { + Group { + if editedScale != scaleFactor { + Button("Cancel") { + dismiss() + } + .foregroundColor(.blue) + } + } + } + + + private var actionButton: some View { + Button("Save") { + scaleFactor = editedScale + dismiss() + } + .disabled(editedScale == scaleFactor) + .buttonStyle(ActionButtonStyle(.primary)) + .padding() + } + + var crossedThreshold: SafetyClassification.Threshold? { + switch guardrail.classification(for: LoopQuantity(unit: .percent, doubleValue: editedScale * 100)) { + case .withinRecommendedRange: + return nil + case .outsideRecommendedRange(let threshold): + return threshold + } + } + + var guardrailWarningIfNecessary: some View { + return Group { + if let crossedThreshold { + WarningView( + title: crossedThreshold.insulinNeedsScaleWarningTitle, + caption: crossedThreshold.insulinNeedsScaleWarningCaption, + severity: crossedThreshold.severity + ) + } + }.padding() + } +} diff --git a/Loop/Views/Presets/NewCustomPreset.swift b/Loop/Views/Presets/NewCustomPreset.swift index e0d07901ca..f9073b400c 100644 --- a/Loop/Views/Presets/NewCustomPreset.swift +++ b/Loop/Views/Presets/NewCustomPreset.swift @@ -10,52 +10,11 @@ import LoopAlgorithm import UIKit import LoopKit -struct PresetScheduleRepeatOptions: OptionSet { - let rawValue: UInt8 - - static let none = PresetScheduleRepeatOptions([]) - static let sunday = PresetScheduleRepeatOptions(rawValue: 1 << 0) - static let monday = PresetScheduleRepeatOptions(rawValue: 1 << 1) - static let tuesday = PresetScheduleRepeatOptions(rawValue: 1 << 2) - static let wednesday = PresetScheduleRepeatOptions(rawValue: 1 << 3) - static let thursday = PresetScheduleRepeatOptions(rawValue: 1 << 4) - static let friday = PresetScheduleRepeatOptions(rawValue: 1 << 5) - static let saturday = PresetScheduleRepeatOptions(rawValue: 1 << 6) - - static let allCases: [PresetScheduleRepeatOptions] = [ - .sunday, - .monday, - .tuesday, - .wednesday, - .thursday, - .friday, - .saturday, - ] - - // Helper to map OptionSet to calendar weekday index (Sunday = 1 in Calendar) - private var calendarWeekdayIndex: Int? { - switch self { - case .sunday: return 1 - case .monday: return 2 - case .tuesday: return 3 - case .wednesday: return 4 - case .thursday: return 5 - case .friday: return 6 - case .saturday: return 7 - default: return nil - } - } -} - -extension PresetScheduleRepeatOptions: CustomStringConvertible { - var description: String { +extension PresetScheduleRepeatOptions: @retroactive CustomStringConvertible { + public var description: String { let calendar = Calendar.current let weekdaySymbols = calendar.weekdaySymbols - if self == .none { - return NSLocalizedString("None", comment: "Preset schedule repeat option none") - } - // Handle single day case if let weekdayIndex = calendarWeekdayIndex { return weekdaySymbols[weekdayIndex - 1] // -1 because array is 0-based @@ -69,10 +28,6 @@ extension PresetScheduleRepeatOptions: CustomStringConvertible { let calendar = Calendar.current let weekdaySymbols = calendar.veryShortWeekdaySymbols - if self == .none { - return NSLocalizedString("None", comment: "Preset schedule repeat option none") - } - // Handle single day case if let weekdayIndex = calendarWeekdayIndex { return weekdaySymbols[weekdayIndex - 1] // -1 because array is 0-based @@ -100,7 +55,7 @@ extension NewCustomPreset { } // Handle case where no days are selected - if repeatOptions.isEmpty || repeatOptions == .none { + if repeatOptions.isEmpty { return "" } @@ -151,7 +106,7 @@ extension NewCustomPreset { } let overrideDuration = duration.presetDuration - let settings = TemporaryScheduleOverrideSettings( + let settings = TemporaryPresetSettings( targetRange: correctionRange, insulinNeedsScaleFactor: insulinMultiplier ) @@ -159,7 +114,7 @@ extension NewCustomPreset { let context: TemporaryScheduleOverride.Context if savePreset { - let preset = TemporaryScheduleOverridePreset( + let preset = TemporaryPreset( symbol: "", name: name, settings: settings, diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 20143952ba..4a2999b5cb 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -178,10 +178,13 @@ struct PresetsView: View { .navigationTitle(Text("Presets", comment: "Presets screen title")) .navigationBarItems(trailing: dismissButton) .navigationDestination(for: String.self) { presetId in - if let scheduledRange { - EditPresetView(preset: temporaryPresetsManager.selectablePresets.first { $0.id == presetId }!, scheduledRange: scheduledRange) { preset in - settingsManager.savePreset(preset) - } + if let scheduledRange, let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == presetId}) { + EditPresetView( + preset: preset, + scheduledRange: scheduledRange, + onSave: { preset in settingsManager.savePreset(preset) }, + onDelete: { preset in settingsManager.deletePreset(preset) }, + ) } } } @@ -270,7 +273,7 @@ extension PresetCard { icon: preset.icon, presetName: preset.name, duration: preset.duration, - insulinMultiplier: preset.insulinMultiplier, + insulinMultiplier: preset.insulinNeedsScaleFactor, correctionRange: preset.correctionRange, guardrail: guardrail, expectedEndTime: expectedEndTime diff --git a/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift b/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift index aa538079d6..58a014ce5b 100644 --- a/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift +++ b/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift @@ -140,10 +140,10 @@ struct PresetsAndExerciseContentView: View { PresetCard( SelectablePreset.custom( - TemporaryScheduleOverridePreset( + TemporaryPreset( symbol: "🚶", name: NSLocalizedString("Walk to Work", comment: "Presets and exercise training content, scheduling preset, preset example, title"), - settings: TemporaryScheduleOverrideSettings( + settings: TemporaryPresetSettings( targetRange: ClosedRange( uncheckedBounds: ( LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140), diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index 5723859809..acd9f23e8a 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -36,7 +36,7 @@ public struct LoopSettings: Equatable { public var legacyWorkoutDuration: TemporaryScheduleOverride.Duration? - public var overridePresets: [TemporaryScheduleOverridePreset] = [] + public var overridePresets: [TemporaryPreset] = [] public var maximumBasalRatePerHour: Double? @@ -61,7 +61,7 @@ public struct LoopSettings: Equatable { preMealTargetRange: ClosedRange? = nil, legacyWorkoutTargetRange: ClosedRange? = nil, legacyWorkoutDuration: TemporaryScheduleOverride.Duration = .indefinite, - overridePresets: [TemporaryScheduleOverridePreset]? = nil, + overridePresets: [TemporaryPreset]? = nil, maximumBasalRatePerHour: Double? = nil, maximumBolus: Double? = nil, suspendThreshold: GlucoseThreshold? = nil, @@ -128,8 +128,8 @@ extension LoopSettings: RawRepresentable { self.legacyWorkoutDuration = .finite(rawLegacyWorkoutDuration) } - if let rawPresets = rawValue["overridePresets"] as? [TemporaryScheduleOverridePreset.RawValue] { - self.overridePresets = rawPresets.compactMap(TemporaryScheduleOverridePreset.init(rawValue:)) + if let rawPresets = rawValue["overridePresets"] as? [TemporaryPreset.RawValue] { + self.overridePresets = rawPresets.compactMap(TemporaryPreset.init(rawValue:)) } self.maximumBasalRatePerHour = rawValue["maximumBasalRatePerHour"] as? Double diff --git a/LoopTests/Managers/TemporaryPresetsManagerTests.swift b/LoopTests/Managers/TemporaryPresetsManagerTests.swift index be831ce387..57a97be7a5 100644 --- a/LoopTests/Managers/TemporaryPresetsManagerTests.swift +++ b/LoopTests/Managers/TemporaryPresetsManagerTests.swift @@ -52,7 +52,7 @@ class TemporaryPresetsManagerTests: XCTestCase { let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) let override = TemporaryScheduleOverride( context: .custom, - settings: TemporaryScheduleOverrideSettings( + settings: TemporaryPresetSettings( unit: .milligramsPerDeciliter, targetRange: overrideTargetRange ), @@ -69,7 +69,7 @@ class TemporaryPresetsManagerTests: XCTestCase { func testScheduleOverrideWithExpiredPreMealOverride() { manager.preMealOverride = TemporaryScheduleOverride( context: .preMeal, - settings: TemporaryScheduleOverrideSettings(targetRange: preMealRange), + settings: TemporaryPresetSettings(targetRange: preMealRange), startDate: Date(timeIntervalSinceNow: -2 /* hours */ * 60 * 60), duration: .finite(1 /* hours */ * 60 * 60), enactTrigger: .local, @@ -80,7 +80,7 @@ class TemporaryPresetsManagerTests: XCTestCase { let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) let override = TemporaryScheduleOverride( context: .custom, - settings: TemporaryScheduleOverrideSettings( + settings: TemporaryPresetSettings( unit: .milligramsPerDeciliter, targetRange: overrideTargetRange ), diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index d3a0dbb7d0..742bade397 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -52,7 +52,7 @@ class BolusEntryViewModelTests: XCTestCase { static let mockUUID = UUID() - static let exampleScheduleOverrideSettings = TemporaryScheduleOverrideSettings(unit: .millimolesPerLiter, targetRange: nil, insulinNeedsScaleFactor: nil) + static let exampleScheduleOverrideSettings = TemporaryPresetSettings(unit: .millimolesPerLiter, targetRange: nil, insulinNeedsScaleFactor: nil) static let examplePreMealOverride = TemporaryScheduleOverride(context: .preMeal, settings: exampleScheduleOverrideSettings, startDate: exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: mockUUID) static let exampleCustomScheduleOverride = TemporaryScheduleOverride(context: .custom, settings: exampleScheduleOverrideSettings, startDate: exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: mockUUID) @@ -224,7 +224,7 @@ class BolusEntryViewModelTests: XCTestCase { maximumBasalRatePerHour: 1.0, maximumBolus: 10.0, suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 100.0)) - let settings = TemporaryScheduleOverrideSettings(unit: .millimolesPerLiter, targetRange: nil, insulinNeedsScaleFactor: nil) + let settings = TemporaryPresetSettings(unit: .millimolesPerLiter, targetRange: nil, insulinNeedsScaleFactor: nil) delegate.preMealOverride = TemporaryScheduleOverride(context: .preMeal, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) delegate.scheduleOverride = TemporaryScheduleOverride(context: .custom, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) delegate.settings = newSettings diff --git a/WatchApp Extension/Controllers/ActionHUDController.swift b/WatchApp Extension/Controllers/ActionHUDController.swift index 8719415d27..cfdc981633 100644 --- a/WatchApp Extension/Controllers/ActionHUDController.swift +++ b/WatchApp Extension/Controllers/ActionHUDController.swift @@ -289,7 +289,7 @@ final class ActionHUDController: HUDInterfaceController { } extension ActionHUDController: OverrideSelectionControllerDelegate { - func overrideSelectionController(_ controller: OverrideSelectionController, didSelectPreset preset: TemporaryScheduleOverridePreset) { + func overrideSelectionController(_ controller: OverrideSelectionController, didSelectPreset preset: TemporaryPreset) { let override = preset.createOverride(enactTrigger: .local) sendOverride(override) } diff --git a/WatchApp Extension/Controllers/OverrideSelectionController.swift b/WatchApp Extension/Controllers/OverrideSelectionController.swift index 93537cd987..a34847df9a 100644 --- a/WatchApp Extension/Controllers/OverrideSelectionController.swift +++ b/WatchApp Extension/Controllers/OverrideSelectionController.swift @@ -14,7 +14,7 @@ import WatchConnectivity protocol OverrideSelectionControllerDelegate: AnyObject { - func overrideSelectionController(_ controller: OverrideSelectionController, didSelectPreset preset: TemporaryScheduleOverridePreset) + func overrideSelectionController(_ controller: OverrideSelectionController, didSelectPreset preset: TemporaryPreset) } From e2f87775cf7ab1add58cc98d3c2ea41570f2c608 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 24 Apr 2025 11:54:32 -0500 Subject: [PATCH 247/421] Fix trailing comma (#789) --- Loop/Views/Presets/PresetsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 4a2999b5cb..cfdc97c7cc 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -183,7 +183,7 @@ struct PresetsView: View { preset: preset, scheduledRange: scheduledRange, onSave: { preset in settingsManager.savePreset(preset) }, - onDelete: { preset in settingsManager.deletePreset(preset) }, + onDelete: { preset in settingsManager.deletePreset(preset) } ) } } From 7c4dc07d0ce31717a3da8161ce97eb90b6fb0cc8 Mon Sep 17 00:00:00 2001 From: pragnasadhu <100800039+pragnasadhu@users.noreply.github.com> Date: Thu, 24 Apr 2025 09:53:34 -0400 Subject: [PATCH 248/421] [QAE-452] Add accessibility identifiers --- Loop/View Controllers/StatusTableViewController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index e06922828f..a0d1ca6177 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1127,13 +1127,15 @@ final class StatusTableViewController: LoopChartsTableViewController { case .pumpSuspended(let resuming): let cell = getTitleSubtitleCell() cell.titleLabel.text = NSLocalizedString("Insulin Suspended", comment: "The title of the cell indicating the pump is suspended") - + cell.titleLabel.accessibilityIdentifier = "text_InsulinSuspended" + if resuming { let indicatorView = UIActivityIndicatorView(style: .default) indicatorView.startAnimating() cell.accessoryView = indicatorView } else { cell.subtitleLabel.text = NSLocalizedString("Tap to Resume", comment: "The subtitle of the cell displaying an action to resume insulin delivery") + cell.subtitleLabel.accessibilityIdentifier = "text_InsulinTapToResume" } cell.selectionStyle = .default return cell From b66912eab256e8dd83c1a7fd366566af556c0eec Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 7 May 2025 15:38:10 -0500 Subject: [PATCH 249/421] Add guardrails for custom preset target ranges (#792) --- Loop/Managers/SettingsManager.swift | 6 +++++- Loop/Views/Presets/CreatePresetView.swift | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index 273fbb12a7..9db2cce112 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -345,7 +345,7 @@ extension SettingsManager { case .legacyWorkout: return legacyWorkoutPresetGuardrail default: - return .correctionRange + return customPresetGuardRail } } @@ -373,6 +373,10 @@ extension SettingsManager { } } + public var customPresetGuardRail: Guardrail { + return Guardrail.temporaryPresetCorrectionRange(suspendThreshold: settings.suspendThreshold) + } + func savePreset(_ preset: SelectablePreset) { switch(preset) { case .preMeal(let range): diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift index 02c7c1235b..9fe10ced79 100644 --- a/Loop/Views/Presets/CreatePresetView.swift +++ b/Loop/Views/Presets/CreatePresetView.swift @@ -76,6 +76,11 @@ struct CreatePresetView: View { settingsManager.settings.glucoseTargetRangeSchedule?.quantityRange(at: Date()) } + var suspendThreshold: GlucoseThreshold? { + settingsManager.settings.suspendThreshold + } + + var body: some View { NavigationStack(path: $path) { VStack(spacing: 0) { @@ -96,7 +101,7 @@ struct CreatePresetView: View { NewPresetRangeEdit( preset: $preset, path: $path, - guardrail: Guardrail.correctionRange, + guardrail: Guardrail.temporaryPresetCorrectionRange(suspendThreshold: suspendThreshold), scheduledRange: scheduledRange, onCancel: { dismiss() } ) From 7e4093d5bdbe4859e6925f38f2542c91a6229a3c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 7 May 2025 15:47:09 -0500 Subject: [PATCH 250/421] Use NavigationPath to workaround iOS 17 bug (#791) --- Loop/Views/Presets/PresetsView.swift | 39 ++++++++++++++++++---------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index cfdc97c7cc..c4676bea07 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -29,17 +29,17 @@ enum PresetSortOption: Int, CaseIterable { } struct PresetsView: View { - + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.settingsManager) private var settingsManager @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @Environment(\.dismiss) private var dismiss - + @State private var editMode: EditMode = .inactive @State private var showingMenu: Bool = false @State private var showTraining: Bool = false @State private var presentCreateView: Bool = false - @State private var editPresetPath: [String] = [] + @State private var navigationPath = NavigationPath() @State private var pendingPreset: SelectablePreset? @AppStorage("presetsSortAscending") private var presetsSortAscending: Bool = true @@ -68,7 +68,7 @@ struct PresetsView: View { } var body: some View { - NavigationStack(path: $editPresetPath) { + NavigationStack(path: $navigationPath) { ScrollView { VStack(spacing: 20) { if !hasCompletedTraining { @@ -128,7 +128,7 @@ struct PresetsView: View { Text("Support") .font(.title2.bold()) - NavigationLink(destination: PresetsHistoryView()) { + NavigationLink(value: Route.presetsHistory) { HStack { Image(systemName: "list.bullet") .foregroundColor(.white) @@ -177,20 +177,25 @@ struct PresetsView: View { .background(Color(UIColor.secondarySystemBackground)) .navigationTitle(Text("Presets", comment: "Presets screen title")) .navigationBarItems(trailing: dismissButton) - .navigationDestination(for: String.self) { presetId in - if let scheduledRange, let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == presetId}) { - EditPresetView( - preset: preset, - scheduledRange: scheduledRange, - onSave: { preset in settingsManager.savePreset(preset) }, - onDelete: { preset in settingsManager.deletePreset(preset) } - ) + .navigationDestination(for: Route.self) { route in + switch route { + case .presetsHistory: + PresetsHistoryView() + case .editPreset(let presetId): + if let scheduledRange, let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == presetId}) { + EditPresetView( + preset: preset, + scheduledRange: scheduledRange, + onSave: { preset in settingsManager.savePreset(preset) }, + onDelete: { preset in settingsManager.deletePreset(preset) } + ) + } } } } .sheet(item: $pendingPreset) { preset in PresetDetentView(preset: preset, didTapEdit: { - editPresetPath.append(preset.id) + navigationPath.append(Route.editPreset(preset.id)) }) } .sheet(isPresented: $showTraining) { @@ -267,6 +272,12 @@ struct PresetsView: View { } } +// Define navigation routes +enum Route: Hashable { + case presetsHistory + case editPreset(String) +} + extension PresetCard { init (_ preset: SelectablePreset, guardrail: Guardrail, expectedEndTime: PresetExpectedEndTime? = nil) { self.init( From 95cab74ec8f2a998e7e7ed3990dd358ad3aa6d80 Mon Sep 17 00:00:00 2001 From: Foscottl-TP Date: Wed, 7 May 2025 10:45:54 -0700 Subject: [PATCH 251/421] [QAE-486] IDs for Fingerstick Glucose field and button --- Loop/View Controllers/StatusTableViewController.swift | 1 + Loop/Views/BolusEntryView.swift | 1 + Loop/Views/ManualGlucoseEntryRow.swift | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index a0d1ca6177..6a8d681808 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1163,6 +1163,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let imageView = UIImageView(image: UIImage(named: "drop.circle")) imageView.tintColor = .glucoseTintColor cell.accessoryView = imageView + cell.titleLabel.accessibilityIdentifier = "text_NoRecentGlucose" return cell } } diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index d7f9e793b7..4a23be8ff2 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -344,6 +344,7 @@ struct BolusEntryView: View { ) .buttonStyle(ActionButtonStyle(viewModel.primaryButton == .manualGlucoseEntry ? .primary : .secondary)) .padding([.top, .horizontal]) + .accessibilityIdentifier("button_EnterFingerstickGlucose") } private var actionButton: some View { diff --git a/Loop/Views/ManualGlucoseEntryRow.swift b/Loop/Views/ManualGlucoseEntryRow.swift index f719bd5c1a..8c5a4481fb 100644 --- a/Loop/Views/ManualGlucoseEntryRow.swift +++ b/Loop/Views/ManualGlucoseEntryRow.swift @@ -50,7 +50,7 @@ struct ManualGlucoseEntryRow: View { .onChange(of: displayGlucosePreference.unit, perform: { value in unitsChanged() }) - + .accessibilityIdentifier("textField_FingerstickGlucose") Text(displayGlucosePreference.formatter.localizedUnitStringWithPlurality()) .foregroundColor(Color(.secondaryLabel)) } From fd789a8b04062e6a9e39b6d612b9ddb18d792a4c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 15 May 2025 13:21:22 -0500 Subject: [PATCH 252/421] LOOP-5218 Treatment arrow (#794) * AutomatedTreatmentState * Updates from discussion with design --- Loop.xcodeproj/project.pbxproj | 8 +-- Loop/Managers/DeviceDataManager.swift | 9 ++- Loop/Managers/LoopDataManager.swift | 45 ++++++++++++- Loop/Models/SimpleInsulinDose.swift | 3 + .../StatusTableViewController.swift | 4 +- Loop/View Models/BolusEntryViewModel.swift | 2 + Loop/View Models/CarbEntryViewModel.swift | 2 + .../ManualEntryDoseViewModel.swift | 1 + Loop/View Models/SimpleBolusViewModel.swift | 1 + LoopUI/HUDView.xib | 13 ++-- LoopUI/StatusBarHUDView.xib | 12 ++-- LoopUI/Views/BasalRateHUDView.swift | 30 ++------- ...ew.swift => TreatmentArrowStateView.swift} | 67 ++++++------------- 13 files changed, 104 insertions(+), 93 deletions(-) rename LoopUI/Views/{BasalStateView.swift => TreatmentArrowStateView.swift} (55%) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 7281e9fe1e..23b8092aae 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -222,7 +222,7 @@ 4F75289C1DFE1F6000C322D6 /* GlucoseHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4337615E1D52F487004A3647 /* GlucoseHUDView.swift */; }; 4F75289E1DFE1F6000C322D6 /* LoopCompletionHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEBD1CD6E0CB003C8C80 /* LoopCompletionHUDView.swift */; }; 4F7528A01DFE1F9D00C322D6 /* LoopStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438DADC71CDE8F8B007697A5 /* LoopStateView.swift */; }; - 4F7528A11DFE200B00C322D6 /* BasalStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B371851CE583890013C5A6 /* BasalStateView.swift */; }; + 4F7528A11DFE200B00C322D6 /* TreatmentArrowStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B371851CE583890013C5A6 /* TreatmentArrowStateView.swift */; }; 4F7528A51DFE208C00C322D6 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 4F7528AA1DFE215100C322D6 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 4F75F00220FCFE8C00B5570E /* GlucoseChartScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F75F00120FCFE8C00B5570E /* GlucoseChartScene.swift */; }; @@ -889,7 +889,7 @@ 43A9438F1B926B7B0051FA24 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 43A943911B926B7B0051FA24 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43B260481ED248FB008CAA77 /* CarbEntryTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEntryTableViewCell.swift; sourceTree = ""; }; - 43B371851CE583890013C5A6 /* BasalStateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasalStateView.swift; sourceTree = ""; }; + 43B371851CE583890013C5A6 /* TreatmentArrowStateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TreatmentArrowStateView.swift; sourceTree = ""; }; 43B371871CE597D10013C5A6 /* ShareClient.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = ShareClient.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 43BFF0B11E45C18400FF19A9 /* UIColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NumberFormatter.swift; sourceTree = ""; }; @@ -2361,7 +2361,7 @@ isa = PBXGroup; children = ( 437CEEBF1CD6FCD8003C8C80 /* BasalRateHUDView.swift */, - 43B371851CE583890013C5A6 /* BasalStateView.swift */, + 43B371851CE583890013C5A6 /* TreatmentArrowStateView.swift */, B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */, B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */, B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */, @@ -4012,7 +4012,7 @@ B490A04324D055D900F509FA /* DeviceStatusHighlight.swift in Sources */, B4AC0D3F24B9005300CDB0A1 /* UIImage.swift in Sources */, B4E96D5B248A8229002DABAD /* StatusBarHUDView.swift in Sources */, - 4F7528A11DFE200B00C322D6 /* BasalStateView.swift in Sources */, + 4F7528A11DFE200B00C322D6 /* TreatmentArrowStateView.swift in Sources */, 43BFF0C61E465A4400FF19A9 /* UIColor+HIG.swift in Sources */, 4F7528A01DFE1F9D00C322D6 /* LoopStateView.swift in Sources */, B491B0A324D0B66D004CBE8F /* Color.swift in Sources */, diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index a8f2d6da58..c68e551883 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -7,7 +7,7 @@ // import HealthKit -import LoopKit +@preconcurrency import LoopKit import LoopKitUI import LoopCore import LoopTestingKit @@ -15,8 +15,10 @@ import UserNotifications import Combine import LoopAlgorithm +@MainActor protocol LoopControl { var lastLoopCompleted: Date? { get } + var automatedTreatmentState: AutomatedTreatmentState? { get } func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async throws func loop() async } @@ -942,7 +944,10 @@ extension DeviceDataManager: CGMManagerOnboardingDelegate { // MARK: - PumpManagerDelegate extension DeviceDataManager: PumpManagerDelegate { - + var automatedTreatmentState: LoopKit.AutomatedTreatmentState? { + return loopControl.automatedTreatmentState + } + var detectedSystemTimeOffset: TimeInterval { UserDefaults.standard.detectedSystemTimeOffset ?? 0 } func pumpManager(_ pumpManager: PumpManager, didAdjustPumpClockBy adjustment: TimeInterval) { diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index fd475cb0d9..a8fd310d69 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1486,7 +1486,50 @@ extension LoopDataManager: DiagnosticReportGenerator { } } -extension LoopDataManager: LoopControl {} +extension LoopDataManager: LoopControl { + var automatedTreatmentState: LoopKit.AutomatedTreatmentState? { + guard let input = displayState.input else { + return nil + } + + let now = Date() + + let neutralBasal = input.basal.closestPrior(to: now)!.value + var scheduledBasalRate: Double + if let activeOverride = temporaryPresetsManager.presetHistory.activeOverride(at: now) { + scheduledBasalRate = neutralBasal / activeOverride.settings.effectiveInsulinNeedsScaleFactor + } else { + scheduledBasalRate = neutralBasal + } + + var currentBasalRate: Double + if let currentTempBasal = deliveryDelegate?.basalDeliveryState?.currentTempBasal { + currentBasalRate = currentTempBasal.unitsPerHour + } else { + currentBasalRate = scheduledBasalRate + } + + if currentBasalRate > neutralBasal { + return .increasedInsulin + } else if currentBasalRate < neutralBasal { + if currentBasalRate == 0 { + return .minimumDelivery + } else { + return .decreasedInsulin + } + } else { + let recentAutomaticBoluses = input.doses.filter({ dose in + dose.deliveryType == .bolus && + dose.automatic && + dose.startDate.addingTimeInterval(.minutes(5)) > now + }) + if !recentAutomaticBoluses.isEmpty { + return .increasedInsulin + } + return scheduledBasalRate != neutralBasal ? .neutralOverride : .neutralNoOverride + } + } +} extension LoopDataManager: AutomationHistoryProvider { func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue] { diff --git a/Loop/Models/SimpleInsulinDose.swift b/Loop/Models/SimpleInsulinDose.swift index 6235d69768..f0cd1048dc 100644 --- a/Loop/Models/SimpleInsulinDose.swift +++ b/Loop/Models/SimpleInsulinDose.swift @@ -15,6 +15,7 @@ import LoopAlgorithm // fast acting insulin model in settings. So until that is removed, we need this. struct SimpleInsulinDose: InsulinDose { var deliveryType: InsulinDeliveryType + var automatic: Bool var startDate: Date var endDate: Date var volume: Double @@ -38,6 +39,7 @@ extension DoseEntry { func simpleDose(with model: InsulinModel) -> SimpleInsulinDose { SimpleInsulinDose( deliveryType: deliveryType, + automatic: automatic ?? false, startDate: startDate, endDate: endDate, volume: volume, @@ -76,6 +78,7 @@ extension SimpleInsulinDose { return SimpleInsulinDose( deliveryType: self.deliveryType, + automatic: automatic, startDate: startDate, endDate: endDate, volume: trimmedVolume, diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index a0d1ca6177..e2b712f4b0 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -494,8 +494,8 @@ final class StatusTableViewController: LoopChartsTableViewController { self.lastLoopError = lastLoopError - if let netBasal = netBasal { - self.hudView?.pumpStatusHUD.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percent, at: netBasal.start) + if let automatedTreatmentState = loopManager.automatedTreatmentState { + self.hudView?.pumpStatusHUD.basalRateHUD.setAutomatedTreatmentState(automatedTreatmentState) } if currentContext.contains(.carbs) { diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 49f3a1e5d2..92cd6e552a 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -18,6 +18,7 @@ import SwiftUI import SwiftCharts import LoopAlgorithm +@MainActor protocol BolusEntryViewModelDelegate: AnyObject { var settings: StoredSettings { get } @@ -520,6 +521,7 @@ final class BolusEntryViewModel: ObservableObject { let enteredBolusDose = SimpleInsulinDose( deliveryType: .bolus, + automatic: false, startDate: startDate, endDate: startDate, volume: enteredBolus.doubleValue(for: .internationalUnit), diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 516741dc6d..2558654848 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -13,12 +13,14 @@ import LoopCore import LoopAlgorithm import os.log +@MainActor protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate, FavoriteFoodInsightsViewModelDelegate { var defaultAbsorptionTimes: DefaultAbsorptionTimes { get } func isScheduleOverrideActive(at date: Date) -> Bool func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] } +@MainActor final class CarbEntryViewModel: ObservableObject { enum Alert: Identifiable { var id: Self { diff --git a/Loop/View Models/ManualEntryDoseViewModel.swift b/Loop/View Models/ManualEntryDoseViewModel.swift index 5365595d0c..9ff4010db6 100644 --- a/Loop/View Models/ManualEntryDoseViewModel.swift +++ b/Loop/View Models/ManualEntryDoseViewModel.swift @@ -232,6 +232,7 @@ final class ManualEntryDoseViewModel: ObservableObject { let enteredBolusDose = SimpleInsulinDose( deliveryType: .bolus, + automatic: false, startDate: selectedDoseDate, endDate: selectedDoseDate, volume: enteredBolus.doubleValue(for: .internationalUnit), diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift index 9955f0addd..3adfa162a2 100644 --- a/Loop/View Models/SimpleBolusViewModel.swift +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -16,6 +16,7 @@ import Intents import LocalAuthentication import LoopAlgorithm +@MainActor protocol SimpleBolusViewModelDelegate: AnyObject { func saveGlucose(sample: NewGlucoseSample) async throws -> StoredGlucoseSample diff --git a/LoopUI/HUDView.xib b/LoopUI/HUDView.xib index 9da3d7b032..7092498a70 100644 --- a/LoopUI/HUDView.xib +++ b/LoopUI/HUDView.xib @@ -1,9 +1,8 @@ - + - - + @@ -74,13 +73,13 @@ - + @@ -154,8 +153,8 @@ - + diff --git a/LoopUI/StatusBarHUDView.xib b/LoopUI/StatusBarHUDView.xib index 539cc0d962..45cfcf329a 100644 --- a/LoopUI/StatusBarHUDView.xib +++ b/LoopUI/StatusBarHUDView.xib @@ -1,8 +1,8 @@ - + - + @@ -193,7 +193,7 @@ - + @@ -210,7 +210,7 @@ - + @@ -347,13 +347,13 @@ - + - + diff --git a/LoopUI/Views/BasalRateHUDView.swift b/LoopUI/Views/BasalRateHUDView.swift index 8608081f0a..7d45f661fa 100644 --- a/LoopUI/Views/BasalRateHUDView.swift +++ b/LoopUI/Views/BasalRateHUDView.swift @@ -7,6 +7,7 @@ // import UIKit +import LoopKit import LoopKitUI public final class BasalRateHUDView: BaseHUDView { @@ -15,7 +16,7 @@ public final class BasalRateHUDView: BaseHUDView { return 3 } - @IBOutlet private weak var basalStateView: BasalStateView! + @IBOutlet private weak var treatmentArrowStateView: TreatmentArrowStateView! @IBOutlet private weak var basalRateLabel: UILabel! { didSet { @@ -28,34 +29,13 @@ public final class BasalRateHUDView: BaseHUDView { public override func tintColorDidChange() { super.tintColorDidChange() - basalStateView.tintColor = tintColor + treatmentArrowStateView.tintColor = tintColor } private lazy var basalRateFormatString = LocalizedString("%@ U", comment: "The format string describing the basal rate.") - public func setNetBasalRate(_ rate: Double, percent: Double, at date: Date) { - let time = timeFormatter.string(from: date) - caption?.text = time - - if let rateString = decimalFormatter.string(from: rate) { - basalRateLabel?.text = String(format: basalRateFormatString, rateString) - accessibilityValue = String(format: LocalizedString("%1$@ units per hour at %2$@", comment: "Accessibility format string describing the basal rate. (1: localized basal rate value)(2: last updated time)"), rateString, time) - } else { - basalRateLabel?.text = nil - accessibilityValue = nil - } - - switch percent { - // still needs to handle manual temp basal - case let x where x == -1.0: - basalStateView.basalDisplayState = .basalTempAutoNoDelivery - case let x where x < 0.0: - basalStateView.basalDisplayState = .basalTempAutoBelow - case let x where x > 0.0: - basalStateView.basalDisplayState = .basalTempAutoAbove - default: - basalStateView.basalDisplayState = .basalScheduled - } + public func setAutomatedTreatmentState(_ automatedTreatmentState: AutomatedTreatmentState) { + treatmentArrowStateView.automatedTreatmentState = automatedTreatmentState } private lazy var decimalFormatter: NumberFormatter = { diff --git a/LoopUI/Views/BasalStateView.swift b/LoopUI/Views/TreatmentArrowStateView.swift similarity index 55% rename from LoopUI/Views/BasalStateView.swift rename to LoopUI/Views/TreatmentArrowStateView.swift index 6e941f9f2e..f6b1bdf9e9 100644 --- a/LoopUI/Views/BasalStateView.swift +++ b/LoopUI/Views/TreatmentArrowStateView.swift @@ -7,56 +7,39 @@ // import SwiftUI +import LoopKit import LoopKitUI -class WrappedBasalRateViewModel: ObservableObject { +class WrappedTreatmentArrowViewModel: ObservableObject { private lazy var basalRateUnitString = LocalizedString("U/hr", comment: "The format string describing the basal rate unit.") private lazy var basalRateFormatString = "%1$d %2$@" - @Published var basalDisplayState: BasalDisplayState + @Published var treatmentArrowState: AutomatedTreatmentState @Published var tintColor: Color var basalStateImageName: String? { - basalDisplayState.imageName - } - var manualTempBasalAmount: Double? { - switch basalDisplayState { - case .basalTempManual(let double): - return double - default: - return nil - } - } - var manualTempBasalAmountString: String? { - guard let manualTempBasalAmount = manualTempBasalAmount else { return nil } - return "\(manualTempBasalAmount)" + treatmentArrowState.imageName } + var basalStateCaptionString: String? { - switch basalDisplayState { - case .basalTempManual: return basalRateUnitString - case .basalTempAutoNoDelivery: return String(format: basalRateFormatString, 0, basalRateUnitString) + switch treatmentArrowState { + case .minimumDelivery: return String(format: basalRateFormatString, 0, basalRateUnitString) default: return nil } } - var isBasalTempManual: Bool { - switch basalDisplayState { - case .basalTempManual: return true - default: return false - } - } - - init(basalDisplayState: BasalDisplayState = .basalScheduled, + + init(basalDisplayState: AutomatedTreatmentState = .neutralNoOverride, tintColor: Color = .insulinTintColor ) { - self.basalDisplayState = basalDisplayState + self.treatmentArrowState = basalDisplayState self.tintColor = tintColor } } -struct WrappedBasalRateView: View { +struct WrappedTreatmentArrowView: View { - @StateObject var viewModel: WrappedBasalRateViewModel + @StateObject var viewModel: WrappedTreatmentArrowViewModel var body: some View { VStack { @@ -65,28 +48,20 @@ struct WrappedBasalRateView: View { .font(.title) .foregroundStyle(viewModel.tintColor) } - if let manualTempBasalAmountString = viewModel.manualTempBasalAmountString { - Text(manualTempBasalAmountString) - .font(.system(size: 24)) - .fontWeight(.heavy) - .bold() - .fixedSize(horizontal: true, vertical: false) - .foregroundStyle(viewModel.tintColor) - } if let basalStateCaptionString = viewModel.basalStateCaptionString { Text(basalStateCaptionString) .font(.caption2) - .foregroundStyle(viewModel.isBasalTempManual ? .secondary : .primary) + .foregroundStyle(.primary) } } - .animation(.default, value: viewModel.basalDisplayState) + .animation(.default, value: viewModel.treatmentArrowState) } } -class BasalRateHostingController: UIHostingController { - init(viewModel: WrappedBasalRateViewModel) { +class BasalRateHostingController: UIHostingController { + init(viewModel: WrappedTreatmentArrowViewModel) { super.init( - rootView: WrappedBasalRateView( + rootView: WrappedTreatmentArrowView( viewModel: viewModel ) ) @@ -98,7 +73,7 @@ class BasalRateHostingController: UIHostingController { } -public final class BasalStateView: UIView { +public final class TreatmentArrowStateView: UIView { override init(frame: CGRect) { super.init(frame: frame) @@ -112,9 +87,9 @@ public final class BasalStateView: UIView { setupViews() } - var basalDisplayState: BasalDisplayState = .basalScheduled { + var automatedTreatmentState: AutomatedTreatmentState = .neutralNoOverride { didSet { - viewModel.basalDisplayState = basalDisplayState + viewModel.treatmentArrowState = automatedTreatmentState } } @@ -123,7 +98,7 @@ public final class BasalStateView: UIView { viewModel.tintColor = Color(uiColor: tintColor) } - private let viewModel = WrappedBasalRateViewModel() + private let viewModel = WrappedTreatmentArrowViewModel() private func setupViews() { let hostingController = BasalRateHostingController(viewModel: viewModel) From c1114395567636651cc685a9f70a71f93b16aba8 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 19 May 2025 10:47:42 -0500 Subject: [PATCH 253/421] Fix button text (#795) --- Loop/Views/Presets/EditPresetView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 6a1a98e271..6d6ffb97bb 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -288,7 +288,7 @@ struct EditPresetView: View { } if preset.canBeDeleted { - Button("Delete") { + Button("Delete Preset") { isConfirmingDelete = true } .buttonStyle(ActionButtonStyle(.destructive)) From 295694c63d272e05fc47f7b3dcf9e4f4d450cf49 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 29 May 2025 11:48:35 -0500 Subject: [PATCH 254/421] Remove adult/child insulin model selection (#797) --- Common/FeatureFlags.swift | 8 -------- Loop/Managers/OnboardingManager.swift | 2 +- Loop/Views/SettingsView.swift | 1 - 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 44d8a84e4b..68a1fc46dd 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -35,7 +35,6 @@ struct FeatureFlagConfiguration: Decodable { let siriEnabled: Bool let simpleBolusCalculatorEnabled: Bool let usePositiveMomentumAndRCForManualBoluses: Bool - let adultChildInsulinModelSelectionEnabled: Bool let profileExpirationSettingsViewEnabled: Bool let missedMealNotifications: Bool let allowAlgorithmExperiments: Bool @@ -207,12 +206,6 @@ struct FeatureFlagConfiguration: Decodable { self.usePositiveMomentumAndRCForManualBoluses = true #endif - #if ADULT_CHILD_INSULIN_MODEL_SELECTION_ENABLED - self.adultChildInsulinModelSelectionEnabled = true - #else - self.adultChildInsulinModelSelectionEnabled = false - #endif - // ProfileExpirationSettingsView is inverse, since the default state is enabled. #if PROFILE_EXPIRATION_SETTINGS_VIEW_DISABLED self.profileExpirationSettingsViewEnabled = false @@ -269,7 +262,6 @@ extension FeatureFlagConfiguration : CustomDebugStringConvertible { "* allowDebugFeatures: \(allowDebugFeatures)", "* simpleBolusCalculatorEnabled: \(simpleBolusCalculatorEnabled)", "* usePositiveMomentumAndRCForManualBoluses: \(usePositiveMomentumAndRCForManualBoluses)", - "* adultChildInsulinModelSelectionEnabled: \(adultChildInsulinModelSelectionEnabled)", "* profileExpirationSettingsViewEnabled: \(profileExpirationSettingsViewEnabled)", "* missedMealNotifications: \(missedMealNotifications)", "* allowAlgorithmExperiments: \(allowAlgorithmExperiments)", diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index 3e64f3cb08..6b5e0ed7c2 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, adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled) + var onboardingViewController = onboarding.onboardingViewController(onboardingProvider: self, displayGlucosePreference: deviceDataManager.displayGlucosePreference, colorPalette: .default) onboardingViewController.cgmManagerOnboardingDelegate = deviceDataManager onboardingViewController.pumpManagerOnboardingDelegate = deviceDataManager onboardingViewController.serviceOnboardingDelegate = servicesManager diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 5edc43c3f3..217c963a19 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -147,7 +147,6 @@ struct SettingsView: View { viewModel: TherapySettingsViewModel( therapySettings: viewModel.therapySettings(), sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, delegate: viewModel.therapySettingsViewModelDelegate ) ) From 270b5330492d82f4bd23d9139023908d92436f4d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 29 May 2025 12:08:10 -0500 Subject: [PATCH 255/421] LOOP-5315 Edit carbs fixes (#796) * iOS 17 navigation fixes * Edge cases * Fix displayed units for adjusted carb ratio --- Loop.xcodeproj/project.pbxproj | 4 + .../Components/CardSectionScrollView.swift | 4 +- .../Components/CorrectionRangePreview.swift | 10 +- .../Components/InsulinScaleAdjustView.swift | 1 + .../Views/Presets/Components/PresetCard.swift | 8 +- .../Presets/Components/PresetDetentView.swift | 1 - .../CreatePresetNameAndScheduledEdit.swift | 1 - Loop/Views/Presets/EditPresetView.swift | 426 +++++++++--------- .../ExistingPresetInsulinNeedsEdit.swift | 32 +- .../Presets/ExistingPresetRangeEdit.swift | 31 +- Loop/Views/Presets/NewPresetRangeEdit.swift | 35 +- Loop/Views/Presets/NoticeView.swift | 40 ++ Loop/Views/Presets/PresetsView.swift | 95 ++-- Loop/Views/Presets/ReviewNewPresetView.swift | 40 +- LoopTests/Mocks/LoopControlMock.swift | 3 + 15 files changed, 385 insertions(+), 346 deletions(-) create mode 100644 Loop/Views/Presets/NoticeView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 23b8092aae..7818a9dddb 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -456,6 +456,7 @@ 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 */; }; + C1620D392DE0E5120033DEB5 /* NoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1620D382DE0E50D0033DEB5 /* NoticeView.swift */; }; C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */; }; C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */; }; C16575732538AFF6004AE16E /* CGMStalenessMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */; }; @@ -1441,6 +1442,7 @@ 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 = ""; }; + C1620D382DE0E50D0033DEB5 /* NoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeView.swift; sourceTree = ""; }; C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusViewModelTests.swift; sourceTree = ""; }; C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitor.swift; sourceTree = ""; }; C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitorTests.swift; sourceTree = ""; }; @@ -2542,6 +2544,7 @@ 84E8BBAF2CC979300078E6CF /* Presets */ = { isa = PBXGroup; children = ( + C1620D382DE0E50D0033DEB5 /* NoticeView.swift */, C11445B32DB2EBDC00034864 /* ExistingPresetInsulinNeedsEdit.swift */, C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */, C105095A2D78D32C00118A37 /* CreatePresetNameAndScheduledEdit.swift */, @@ -3699,6 +3702,7 @@ 89E267FF229267DF00A3F2AF /* Optional.swift in Sources */, 43785E982120E7060057DED1 /* Intents.intentdefinition in Sources */, 1D82E6A025377C6B009131FB /* TrustedTimeChecker.swift in Sources */, + C1620D392DE0E5120033DEB5 /* NoticeView.swift in Sources */, C105096F2D8237F300118A37 /* EditPresetView.swift in Sources */, A98556852493F901000FD662 /* AlertStore+SimulatedCoreData.swift in Sources */, 899433B823FE129800FA4BEA /* OverrideBadgeView.swift in Sources */, diff --git a/Loop/Views/Presets/Components/CardSectionScrollView.swift b/Loop/Views/Presets/Components/CardSectionScrollView.swift index 4a93d3c7f8..4c8b526166 100644 --- a/Loop/Views/Presets/Components/CardSectionScrollView.swift +++ b/Loop/Views/Presets/Components/CardSectionScrollView.swift @@ -38,9 +38,11 @@ struct CardSectionScrollView: View { .padding() } if let actionArea { - VStack(spacing: 0) { + VStack(spacing: 12) { actionArea } + .padding(.horizontal, 16) + .padding(.vertical, 12) .background(Color(.secondarySystemGroupedBackground).shadow(radius: 5)) } } diff --git a/Loop/Views/Presets/Components/CorrectionRangePreview.swift b/Loop/Views/Presets/Components/CorrectionRangePreview.swift index ae15787a9e..93d356eeff 100644 --- a/Loop/Views/Presets/Components/CorrectionRangePreview.swift +++ b/Loop/Views/Presets/Components/CorrectionRangePreview.swift @@ -15,19 +15,15 @@ public struct CorrectionRangePreview: View { @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference @Environment(\.guidanceColors) private var guidanceColors - @Binding var range: ClosedRange? + var range: ClosedRange? var guardrail: Guardrail private var scheduledRange: ClosedRange - @State private var editedRange: ClosedRange? - private var allowsScheduledRange: Bool var showDisclosure: Bool - init(range: Binding?>, guardrail: Guardrail, scheduledRange: ClosedRange, allowsScheduledRange: Bool = true, showDisclosure: Bool = false) { - self._range = range - self.editedRange = range.wrappedValue + init(range: ClosedRange?, guardrail: Guardrail, scheduledRange: ClosedRange, showDisclosure: Bool = false) { + self.range = range self.guardrail = guardrail self.scheduledRange = scheduledRange - self.allowsScheduledRange = allowsScheduledRange self.showDisclosure = showDisclosure } diff --git a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift index 8d786f7534..346649309e 100644 --- a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift +++ b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift @@ -169,6 +169,7 @@ public struct InsulinScaleAdjustView: View { SettingAdjustmentPreview( value: carbRatio, + displayUnit: .gramsPerUnit, name: "Carb Ratio", highlighted: insulinPercentage != 100 ) diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index d790192ac0..7b0bcb34a6 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -75,13 +75,7 @@ struct PresetCard: View { if expectedEndTime == nil { presetDuration - } - - Image(systemName: "chevron.right") - .imageScale(.small) - .font(.headline) - .foregroundColor(.secondary) - .opacity(0.5) + } } VStack(alignment: .leading, spacing: 10) { diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index d322cee00a..18f58ad4d9 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -114,7 +114,6 @@ struct PresetDetentView: View { if operation == .start { Button { - dismiss() didTapEdit() } label: { Group { diff --git a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift index 503e7ab326..5240dc7894 100644 --- a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift +++ b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift @@ -232,7 +232,6 @@ struct CreatePresetNameAndScheduledEdit: View { } .disabled(!allowSave) .buttonStyle(ActionButtonStyle(.primary)) - .padding() } .onChange(of: selectedRepeatOption, { oldValue, newValue in if newValue == .weekly { diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 6d6ffb97bb..182b20a207 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -22,20 +22,21 @@ struct EditPresetView: View { case editCorrectionRange case editInsulinNeeds } - @State private var destination: Destination? = nil @State private var preset: SelectablePreset - private var originalPreset: SelectablePreset - private var scheduledRange: ClosedRange - private var onSave: (SelectablePreset) throws -> Void - private var onDelete: (SelectablePreset) throws -> Void - + @State private var navigationPath = NavigationPath() @State private var isDurationPickerExpanded = false @State private var showingDayPicker: Bool = false @State private var isConfirmingDelete = false @FocusState private var isTextFieldFocused: Bool + private var originalPreset: SelectablePreset + private var scheduledRange: ClosedRange + private var onSave: (SelectablePreset) throws -> Void + private var onDelete: (SelectablePreset) throws -> Void + + init( preset: SelectablePreset, scheduledRange: ClosedRange, @@ -51,7 +52,9 @@ struct EditPresetView: View { var sensitivitySection: some View { Button { - destination = .editInsulinNeeds + if preset.canAdjustSensitivity { + navigationPath.append(Destination.editInsulinNeeds) + } } label: { CardSection("Temporary Settings Adjustments") { VStack(alignment: .leading, spacing: 8) { @@ -68,7 +71,7 @@ struct EditPresetView: View { HStack { Spacer() VStack(alignment: .center) { - Text("\(Int((1.0 / (preset.insulinSensitivityMultiplier ?? 1)) * 100))%") + Text("\(Int(((1.0 / (preset.insulinSensitivityMultiplier ?? 1)) * 100).rounded()))%") .font(.system(size: 34, weight: .bold)) .foregroundColor(.accentColor) Text("of scheduled") @@ -91,253 +94,254 @@ struct EditPresetView: View { } var body: some View { - ScrollViewReader { scrollViewProxy in - CardSectionScrollView { - presetTitle + NavigationStack(path: $navigationPath) { + ScrollViewReader { scrollViewProxy in + CardSectionScrollView { + presetTitle - sensitivitySection + sensitivitySection - CardSection { - Button { - destination = .editCorrectionRange - } label: { - CorrectionRangePreview( - range: $preset.correctionRange, - guardrail: settingsManager.guardrailForPreset(preset), - scheduledRange: scheduledRange, - allowsScheduledRange: preset.canAdjustSensitivity, - showDisclosure: true - ) - } - } - - CardSection("Preset Details") { - HStack { - Text("Name") - Spacer() - if preset.canChangeName { - TextField("", text: $preset.name, prompt: Text("Required")) - .multilineTextAlignment(.trailing) - .focused($isTextFieldFocused) - .foregroundColor(.secondary) - } else { - Text(preset.name) - .foregroundColor(.secondary) - } - } - } - - // Duration Section - if preset.canAdjustDuration { CardSection { - VStack(alignment: .leading, spacing: 0) { - HStack { - Text("Duration") - .foregroundColor(.primary) - Spacer() - Group { - Text(preset.duration.localizedTitle) - Image(systemName: "chevron.right") - } - .foregroundColor(.secondary) - } - .contentShape(Rectangle()) - .onTapGesture { - isTextFieldFocused = false - withAnimation { - isDurationPickerExpanded.toggle() - Task { - if isDurationPickerExpanded { - try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay - withAnimation { - scrollViewProxy.scrollTo("durationPicker", anchor: .bottom) - } - } - } - } - } - - if isDurationPickerExpanded { - DurationPickerView( - durationType: $preset.duration - ) - .id("durationPicker") // Assign an ID for scrolling - } + Button { + navigationPath.append(Destination.editCorrectionRange) + } label: { + CorrectionRangePreview( + range: preset.correctionRange, + guardrail: settingsManager.guardrailForPreset(preset), + scheduledRange: scheduledRange, + showDisclosure: true + ) } } - .id("durationSection") // Optional: ID for the entire duration section - } - // Schedule Toggle - if preset.allowsScheduling { - CardSection { + CardSection("Preset Details") { HStack { - Text("Schedule") - .font(.body) - + Text("Name") Spacer() + if preset.canChangeName { + TextField("", text: $preset.name, prompt: Text("Required")) + .multilineTextAlignment(.trailing) + .focused($isTextFieldFocused) + .foregroundColor(.secondary) + } else { + Text(preset.name) + .foregroundColor(.secondary) + } + } + } - Toggle("", isOn: Binding(get: { - return preset.scheduleStartDate != nil - }, set: { newValue in - withAnimation { - if newValue { - preset.scheduleStartDate = Date().addingTimeInterval(.hours(1)) + // Duration Section + if preset.canAdjustDuration { + CardSection { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Duration") + .foregroundColor(.primary) + Spacer() + Group { + Text(preset.duration.localizedTitle) + Image(systemName: "chevron.right") + } + .foregroundColor(.secondary) + } + .contentShape(Rectangle()) + .onTapGesture { + isTextFieldFocused = false + withAnimation { + isDurationPickerExpanded.toggle() Task { - try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay - withAnimation { - scrollViewProxy.scrollTo("repeatOption", anchor: .bottom) + if isDurationPickerExpanded { + try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay + withAnimation { + scrollViewProxy.scrollTo("durationPicker", anchor: .bottom) + } } } - } else { - preset.scheduleStartDate = nil - preset.repeatOptions = .none } } - })) - .toggleStyle(SwitchToggleStyle(tint: .green)) - .labelsHidden() - .padding(.vertical, -4) - } - if preset.scheduleStartDate != nil { - Divider() - HStack { - if preset.repeatOptions != .none { - Text("Date") - } else { - Text("Start Date") + if isDurationPickerExpanded { + DurationPickerView( + durationType: $preset.duration + ) + .id("durationPicker") // Assign an ID for scrolling } - Spacer() - DatePicker( - "", - selection: Binding(get: { - preset.scheduleStartDate ?? Date() - }, set: { newValue in - preset.scheduleStartDate = newValue - }), - in: Date()..., - displayedComponents: [.date, .hourAndMinute] - ) } - Divider() - .padding(.top, -4) + } + .id("durationSection") // Optional: ID for the entire duration section + } + + // Schedule Toggle + if preset.allowsScheduling { + CardSection { HStack { - Text("Repeat") + Text("Schedule") + .font(.body) + Spacer() - Picker("Repeat", selection: Binding( - get: { preset.repeatOptions == .none ? .never : .weekly }, - set: { newValue in - if newValue == .never { - preset.repeatOptions = .none - } else { + + Toggle("", isOn: Binding(get: { + return preset.scheduleStartDate != nil + }, set: { newValue in + withAnimation { + if newValue { + preset.scheduleStartDate = Date().addingTimeInterval(.hours(1)) Task { - if let requiredRepeatOption { - preset.repeatOptions = requiredRepeatOption - } try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay withAnimation { - scrollViewProxy.scrollTo("selectedDays", anchor: .bottom) + scrollViewProxy.scrollTo("repeatOption", anchor: .bottom) } } + } else { + preset.scheduleStartDate = nil + preset.repeatOptions = .none } } - ).animation()) { - ForEach(RepeatOption.allCases, id: \.self) { option in - Text(String(describing: option)) - } - } - .tint(.secondary) - .pickerStyle(MenuPickerStyle()) - .padding(.trailing, -8) + })) + .toggleStyle(SwitchToggleStyle(tint: .green)) + .labelsHidden() + .padding(.vertical, -4) } - .id("repeatOption") // Assign an ID for scrolling - - if preset.repeatOptions != .none { + if preset.scheduleStartDate != nil { + Divider() + HStack { + if preset.repeatOptions != .none { + Text("Date") + } else { + Text("Start Date") + } + Spacer() + DatePicker( + "", + selection: Binding(get: { + preset.scheduleStartDate ?? Date() + }, set: { newValue in + preset.scheduleStartDate = newValue + }), + in: Date()..., + displayedComponents: [.date, .hourAndMinute] + ) + } Divider() .padding(.top, -4) HStack { - Text("Selected days") - .foregroundColor(.primary) - HStack { - Spacer() - RepeatOptionView(repeatOptions: preset.repeatOptions) - .padding(.vertical, 6) - .onTapGesture { - withAnimation { - showingDayPicker = true + Text("Repeat") + Spacer() + Picker("Repeat", selection: Binding( + get: { preset.repeatOptions == .none ? .never : .weekly }, + set: { newValue in + if newValue == .never { + preset.repeatOptions = .none + } else { + Task { + if let requiredRepeatOption { + preset.repeatOptions = requiredRepeatOption + } + try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay + withAnimation { + scrollViewProxy.scrollTo("selectedDays", anchor: .bottom) + } } } + } + ).animation()) { + ForEach(RepeatOption.allCases, id: \.self) { option in + Text(String(describing: option)) + } } - .popover(isPresented: $showingDayPicker, arrowEdge: .bottom) { - DayPickerPopup(selectedDays: Binding( - get: { - preset.repeatOptions - }, set: { newValue in - preset.repeatOptions = newValue.union(requiredRepeatOption ?? .none) - })) - .cornerRadius(12) - .presentationCompactAdaptation(.popover) + .tint(.secondary) + .pickerStyle(MenuPickerStyle()) + .padding(.trailing, -8) + } + .id("repeatOption") // Assign an ID for scrolling + + + if preset.repeatOptions != .none { + Divider() + .padding(.top, -4) + HStack { + Text("Selected days") + .foregroundColor(.primary) + HStack { + Spacer() + RepeatOptionView(repeatOptions: preset.repeatOptions) + .padding(.vertical, 6) + .onTapGesture { + withAnimation { + showingDayPicker = true + } + } + } + .popover(isPresented: $showingDayPicker, arrowEdge: .bottom) { + DayPickerPopup(selectedDays: Binding( + get: { + preset.repeatOptions + }, set: { newValue in + preset.repeatOptions = newValue.union(requiredRepeatOption ?? .none) + })) + .cornerRadius(12) + .presentationCompactAdaptation(.popover) + } } + .id("selectedDays") // Assign an ID for scrolling } - .id("selectedDays") // Assign an ID for scrolling } } } - } - if preset.canBeDeleted { - Button("Delete Preset") { - isConfirmingDelete = true + if preset.canBeDeleted { + Button("Delete Preset") { + isConfirmingDelete = true + } + .buttonStyle(ActionButtonStyle(.destructive)) + .padding(.top) } - .buttonStyle(ActionButtonStyle(.destructive)) - .padding(.top) } } - } + .navigationBarItems(trailing: dismissButton) + .navigationDestination(for: Destination.self) { dest in + switch dest { + case .editInsulinNeeds: + ExistingPresetInsulinNeedsEdit( + insulinScaleFactor: $preset.insulinNeedsScaleFactor, + presetUsesScheduledRange: preset.correctionRange == nil, + ) + case .editCorrectionRange: + ExistingPresetRangeEdit( + range: $preset.correctionRange, + guardrail: settingsManager.guardrailForPreset(preset), + scheduledRange: scheduledRange, + allowsScheduledRange: preset.canAdjustSensitivity, + isPreMeal: preset.isPreMeal, + presetAdjustsInsulinNeeds: preset.insulinNeedsScaleFactor != 1 + ) + } + } - .navigationDestination(isPresented: Binding( - get: { destination != nil }, - set: { if !$0 { destination = nil } } - )) { - switch destination { - case .editInsulinNeeds: - ExistingPresetInsulinNeedsEdit(insulinScaleFactor: $preset.insulinNeedsScaleFactor) - case .editCorrectionRange: - ExistingPresetRangeEdit( - range: $preset.correctionRange, - guardrail: settingsManager.guardrailForPreset(preset), - scheduledRange: scheduledRange, - allowsScheduledRange: preset.canAdjustSensitivity, - isPreMeal: preset.isPreMeal - ) - case .none: - EmptyView() + .onChange(of: preset) { + do { + try onSave(preset) + } catch { + print(error) + } } - } - .onChange(of: preset) { - do { - try onSave(preset) - } catch { - print(error) + .alert(isPresented: $isConfirmingDelete) { + Alert( + title: Text("Delete “\(preset.name)”?"), + message: Text("Are you sure you want to delete this preset?"), + primaryButton: .default(Text("Go Back")), + secondaryButton: .destructive(Text("Yes, Delete").bold(), action: { + do { + try onDelete(preset) + dismiss() + } catch { + print(error) + } + }) + ) } } - .alert(isPresented: $isConfirmingDelete) { - Alert( - title: Text("Delete “\(preset.name)”?"), - message: Text("Are you sure you want to delete this preset?"), - primaryButton: .default(Text("Go Back")), - secondaryButton: .destructive(Text("Yes, Delete").bold(), action: { - do { - try onDelete(preset) - dismiss() - } catch { - print(error) - } - }) - ) - } } private var requiredRepeatOption: PresetScheduleRepeatOptions? { @@ -345,6 +349,12 @@ struct EditPresetView: View { return .allCases[Calendar.current.component(.weekday, from: startDate) - 1] } + private var dismissButton: some View { + Button("Done") { + dismiss() + }.bold() + } + var presetTitle: some View { HStack(spacing: 6) { switch preset.icon { diff --git a/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift b/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift index c5bc5254aa..61191222d1 100644 --- a/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift +++ b/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift @@ -17,12 +17,14 @@ struct ExistingPresetInsulinNeedsEdit: View { var guardrail: Guardrail @Binding var scaleFactor: Double @State var editedScale: Double + var presetUsesScheduledRange: Bool = false - init(insulinScaleFactor: Binding) { + init(insulinScaleFactor: Binding, presetUsesScheduledRange: Bool) { _scaleFactor = insulinScaleFactor editedScale = insulinScaleFactor.wrappedValue guardrail = Guardrail.presetInsulinNeeds + self.presetUsesScheduledRange = presetUsesScheduledRange } var body: some View { @@ -31,7 +33,18 @@ struct ExistingPresetInsulinNeedsEdit: View { InsulinScaleAdjustView(insulinMultiplier: $editedScale) } } actionArea: { - guardrailWarningIfNecessary + if let crossedThreshold { + WarningView( + title: crossedThreshold.insulinNeedsScaleWarningTitle, + caption: crossedThreshold.insulinNeedsScaleWarningCaption, + severity: crossedThreshold.severity + ) + } else if presetUsesScheduledRange && editedScale == 1 { + NoticeView( + title: Text("Adjust Overall Insulin Needs"), + caption: Text("With correction range set to using your scheduled range, overall insulin needs adjustment is required.") + ) + } actionButton } .navigationBarBackButtonHidden(editedScale != scaleFactor) @@ -59,9 +72,8 @@ struct ExistingPresetInsulinNeedsEdit: View { scaleFactor = editedScale dismiss() } - .disabled(editedScale == scaleFactor) + .disabled(editedScale == scaleFactor || (editedScale == 1 && presetUsesScheduledRange)) .buttonStyle(ActionButtonStyle(.primary)) - .padding() } var crossedThreshold: SafetyClassification.Threshold? { @@ -72,16 +84,4 @@ struct ExistingPresetInsulinNeedsEdit: View { return threshold } } - - var guardrailWarningIfNecessary: some View { - return Group { - if let crossedThreshold { - WarningView( - title: crossedThreshold.insulinNeedsScaleWarningTitle, - caption: crossedThreshold.insulinNeedsScaleWarningCaption, - severity: crossedThreshold.severity - ) - } - }.padding() - } } diff --git a/Loop/Views/Presets/ExistingPresetRangeEdit.swift b/Loop/Views/Presets/ExistingPresetRangeEdit.swift index 5f69d17075..15d459d529 100644 --- a/Loop/Views/Presets/ExistingPresetRangeEdit.swift +++ b/Loop/Views/Presets/ExistingPresetRangeEdit.swift @@ -20,14 +20,23 @@ struct ExistingPresetRangeEdit: View { @State private var editedRange: ClosedRange? private var allowsScheduledRange: Bool private var isPreMeal: Bool = false + private var presetAdjustsInsulinNeeds: Bool = false - init(range: Binding?>, guardrail: Guardrail, scheduledRange: ClosedRange, allowsScheduledRange: Bool = true, isPreMeal: Bool = false) { + init( + range: Binding?>, + guardrail: Guardrail, + scheduledRange: ClosedRange, + allowsScheduledRange: Bool = true, + isPreMeal: Bool = false, + presetAdjustsInsulinNeeds: Bool + ) { self._range = range self.editedRange = range.wrappedValue self.guardrail = guardrail self.scheduledRange = scheduledRange self.allowsScheduledRange = allowsScheduledRange self.isPreMeal = isPreMeal + self.presetAdjustsInsulinNeeds = presetAdjustsInsulinNeeds } var body: some View { @@ -42,7 +51,13 @@ struct ExistingPresetRangeEdit: View { ) } } actionArea: { - guardrailWarningIfNecessary + if !crossedThresholds.isEmpty { + CorrectionRangeGuardrailWarning(crossedThresholds: crossedThresholds) + } else if (editedRange == nil && !presetAdjustsInsulinNeeds) { + NoticeView( + title: Text("Set an Adjusted Correction Range"), + caption: Text("With overall insulin needs at 100%, an adjusted correction range is required.")) + } actionButton } .navigationBarBackButtonHidden(editedRange != range) @@ -70,9 +85,8 @@ struct ExistingPresetRangeEdit: View { range = editedRange dismiss() } - .disabled(editedRange == range) + .disabled(editedRange == range || (editedRange == nil && !presetAdjustsInsulinNeeds)) .buttonStyle(ActionButtonStyle(.primary)) - .padding() } @@ -92,15 +106,6 @@ struct ExistingPresetRangeEdit: View { return [] } } - - var guardrailWarningIfNecessary: some View { - let crossedThresholds = self.crossedThresholds - return Group { - if !crossedThresholds.isEmpty { - CorrectionRangeGuardrailWarning(crossedThresholds: crossedThresholds) - } - }.padding() - } } private struct CorrectionRangeGuardrailWarning: View { diff --git a/Loop/Views/Presets/NewPresetRangeEdit.swift b/Loop/Views/Presets/NewPresetRangeEdit.swift index 4f0b08f258..867d1f6d75 100644 --- a/Loop/Views/Presets/NewPresetRangeEdit.swift +++ b/Loop/Views/Presets/NewPresetRangeEdit.swift @@ -33,27 +33,13 @@ struct NewPresetRangeEdit: View { ) } } actionArea: { - if preset.insulinMultiplier == 1 && editedRange == nil { - HStack { - VStack(alignment: .leading, spacing: 0) { - Text("Set an Adjusted Correction Range") - .font(Font(UIFont.preferredFont(forTextStyle: .title3))) - .bold() - .padding(.vertical) - - Text("With overall insulin needs at 100%, an adjusted correction range is required.") - .font(.callout) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .accessibilityElement(children: .combine) - - Spacer() - } - .padding(.horizontal) - + if !crossedThresholds.isEmpty { + CorrectionRangeGuardrailWarning(crossedThresholds: crossedThresholds) + } else if preset.insulinMultiplier == 1 && editedRange == nil { + NoticeView( + title: Text("Set an Adjusted Correction Range"), + caption: Text("With overall insulin needs at 100%, an adjusted correction range is required.")) } - guardrailWarningIfNecessary actionButton } @@ -86,7 +72,6 @@ struct NewPresetRangeEdit: View { } .disabled(preset.insulinMultiplier == 1 && editedRange == nil) .buttonStyle(ActionButtonStyle(.primary)) - .padding() } @@ -107,14 +92,6 @@ struct NewPresetRangeEdit: View { } } - var guardrailWarningIfNecessary: some View { - let crossedThresholds = self.crossedThresholds - return Group { - if !crossedThresholds.isEmpty { - CorrectionRangeGuardrailWarning(crossedThresholds: crossedThresholds) - } - }.padding() - } } private struct CorrectionRangeGuardrailWarning: View { diff --git a/Loop/Views/Presets/NoticeView.swift b/Loop/Views/Presets/NoticeView.swift new file mode 100644 index 0000000000..21d37ea69d --- /dev/null +++ b/Loop/Views/Presets/NoticeView.swift @@ -0,0 +1,40 @@ +// +// NoticeView.swift +// Loop +// +// Created by Pete Schwamb on 5/23/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// +import SwiftUI + +public struct NoticeView: View { + var title: Text + var caption: Text + + public init(title: Text, caption: Text) { + self.title = title + self.caption = caption + } + + public var body: some View { + HStack { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .center) { + title + .font(Font(UIFont.preferredFont(forTextStyle: .title3))) + .bold() + .fixedSize(horizontal: false, vertical: true) + } + + caption + .font(.callout) + .foregroundColor(Color(.secondaryLabel)) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + + Spacer() + } + .padding(.vertical, 8) + } +} diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index c4676bea07..dd1f69c203 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -28,8 +28,29 @@ enum PresetSortOption: Int, CaseIterable { } } +// Define an enum to represent the active sheet +enum ActiveSheet: Identifiable { + case editPreset(SelectablePreset) // For EditPresetView + case presetDetent(SelectablePreset) // For PresetDetentView + + var id: String { + switch self { + case .editPreset(let preset): + return "edit_\(preset.id)" // Assuming Preset has an id + case .presetDetent(let preset): + return "detent_\(preset.id)" + } + } +} + struct PresetsView: View { + // Define navigation routes + enum NavigationDestination: Hashable { + case presetsHistory + } + + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.settingsManager) private var settingsManager @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @@ -39,8 +60,8 @@ struct PresetsView: View { @State private var showingMenu: Bool = false @State private var showTraining: Bool = false @State private var presentCreateView: Bool = false + @State private var activeSheet: ActiveSheet? @State private var navigationPath = NavigationPath() - @State private var pendingPreset: SelectablePreset? @AppStorage("presetsSortAscending") private var presetsSortAscending: Bool = true @AppStorage("presetsSortOrder") private var selectedSortOption: PresetSortOption = .name @@ -74,7 +95,7 @@ struct PresetsView: View { if !hasCompletedTraining { PresetsTrainingCard(showTraining: $showTraining) } - + if let activePreset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == temporaryPresetsManager.activeOverride?.presetId }) { PresetCard( @@ -83,24 +104,24 @@ struct PresetsView: View { expectedEndTime: temporaryPresetsManager.activeOverride?.expectedEndTime ) .onTapGesture { - pendingPreset = activePreset + activeSheet = .presetDetent(activePreset) } } - + // All Presets Section VStack(alignment: .leading, spacing: 16) { HStack { Text("All Presets") .font(.title2.bold()) Spacer() - + Button("Sort") { showingMenu.toggle() } .popover(isPresented: $showingMenu) { sortMenu } - + Button(action: { presentCreateView = true; }) { @@ -108,7 +129,7 @@ struct PresetsView: View { } .disabled(!hasCompletedTraining) } - + LazyVStack(spacing: 12) { ForEach(presetsSorted) { preset in PresetCard( @@ -117,25 +138,25 @@ struct PresetsView: View { ) .cornerRadius(12) .onTapGesture { - pendingPreset = preset + activeSheet = .presetDetent(preset) } } } } - + // Support Section VStack(alignment: .leading, spacing: 16) { Text("Support") .font(.title2.bold()) - - NavigationLink(value: Route.presetsHistory) { + + NavigationLink(value: NavigationDestination.presetsHistory) { HStack { Image(systemName: "list.bullet") .foregroundColor(.white) .padding(8) .background(Color.presets) .cornerRadius(8) - + Text("Presets Performance History") Spacer() Image(systemName: "chevron.right") @@ -148,7 +169,7 @@ struct PresetsView: View { .fill(Color(UIColor.tertiarySystemBackground)) .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) .frame(maxWidth: .infinity)) - + if hasCompletedTraining { Button { showTraining = true @@ -167,7 +188,7 @@ struct PresetsView: View { .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) .frame(maxWidth: .infinity)) } - + } } .padding() @@ -177,27 +198,36 @@ struct PresetsView: View { .background(Color(UIColor.secondarySystemBackground)) .navigationTitle(Text("Presets", comment: "Presets screen title")) .navigationBarItems(trailing: dismissButton) - .navigationDestination(for: Route.self) { route in + .navigationDestination(for: NavigationDestination.self) { route in switch route { case .presetsHistory: PresetsHistoryView() - case .editPreset(let presetId): - if let scheduledRange, let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == presetId}) { + } + } + } + .sheet(item: $activeSheet) { sheet in + switch sheet { + case .presetDetent(let preset): + PresetDetentView(preset: preset, didTapEdit: { + activeSheet = .editPreset(preset) + }) + case .editPreset(let preset): + Group { + if let scheduledRange { EditPresetView( preset: preset, scheduledRange: scheduledRange, - onSave: { preset in settingsManager.savePreset(preset) }, - onDelete: { preset in settingsManager.deletePreset(preset) } + onSave: { updatedPreset in + settingsManager.savePreset(updatedPreset) + }, + onDelete: { preset in + settingsManager.deletePreset(preset) + } ) } } } } - .sheet(item: $pendingPreset) { preset in - PresetDetentView(preset: preset, didTapEdit: { - navigationPath.append(Route.editPreset(preset.id)) - }) - } .sheet(isPresented: $showTraining) { PresetsTrainingView { hasCompletedTraining = true @@ -259,23 +289,6 @@ struct PresetsView: View { dismiss() }.bold() } - - private var editButton: some View { - Button(action: { - withAnimation(.easeInOut(duration: 0.3)) { - editMode.toggle() - } - }) { - Text(editMode.title) - .textCase(nil) - } - } -} - -// Define navigation routes -enum Route: Hashable { - case presetsHistory - case editPreset(String) } extension PresetCard { diff --git a/Loop/Views/Presets/ReviewNewPresetView.swift b/Loop/Views/Presets/ReviewNewPresetView.swift index a4d161c702..eca342dc8c 100644 --- a/Loop/Views/Presets/ReviewNewPresetView.swift +++ b/Loop/Views/Presets/ReviewNewPresetView.swift @@ -57,7 +57,7 @@ struct ReviewNewPresetView: View { sensitivitySection CardSection { - CorrectionRangePreview(range: $preset.correctionRange, guardrail: Guardrail.correctionRange, scheduledRange: scheduledRange, allowsScheduledRange: true) + CorrectionRangePreview(range: preset.correctionRange, guardrail: Guardrail.correctionRange, scheduledRange: scheduledRange) } // Name Field @@ -133,35 +133,31 @@ struct ReviewNewPresetView: View { title: Text("Invalid Start Time"), caption: Text("Start time must be at least 1 minute in the future.") ) - .padding() } - Group { - if preset.savePreset, preset.startDate != nil { - Button("Save and Schedule for Later") { - onComplete(false) - } - .buttonStyle(ActionButtonStyle(.primary)) - .disabled(isStartDateTooSoon) - } else if preset.savePreset { - VStack { - Button("Start Preset") { - onComplete(true) - } - .buttonStyle(ActionButtonStyle(.primary)) - Button("Save for Later") { - onComplete(false) - } - .buttonStyle(ActionButtonStyle(.secondary)) - } - } else { + if preset.savePreset, preset.startDate != nil { + Button("Save and Schedule for Later") { + onComplete(false) + } + .buttonStyle(ActionButtonStyle(.primary)) + .disabled(isStartDateTooSoon) + } else if preset.savePreset { + VStack { Button("Start Preset") { onComplete(true) } .buttonStyle(ActionButtonStyle(.primary)) + Button("Save for Later") { + onComplete(false) + } + .buttonStyle(ActionButtonStyle(.secondary)) + } + } else { + Button("Start Preset") { + onComplete(true) } + .buttonStyle(ActionButtonStyle(.primary)) } - .padding() } .navigationBarTitleDisplayMode(.inline) .navigationTitle("Create a Preset") diff --git a/LoopTests/Mocks/LoopControlMock.swift b/LoopTests/Mocks/LoopControlMock.swift index ef5847651c..af720c315a 100644 --- a/LoopTests/Mocks/LoopControlMock.swift +++ b/LoopTests/Mocks/LoopControlMock.swift @@ -9,10 +9,13 @@ import XCTest import Foundation import LoopAlgorithm +import LoopKit @testable import Loop class LoopControlMock: LoopControl { + var automatedTreatmentState: AutomatedTreatmentState? + var lastLoopCompleted: Date? var lastCancelActiveTempBasalReason: CancelActiveTempBasalReason? From 08f45c71e9a0018623c4af1ddcb25bf636b86bf5 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 6 Jun 2025 11:32:17 -0700 Subject: [PATCH 256/421] [LOOP-5295] decisionId on DoseEntry and PersistedPumpEvent (#790) * [LOOP-5295] decisionId on DoseEntry and PersistedPumpEvent * [LOOP-5295] decisionId on DoseEntry and PersistedPumpEvent * [LOOP-5295] decisionId for temp basal * [LOOP-5295] decisionId for temp basal * [LOOP-5295] decisionId for temp basal * [LOOP-5295] decisionId for temp basal --- .../DoseStore+SimulatedCoreData.swift | 5 +- ...osingDecisionStore+SimulatedCoreData.swift | 6 +- Loop/Managers/DeviceDataManager.swift | 17 ++--- Loop/Managers/DoseEnactor.swift | 11 ++- Loop/Managers/LoopAppManager.swift | 7 +- Loop/Managers/LoopDataManager.swift | 69 ++++++++++--------- Loop/Managers/NotificationManager.swift | 6 +- Loop/Managers/ServicesManager.swift | 6 +- .../DosingDecisionStoreProtocol.swift | 2 + Loop/Managers/WatchDataManager.swift | 2 +- Loop/Models/BolusDosingDecision.swift | 2 + Loop/View Models/BolusEntryViewModel.swift | 4 +- Loop/View Models/SimpleBolusViewModel.swift | 4 +- Loop/Views/SimpleBolusView.swift | 2 +- .../Managers/Alerts/AlertManagerTests.swift | 16 ++--- .../Managers/DeviceDataManagerTests.swift | 7 +- LoopTests/Managers/DoseEnactorTests.swift | 12 ++-- LoopTests/Managers/LoopDataManagerTests.swift | 54 +++++++++------ .../Managers/MealDetectionManagerTests.swift | 3 + .../Mock Stores/MockDosingDecisionStore.swift | 4 ++ LoopTests/Mocks/MockDeliveryDelegate.swift | 8 +-- LoopTests/Mocks/MockPumpManager.swift | 5 +- .../ViewModels/BolusEntryViewModelTests.swift | 2 +- .../SimpleBolusViewModelTests.swift | 2 +- 24 files changed, 142 insertions(+), 114 deletions(-) diff --git a/Loop/Extensions/DoseStore+SimulatedCoreData.swift b/Loop/Extensions/DoseStore+SimulatedCoreData.swift index 7f95ea82ba..185bea71b9 100644 --- a/Loop/Extensions/DoseStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DoseStore+SimulatedCoreData.swift @@ -110,6 +110,7 @@ fileprivate extension PersistedPumpEvent { endDate: date.addingTimeInterval(duration), value: rate, unit: .unitsPerHour, + decisionId: nil, deliveredUnits: rate * duration / .hours(1))) } @@ -118,7 +119,8 @@ fileprivate extension PersistedPumpEvent { startDate: date, endDate: date.addingTimeInterval(.minutes(1)), value: amount, - unit: .units)) + unit: .units, + decisionId: nil)) } static func simulatedPrime(date: Date) -> PersistedPumpEvent { @@ -143,6 +145,7 @@ fileprivate extension PersistedPumpEvent { endDate: date.addingTimeInterval(duration), value: rate, unit: .unitsPerHour, + decisionId: nil, deliveredUnits: rate * duration / .hours(1), scheduledBasalRate: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: scheduledRate))) } diff --git a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift index b0e55f7267..f83e02446a 100644 --- a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift @@ -59,6 +59,7 @@ extension DosingDecisionStore { fileprivate extension StoredDosingDecision { static func simulated(date: Date) -> StoredDosingDecision { + let id = UUID(uuidString: "ebd31ac5-4345-4a81-a0fe-871aa0b0938d")! let controllerTimeZone = TimeZone(identifier: "America/Los_Angeles")! let scheduleTimeZone = TimeZone(secondsFromGMT: TimeZone(identifier: "America/Phoenix")!.secondsFromGMT())! let reason = "simulatedCoreData" @@ -166,7 +167,7 @@ fileprivate extension StoredDosingDecision { quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 125 + minutes / 5))) } let automaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 0.75, - duration: .minutes(30)), + duration: .minutes(30)), direction: .increase, bolusUnits: 1.25) let manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 0.2, notice: .predictedGlucoseBelowTarget(minGlucose: SimpleGlucoseValue(startDate: date.addingTimeInterval(.minutes(30)), @@ -178,7 +179,8 @@ fileprivate extension StoredDosingDecision { let errors: [Issue] = [Issue(id: "alpha"), Issue(id: "bravo", details: ["size": "tiny"])] - return StoredDosingDecision(date: date, + return StoredDosingDecision(id: id, + date: date, controllerTimeZone: controllerTimeZone, reason: reason, settings: settings, diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index c68e551883..ac22edbc30 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -723,13 +723,13 @@ extension DeviceDataManager { // MARK: - Client API extension DeviceDataManager { - func enactBolus(units: Double, activationType: BolusActivationType) async throws { + func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws { guard let pumpManager = pumpManager else { throw LoopError.configurationError(.pumpManager) } try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - pumpManager.enactBolus(units: units, activationType: activationType) { (error) in + pumpManager.enactBolus(decisionId: decisionId, units: units, activationType: activationType) { (error) in if let error = error { self.log.error("%{public}@", String(describing: error)) switch error { @@ -739,7 +739,7 @@ extension DeviceDataManager { default: // Do not generate notifications for automatic boluses that fail. if !activationType.isAutomatic { - NotificationManager.sendBolusFailureNotification(for: error, units: units, at: Date(), activationType: activationType) + NotificationManager.sendBolusFailureNotification(for: error, units: units, at: Date(), decisionId: decisionId, activationType: activationType) } } continuation.resume(throwing: error) @@ -1367,7 +1367,7 @@ extension DeviceDataManager: DeliveryDelegate { return pumpManager?.status.basalDeliveryState?.isSuspended ?? false } - func enact(_ recommendation: AutomaticDoseRecommendation) async throws { + func enact(bolus: Double?, tempBasal: TempBasalRecommendation?, decisionId: UUID?) async throws { guard let pumpManager = pumpManager else { throw LoopError.configurationError(.pumpManager) } @@ -1376,12 +1376,9 @@ extension DeviceDataManager: DeliveryDelegate { 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) + log.default("Enacting dose: %{public}@", String(describing: (bolus, tempBasal))) + + try await doseEnactor.enact(decisionId: decisionId, bolus: bolus, tempBasal: tempBasal, with: pumpManager) } var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { diff --git a/Loop/Managers/DoseEnactor.swift b/Loop/Managers/DoseEnactor.swift index 6777802a5d..f9df3ebdff 100644 --- a/Loop/Managers/DoseEnactor.swift +++ b/Loop/Managers/DoseEnactor.swift @@ -16,16 +16,15 @@ class DoseEnactor { private let log = DiagnosticLog(category: "DoseEnactor") - func enact(recommendation: AutomaticDoseRecommendation, with pumpManager: PumpManager) async throws { - - if let basalAdjustment = recommendation.basalAdjustment { + func enact(decisionId: UUID?, bolus: Double?, tempBasal: TempBasalRecommendation?, with pumpManager: PumpManager) async throws { + if let tempBasal { self.log.default("Enacting recommended basal change") - try await pumpManager.enactTempBasal(unitsPerHour: basalAdjustment.unitsPerHour, for: basalAdjustment.duration) + try await pumpManager.enactTempBasal(decisionId: decisionId, unitsPerHour: tempBasal.unitsPerHour, for: tempBasal.duration) } - if let bolusUnits = recommendation.bolusUnits, bolusUnits > 0 { + if let bolus, bolus > 0 { self.log.default("Enacting recommended bolus dose") - try await pumpManager.enactBolus(units: bolusUnits, activationType: .automatic) + try await pumpManager.enactBolus(decisionId: decisionId, units: bolus, activationType: .automatic) } } } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 20dbbe7ba3..dd9f365855 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -317,6 +317,7 @@ class LoopAppManager: NSObject { doseStore: doseStore, glucoseStore: glucoseStore, carbStore: carbStore, + crashRecoveryManager: crashRecoveryManager, dosingDecisionStore: dosingDecisionStore, automaticDosingStatus: automaticDosingStatus, trustedTimeOffset: { self.trustedTimeChecker.detectedSystemTimeOffset }, @@ -898,7 +899,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { analyticsServicesManager.didRetryBolus() Task { @MainActor in - try? await deviceDataManager?.enactBolus(units: units, activationType: activationType) + try? await deviceDataManager?.enactBolus(units: units, decisionId: UUID(uuidString: response.notification.request.content.userInfo[LoopNotificationUserInfoKey.decisionId.rawValue] as? String ?? ""), activationType: activationType) completionHandler() } } @@ -998,8 +999,8 @@ extension LoopAppManager: ResetLoopManagerDelegate { // MARK: - ServicesManagerDosingDelegate extension LoopAppManager: ServicesManagerDosingDelegate { - func deliverBolus(amountInUnits: Double) async throws { - try await deviceDataManager.enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) + func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws { + try await deviceDataManager.enactBolus(units: amountInUnits, decisionId: decisionId, activationType: .manualNoRecommendation) } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index a8fd310d69..2f556c9153 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -44,8 +44,8 @@ protocol DeliveryDelegate: AnyObject { var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { get } var isPumpConfigured: Bool { get } - func enact(_ recommendation: AutomaticDoseRecommendation) async throws - func enactBolus(units: Double, activationType: BolusActivationType) async throws + func enact(bolus: Double?, tempBasal: TempBasalRecommendation?, decisionId: UUID?) async throws + func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws func roundBasalRate(unitsPerHour: Double) -> Double func roundBolusVolume(units: Double) -> Double } @@ -110,6 +110,7 @@ final class LoopDataManager: ObservableObject { let settingsProvider: SettingsProvider let dosingDecisionStore: DosingDecisionStoreProtocol let glucoseStore: GlucoseStoreProtocol + let crashRecoveryManager: CrashRecoveryManager let logger = DiagnosticLog(category: "LoopDataManager") @@ -119,7 +120,7 @@ final class LoopDataManager: ObservableObject { private let now: () -> Date - private let automaticDosingStatus: AutomaticDosingStatus + let automaticDosingStatus: AutomaticDosingStatus // References to registered notification center observers private var notificationObservers: [Any] = [] @@ -160,6 +161,7 @@ final class LoopDataManager: ObservableObject { doseStore: DoseStoreProtocol, glucoseStore: GlucoseStoreProtocol, carbStore: CarbStoreProtocol, + crashRecoveryManager: CrashRecoveryManager, dosingDecisionStore: DosingDecisionStoreProtocol, now: @escaping () -> Date = { Date() }, automaticDosingStatus: AutomaticDosingStatus, @@ -175,6 +177,7 @@ final class LoopDataManager: ObservableObject { self.doseStore = doseStore self.glucoseStore = glucoseStore self.carbStore = carbStore + self.crashRecoveryManager = crashRecoveryManager self.dosingDecisionStore = dosingDecisionStore self.now = now self.automaticDosingStatus = automaticDosingStatus @@ -254,6 +257,7 @@ final class LoopDataManager: ObservableObject { now.timeIntervalSince(entry.startDate) < .hours(36) }) ?? [] } + if !enabled { temporaryPresetsManager.clearOverride(matching: .preMeal) Task { @@ -488,14 +492,16 @@ final class LoopDataManager: ObservableObject { logger.default("Cancelling active temp basal for reason: %{public}@", String(describing: reason)) - let recommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) + let recommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel, direction: .decrease) var dosingDecision = StoredDosingDecision(reason: reason.rawValue) dosingDecision.settings = StoredDosingDecision.Settings(settingsProvider.settings) dosingDecision.automaticDoseRecommendation = recommendation do { - try await deliveryDelegate?.enact(recommendation) + crashRecoveryManager.dosingStarted(dose: recommendation) + try await deliveryDelegate?.enact(bolus: recommendation.bolusUnits, tempBasal: recommendation.basalAdjustment, decisionId: dosingDecision.id) + self.crashRecoveryManager.dosingFinished() } catch { dosingDecision.appendError(error as? LoopError ?? .unknownError(error)) if reason == .maximumBasalRateChanged { @@ -565,22 +571,25 @@ final class LoopDataManager: ObservableObject { recommendationToEnact.bolusUnits = deliveryDelegate.roundBolusVolume(units: bolus) } - if var basal = algoRecommendation.basalAdjustment { - basal.unitsPerHour = deliveryDelegate.roundBasalRate(unitsPerHour: basal.unitsPerHour) - - let scheduledBasalRate = input.basal.closestPrior(to: loopBaseTime)!.value - let activeOverride = temporaryPresetsManager.presetHistory.activeOverride(at: loopBaseTime) - - let basalAdjustment = basal.adjustForCurrentDelivery( - at: loopBaseTime, - neutralBasalRate: scheduledBasalRate, - currentTempBasal: deliveryDelegate.basalDeliveryState?.currentTempBasal, - continuationInterval: .minutes(11), - neutralBasalRateMatchesPump: activeOverride == nil - ) - + var basal = algoRecommendation.basalAdjustment + + basal.unitsPerHour = deliveryDelegate.roundBasalRate(unitsPerHour: basal.unitsPerHour) + + let scheduledBasalRate = input.basal.closestPrior(to: loopBaseTime)!.value + let activeOverride = temporaryPresetsManager.presetHistory.activeOverride(at: loopBaseTime) + + let basalAdjustment = basal.adjustForCurrentDelivery( + at: loopBaseTime, + neutralBasalRate: scheduledBasalRate, + currentTempBasal: deliveryDelegate.basalDeliveryState?.currentTempBasal, + continuationInterval: .minutes(11), + neutralBasalRateMatchesPump: activeOverride == nil + ) + + if let basalAdjustment { recommendationToEnact.basalAdjustment = basalAdjustment } + output.recommendationResult = .success(.init(automatic: recommendationToEnact)) if recommendationToEnact != algoRecommendation { @@ -598,10 +607,9 @@ final class LoopDataManager: ObservableObject { throw LoopError.pumpSuspended } - if recommendationToEnact.hasDosingChange { - logger.default("Enacting: %{public}@", String(describing: recommendationToEnact)) - try await deliveryDelegate.enact(recommendationToEnact) - } + logger.default("Enacting: %{public}@", String(describing: recommendationToEnact)) + + try await deliveryDelegate.enact(bolus: recommendationToEnact.bolusUnits, tempBasal: basalAdjustment, decisionId: dosingDecision.id) logger.default("loop() completed successfully.") lastLoopCompleted = Date() @@ -807,7 +815,7 @@ extension LoopDataManager { /// - 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) + let dose = DoseEntry(type: .bolus, startDate: startDate, value: units, unit: .units, decisionId: nil, syncIdentifier: syncIdentifier, insulinType: insulinType, manuallyEntered: true) do { try await doseStore.addDoses([dose], from: nil) @@ -818,7 +826,8 @@ extension LoopDataManager { } func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async { - let dosingDecision = StoredDosingDecision(date: date, + let dosingDecision = StoredDosingDecision(id: bolusDosingDecision.id, + date: date, reason: bolusDosingDecision.reason.rawValue, settings: StoredDosingDecision.Settings(settingsProvider.settings), scheduleOverride: bolusDosingDecision.scheduleOverride, @@ -1195,8 +1204,8 @@ extension LoopDataManager: SimpleBolusViewModelDelegate { settingsProvider.settings.suspendThreshold?.quantity } - func enactBolus(units: Double, activationType: BolusActivationType) async throws { - try await deliveryDelegate?.enactBolus(units: units, activationType: activationType) + func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws { + try await deliveryDelegate?.enactBolus(units: units, decisionId: decisionId, activationType: activationType) } } @@ -1376,12 +1385,6 @@ extension AutomaticDosingStrategy { } } -extension AutomaticDoseRecommendation { - public var hasDosingChange: Bool { - return basalAdjustment != nil || bolusUnits != nil - } -} - extension StoredDosingDecision { mutating func updateFrom(input: StoredDataAlgorithmInput, output: AlgorithmOutput) { self.historicalGlucose = input.glucoseHistory.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index d3ab7d44e8..242b086c07 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -80,7 +80,7 @@ extension NotificationManager { // MARK: - Notifications - static func sendBolusFailureNotification(for error: PumpManagerError, units: Double, at startDate: Date, activationType: BolusActivationType) { + static func sendBolusFailureNotification(for error: PumpManagerError, units: Double, at startDate: Date, decisionId: UUID?, activationType: BolusActivationType) { let notification = UNMutableNotificationContent() notification.title = NSLocalizedString("Bolus Issue", comment: "The notification title for a bolus issue") @@ -105,6 +105,10 @@ extension NotificationManager { LoopNotificationUserInfoKey.bolusStartDate.rawValue: startDate, LoopNotificationUserInfoKey.bolusActivationType.rawValue: activationType.rawValue ] + + if let decisionId { + notification.userInfo[LoopNotificationUserInfoKey.decisionId.rawValue] = decisionId + } let request = UNNotificationRequest( // Only support 1 bolus notification at once diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 5fbc5b7f41..8a8fc2cc16 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -237,7 +237,7 @@ class ServicesManager { } public protocol ServicesManagerDosingDelegate: AnyObject { - func deliverBolus(amountInUnits: Double) async throws + func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws } public protocol ServicesManagerDelegate: AnyObject { @@ -329,7 +329,7 @@ extension ServicesManager: ServiceDelegate { } } - func deliverRemoteBolus(amountInUnits: Double) async throws { + func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws { do { guard amountInUnits > 0 else { @@ -344,7 +344,7 @@ extension ServicesManager: ServiceDelegate { throw BolusActionError.exceedsMaxBolus } - try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) + try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId) NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) await remoteDataServicesManager.performUpload(for: .dose) analyticsServicesManager.didBolus(source: "Remote", units: amountInUnits) diff --git a/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift index 79ba9ca090..3bbeee37b5 100644 --- a/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift @@ -14,6 +14,8 @@ protocol DosingDecisionStoreProtocol: CriticalEventLog { func storeDosingDecision(_ dosingDecision: StoredDosingDecision) async func executeDosingDecisionQuery(fromQueryAnchor queryAnchor: DosingDecisionStore.QueryAnchor?, limit: Int, completion: @escaping (DosingDecisionStore.DosingDecisionQueryResult) -> Void) + + func findDosingDecisionsById(_ id: UUID) async throws -> StoredDosingDecision? } extension DosingDecisionStore: DosingDecisionStoreProtocol { } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index b7262086fe..98fbf07e87 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -404,7 +404,7 @@ final class WatchDataManager: NSObject { } do { - try await deviceManager.enactBolus(units: bolus.value, activationType: bolus.activationType) + try await deviceManager.enactBolus(units: bolus.value, decisionId: dosingDecision.id, activationType: bolus.activationType) self.analyticsServicesManager?.didBolus(source: "Watch", units: bolus.value) } catch { } diff --git a/Loop/Models/BolusDosingDecision.swift b/Loop/Models/BolusDosingDecision.swift index 4d3002d2ba..d9b8a47073 100644 --- a/Loop/Models/BolusDosingDecision.swift +++ b/Loop/Models/BolusDosingDecision.swift @@ -16,6 +16,7 @@ struct BolusDosingDecision { case watchBolus } + var id: UUID var reason: Reason var scheduleOverride: TemporaryScheduleOverride? var historicalGlucose: [HistoricalGlucoseValue]? @@ -30,6 +31,7 @@ struct BolusDosingDecision { var manualBolusRequested: Double? init(for reason: Reason) { + self.id = UUID() self.reason = reason } } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 92cd6e552a..7f7860035d 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -33,7 +33,7 @@ protocol BolusEntryViewModelDelegate: AnyObject { 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 enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws func insulinModel(for type: InsulinType?) -> InsulinModel @@ -397,7 +397,7 @@ final class BolusEntryViewModel: ObservableObject { if amountToDeliver > 0 { savedPreMealOverride = nil do { - try await delegate.enactBolus(units: amountToDeliver, activationType: activationType) + try await delegate.enactBolus(units: amountToDeliver, decisionId: dosingDecision.id, activationType: activationType) } catch { log.error("Failed to store bolus: %{public}@", String(describing: error)) } diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift index 3adfa162a2..7d48563871 100644 --- a/Loop/View Models/SimpleBolusViewModel.swift +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -25,7 +25,7 @@ protocol SimpleBolusViewModelDelegate: AnyObject { func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) async - func enactBolus(units: Double, activationType: BolusActivationType) async throws + func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws func insulinOnBoard(at date: Date) async -> InsulinValue? @@ -404,7 +404,7 @@ class SimpleBolusViewModel: ObservableObject { if let bolusVolume = bolus?.doubleValue(for: .internationalUnit), bolusVolume > 0 { do { - try await delegate.enactBolus(units: bolusVolume, activationType: .activationTypeFor(recommendedAmount: recommendation, bolusAmount: bolusVolume)) + try await delegate.enactBolus(units: bolusVolume, decisionId: dosingDecision?.id, activationType: .activationTypeFor(recommendedAmount: recommendation, bolusAmount: bolusVolume)) dosingDecision?.manualBolusRequested = bolusVolume } catch { log.error("Unable to enact bolus: %{public}@", String(describing: error)) diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift index 12968d2ca4..9d98c285d5 100644 --- a/Loop/Views/SimpleBolusView.swift +++ b/Loop/Views/SimpleBolusView.swift @@ -390,7 +390,7 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider { return nil } - func enactBolus(units: Double, activationType: BolusActivationType) { + func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) { } func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { diff --git a/LoopTests/Managers/Alerts/AlertManagerTests.swift b/LoopTests/Managers/Alerts/AlertManagerTests.swift index 44403da913..06aec6dd68 100644 --- a/LoopTests/Managers/Alerts/AlertManagerTests.swift +++ b/LoopTests/Managers/Alerts/AlertManagerTests.swift @@ -342,25 +342,17 @@ class AlertManagerTests: XCTestCase { XCTAssertEqual(.critical, mockAlertStore.issuedAlert!.interruptionLevel) } - func testRescheduleMutedLoopNotLoopingAlerts() { + func testRescheduleMutedLoopNotLoopingAlerts() async { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() let lastLoopDate = Date() alertManager.loopDidComplete(lastLoopDate) alertManager.alertMuter.configuration.startTime = Date() alertManager.alertMuter.configuration.duration = .hours(4) - waitOnMain() - let testExpectation = expectation(description: #function) - var loopNotRunningRequests: [UNNotificationRequest] = [] - UNUserNotificationCenter.current().getPendingNotificationRequests() { notificationRequests in - loopNotRunningRequests = notificationRequests.filter({ - $0.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue - }) - testExpectation.fulfill() - } - - wait(for: [testExpectation], timeout: 1) + let loopNotRunningRequests = await UNUserNotificationCenter.current().pendingNotificationRequests().filter({ + $0.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue + }) XCTAssertNil(loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .timeSensitive })?.content.sound) if let request = loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .critical }) { XCTAssertEqual(request.content.sound, .defaultCriticalSound(withAudioVolume: 0)) diff --git a/LoopTests/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift index 6f4976e592..179040a509 100644 --- a/LoopTests/Managers/DeviceDataManagerTests.swift +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -116,6 +116,7 @@ final class DeviceDataManagerTests: XCTestCase { startDate: Date(), value: 3.0, unit: .unitsPerHour, + decisionId: nil, automatic: true ) pumpManager.status.basalDeliveryState = .tempBasal(dose) @@ -137,7 +138,8 @@ final class DeviceDataManagerTests: XCTestCase { startDate: Date(), endDate: nil, value: 5.0, - unit: .unitsPerHour + unit: .unitsPerHour, + decisionId: nil ) pumpManager.status.basalDeliveryState = .tempBasal(dose) @@ -156,7 +158,8 @@ final class DeviceDataManagerTests: XCTestCase { type: .tempBasal, startDate: Date(), value: 5.0, - unit: .unitsPerHour + unit: .unitsPerHour, + decisionId: nil ) pumpManager.status.basalDeliveryState = .tempBasal(dose) diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index 445cd9aa7c..5609d03952 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -26,7 +26,7 @@ class DoseEnactorTests: XCTestCase { func testBasalAndBolusDosedSerially() async throws { let enactor = DoseEnactor() let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 0, duration: 0) // Cancel - let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 1.5) + let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, direction: .decrease, bolusUnits: 1.5) let pumpManager = MockPumpManager() let tempBasalExpectation = expectation(description: "enactTempBasal called") @@ -39,7 +39,7 @@ class DoseEnactorTests: XCTestCase { bolusExpectation.fulfill() } - try await enactor.enact(recommendation: recommendation, with: pumpManager) + try await enactor.enact(decisionId: nil, bolus: recommendation.bolusUnits, tempBasal: recommendation.basalAdjustment, with: pumpManager) await fulfillment(of: [tempBasalExpectation, bolusExpectation], timeout: 5, enforceOrder: true) } @@ -47,7 +47,7 @@ class DoseEnactorTests: XCTestCase { func testBolusDoesNotIssueIfTempBasalAdjustmentFailed() async throws { let enactor = DoseEnactor() let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 0, duration: 0) // Cancel - let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 1.5) + let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, direction: .decrease, bolusUnits: 1.5) let pumpManager = MockPumpManager() let tempBasalExpectation = expectation(description: "enactTempBasal called") @@ -62,7 +62,7 @@ class DoseEnactorTests: XCTestCase { pumpManager.enactTempBasalError = .configuration(MockPumpManagerError.failed) do { - try await enactor.enact(recommendation: recommendation, with: pumpManager) + try await enactor.enact(decisionId: nil, bolus: recommendation.bolusUnits, tempBasal: recommendation.basalAdjustment, with: pumpManager) XCTFail("Expected enact to throw error on failure.") } catch { } @@ -73,7 +73,7 @@ class DoseEnactorTests: XCTestCase { func testTempBasalOnly() async throws { let enactor = DoseEnactor() let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 1.2, duration: .minutes(30)) // Cancel - let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 0) + let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, direction: .decrease, bolusUnits: 0) let pumpManager = MockPumpManager() let tempBasalExpectation = expectation(description: "enactTempBasal called") @@ -87,7 +87,7 @@ class DoseEnactorTests: XCTestCase { XCTFail("Should not enact bolus") } - try await enactor.enact(recommendation: recommendation, with: pumpManager) + try await enactor.enact(decisionId: nil, bolus: recommendation.bolusUnits, tempBasal: recommendation.basalAdjustment, with: pumpManager) await fulfillment(of: [tempBasalExpectation]) } diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index f833a80934..f9ba22695d 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -39,6 +39,15 @@ extension ISO8601DateFormatter { @MainActor class LoopDataManagerTests: XCTestCase { + + class MockAlertIssuer: AlertIssuer { + func issueAlert(_ alert: LoopKit.Alert) { + } + + func retractAlert(identifier: LoopKit.Alert.Identifier) { + } + } + // MARK: Constants for testing let retrospectiveCorrectionEffectDuration = TimeInterval(hours: 1) let retrospectiveCorrectionGroupingInterval = 1.01 @@ -125,6 +134,7 @@ class LoopDataManagerTests: XCTestCase { doseStore: doseStore, glucoseStore: glucoseStore, carbStore: carbStore, + crashRecoveryManager: CrashRecoveryManager(alertIssuer: MockAlertIssuer()), dosingDecisionStore: dosingDecisionStore, now: { [weak self] in self?.now ?? Date() }, automaticDosingStatus: automaticDosingStatus, @@ -227,8 +237,8 @@ class LoopDataManagerTests: XCTestCase { await loopDataManager.loop() - XCTAssertEqual(0, deliveryDelegate.lastEnact?.bolusUnits) - XCTAssertEqual(0, deliveryDelegate.lastEnact?.basalAdjustment?.unitsPerHour) + XCTAssertEqual(0, deliveryDelegate.lastEnact.bolus) + XCTAssertEqual(0, deliveryDelegate.lastEnact.tempBasal?.unitsPerHour) } @@ -244,7 +254,7 @@ class LoopDataManagerTests: XCTestCase { await loopDataManager.loop() - XCTAssertEqual(0.2, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + XCTAssertEqual(0.2, deliveryDelegate.lastEnact.bolus!, accuracy: defaultAccuracy) } @@ -264,7 +274,7 @@ class LoopDataManagerTests: XCTestCase { await loopDataManager.loop() // Should correct high. - XCTAssertEqual(0.25, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + XCTAssertEqual(0.25, deliveryDelegate.lastEnact.bolus!, accuracy: defaultAccuracy) } func testHighAndRisingWithCOB() async { @@ -283,7 +293,7 @@ class LoopDataManagerTests: XCTestCase { await loopDataManager.loop() // Should correct high. - XCTAssertEqual(1.25, deliveryDelegate.lastEnact!.bolusUnits!, accuracy: defaultAccuracy) + XCTAssertEqual(1.25, deliveryDelegate.lastEnact.bolus!, accuracy: defaultAccuracy) } func testLowAndFalling() async { @@ -302,8 +312,8 @@ class LoopDataManagerTests: XCTestCase { 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) + XCTAssertEqual(0, deliveryDelegate.lastEnact.bolus!, accuracy: defaultAccuracy) + XCTAssertEqual(0, deliveryDelegate.lastEnact.tempBasal!.unitsPerHour, accuracy: defaultAccuracy) } @@ -327,8 +337,7 @@ class LoopDataManagerTests: XCTestCase { 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) + XCTAssertEqual(0, deliveryDelegate.lastEnact.bolus!, accuracy: defaultAccuracy) } func testOpenLoopCancelsTempBasal() async { @@ -336,7 +345,7 @@ class LoopDataManagerTests: XCTestCase { StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)), ] - let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 1.0, unit: .unitsPerHour) + let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 1.0, unit: .unitsPerHour, decisionId: nil) deliveryDelegate.basalDeliveryState = .tempBasal(dose) dosingDecisionStore.storeExpectation = expectation(description: #function) @@ -345,8 +354,9 @@ class LoopDataManagerTests: XCTestCase { await fulfillment(of: [dosingDecisionStore.storeExpectation!], timeout: 1.0) - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) - XCTAssertEqual(deliveryDelegate.lastEnact, expectedAutomaticDoseRecommendation) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel, direction: .decrease) + XCTAssertEqual(deliveryDelegate.lastEnact.bolus, expectedAutomaticDoseRecommendation.bolusUnits) + XCTAssertEqual(deliveryDelegate.lastEnact.tempBasal, expectedAutomaticDoseRecommendation.basalAdjustment) XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "automaticDosingDisabled") XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) @@ -361,8 +371,9 @@ class LoopDataManagerTests: XCTestCase { await loopDataManager.loop() - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 3.0, duration: .minutes(30))) - XCTAssertEqual(deliveryDelegate.lastEnact, expectedAutomaticDoseRecommendation) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 3.0, duration: .minutes(30)), direction: .increase) + XCTAssertEqual(deliveryDelegate.lastEnact.bolus, expectedAutomaticDoseRecommendation.bolusUnits) + XCTAssertEqual(deliveryDelegate.lastEnact.tempBasal, expectedAutomaticDoseRecommendation.basalAdjustment) XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) if dosingDecisionStore.dosingDecisions.count == 1 { XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") @@ -390,7 +401,8 @@ class LoopDataManagerTests: XCTestCase { startDate: d(.minutes(-1)), endDate: d(.minutes(29)), value: 5.05, - unit: .unitsPerHour + unit: .unitsPerHour, + decisionId: nil, ) deliveryDelegate.basalDeliveryState = .tempBasal(dose) @@ -401,8 +413,8 @@ class LoopDataManagerTests: XCTestCase { await loopDataManager.loop() // Should not adjust delivery, as existing temp basal is correct. - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: nil) - XCTAssertNil(deliveryDelegate.lastEnact) + let basalAdjustment = TempBasalRecommendation(unitsPerHour: 5.046818181818183, duration: .seconds(1800)) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: basalAdjustment, direction: .increase) XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) if dosingDecisionStore.dosingDecisions.count == 1 { XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") @@ -422,8 +434,9 @@ class LoopDataManagerTests: XCTestCase { await loopDataManager.loop() - let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 3.0, duration: .minutes(30))) - XCTAssertNil(deliveryDelegate.lastEnact) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 3.0, duration: .minutes(30)), direction: .increase) + XCTAssertNil(deliveryDelegate.lastEnact.bolus) + XCTAssertNil(deliveryDelegate.lastEnact.tempBasal) XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) @@ -510,7 +523,8 @@ extension DoseEntry { startDate: fixture.startDate, endDate: fixture.endDate, value: fixture.volume, - unit: .units + unit: .units, + decisionId: nil ) } } diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift index 202459cd2f..36f749c870 100644 --- a/LoopTests/Managers/MealDetectionManagerTests.swift +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -508,6 +508,7 @@ class MealDetectionManagerTests: XCTestCase { endDate: now.addingTimeInterval(.minutes(10)), value: 20, unit: .units, + decisionId: nil, automatic: true ) ) @@ -531,6 +532,7 @@ class MealDetectionManagerTests: XCTestCase { endDate: now.addingTimeInterval(20), value: 2, unit: .units, + decisionId: nil, automatic: true ) ) @@ -548,6 +550,7 @@ class MealDetectionManagerTests: XCTestCase { endDate: now.addingTimeInterval(.minutes(3)), value: 4.5, unit: .units, + decisionId: nil, automatic: true ) ) diff --git a/LoopTests/Mock Stores/MockDosingDecisionStore.swift b/LoopTests/Mock Stores/MockDosingDecisionStore.swift index f13734326a..affb4ceca0 100644 --- a/LoopTests/Mock Stores/MockDosingDecisionStore.swift +++ b/LoopTests/Mock Stores/MockDosingDecisionStore.swift @@ -37,4 +37,8 @@ class MockDosingDecisionStore: DosingDecisionStoreProtocol { completion(.success(queryAnchor, [])) } } + + func findDosingDecisionsById(_ id: UUID) async throws -> StoredDosingDecision? { + nil + } } diff --git a/LoopTests/Mocks/MockDeliveryDelegate.swift b/LoopTests/Mocks/MockDeliveryDelegate.swift index c3bd8e911b..50a9844f83 100644 --- a/LoopTests/Mocks/MockDeliveryDelegate.swift +++ b/LoopTests/Mocks/MockDeliveryDelegate.swift @@ -20,16 +20,16 @@ class MockDeliveryDelegate: DeliveryDelegate { var isPumpConfigured: Bool = true - var lastEnact: AutomaticDoseRecommendation? + var lastEnact: (bolus: Double?, tempBasal: TempBasalRecommendation?) - func enact(_ recommendation: AutomaticDoseRecommendation) async throws { - lastEnact = recommendation + func enact(bolus: Double?, tempBasal: TempBasalRecommendation?, decisionId: UUID?) async throws { + lastEnact = (bolus, tempBasal) } var lastBolus: Double? var lastBolusActivationType: BolusActivationType? - func enactBolus(units: Double, activationType: BolusActivationType) async throws { + func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws { lastBolus = units lastBolusActivationType = activationType } diff --git a/LoopTests/Mocks/MockPumpManager.swift b/LoopTests/Mocks/MockPumpManager.swift index 70131ab674..52b898447c 100644 --- a/LoopTests/Mocks/MockPumpManager.swift +++ b/LoopTests/Mocks/MockPumpManager.swift @@ -13,7 +13,6 @@ import HealthKit @testable import Loop class MockPumpManager: PumpManager { - var enactBolusCalled: ((Double, BolusActivationType) -> Void)? var enactTempBasalCalled: ((Double, TimeInterval) -> Void)? @@ -79,7 +78,7 @@ class MockPumpManager: PumpManager { return nil } - func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (PumpManagerError?) -> Void) { + func enactBolus(decisionId: UUID?, units: Double, activationType: BolusActivationType, completion: @escaping (PumpManagerError?) -> Void) { enactBolusCalled?(units, activationType) completion(nil) } @@ -88,7 +87,7 @@ class MockPumpManager: PumpManager { completion(.success(nil)) } - func enactTempBasal(unitsPerHour: Double, for duration: TimeInterval, completion: @escaping (PumpManagerError?) -> Void) { + func enactTempBasal(decisionId: UUID?, unitsPerHour: Double, for duration: TimeInterval, completion: @escaping (PumpManagerError?) -> Void) { enactTempBasalCalled?(unitsPerHour, duration) completion(enactTempBasalError) } diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 742bade397..19772d9a58 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -913,7 +913,7 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { var enactedBolusUnits: Double? var enactedBolusActivationType: BolusActivationType? - func enactBolus(units: Double, activationType: BolusActivationType) async throws { + func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws { enactedBolusUnits = units enactedBolusActivationType = activationType } diff --git a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift index c4e17bd1ed..4f4401bcec 100644 --- a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift +++ b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift @@ -289,7 +289,7 @@ extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { } - func enactBolus(units: Double, activationType: BolusActivationType) async throws { + func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws { enactedBolus = (units: units, activationType: activationType) } From 2ad62a9cb3cfcb39778ff3c3b5924b5aea652a65 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 6 Jun 2025 13:14:33 -0700 Subject: [PATCH 257/421] [LOOP-5295] crashrecoverymanager fix (#800) --- Loop/Managers/LoopAppManager.swift | 2 +- Loop/Managers/LoopDataManager.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index dd9f365855..7b65d33a51 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -309,6 +309,7 @@ class LoopAppManager: NSObject { } let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) loopDataManager = LoopDataManager( lastLoopCompleted: ExtensionDataManager.context?.lastLoopCompleted, @@ -327,7 +328,6 @@ class LoopAppManager: NSObject { cacheStore.delegate = loopDataManager - crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) Task { @MainActor in alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2f556c9153..e006172551 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -43,7 +43,7 @@ protocol DeliveryDelegate: AnyObject { var pumpInsulinType: InsulinType? { get } var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { get } var isPumpConfigured: Bool { get } - + func enact(bolus: Double?, tempBasal: TempBasalRecommendation?, decisionId: UUID?) async throws func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws func roundBasalRate(unitsPerHour: Double) -> Double From 2fb1fde5b9336e8bf631c383fccac7a674968abe Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 6 Jun 2025 15:16:18 -0500 Subject: [PATCH 258/421] Update guidance colors for status table (#799) --- Loop/Managers/LoopAppManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 7b65d33a51..e7bc8d1dd4 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -627,6 +627,7 @@ class LoopAppManager: NSObject { .environmentObject(deviceDataManager.displayGlucosePreference) .environment(\.appName, Bundle.main.bundleDisplayName) .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) + .environment(\.guidanceColors, .default) .environment(\.loopStatusColorPalette, .loopStatus) .environment(\.settingsManager, settingsManager) .environment(\.temporaryPresetsManager, temporaryPresetsManager) From 6008346c6b9404727fd7413a1010c3cd86fa4f7d Mon Sep 17 00:00:00 2001 From: Petr Zywczok Date: Wed, 28 May 2025 14:29:16 +0200 Subject: [PATCH 259/421] [QAE-487] Add accessibility identifiers --- Loop/View Controllers/StatusTableViewController.swift | 7 +++++++ Loop/Views/Presets/Components/CorrectionRangePreview.swift | 7 ++++--- Loop/Views/Presets/Components/EditPresetDurationView.swift | 1 + Loop/Views/Presets/Components/PresetCard.swift | 1 + Loop/Views/Presets/Components/PresetDetentView.swift | 2 ++ Loop/Views/Presets/EditPresetView.swift | 2 +- Loop/Views/Presets/ExistingPresetRangeEdit.swift | 2 ++ Loop/Views/Presets/PresetRangeEditor.swift | 1 + Loop/Views/Presets/PresetsView.swift | 5 ++++- Loop/Views/SettingsView.swift | 2 +- Loop/Views/StatusTableView.swift | 2 ++ 11 files changed, 26 insertions(+), 6 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 4fabb7ecb9..2ebf8f07d4 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -951,6 +951,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let attributedString = NSMutableAttributedString(attachment: symbolAttachment) attributedString.append(NSAttributedString(string: NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)"))) cell.titleLabel.attributedText = attributedString + cell.titleLabel.accessibilityIdentifier = "text_PreMealPresetCellTitle" case .legacyWorkout: let symbolAttachment = NSTextAttachment() symbolAttachment.image = UIImage(named: "workout-symbol")?.withTintColor(.white) @@ -958,6 +959,7 @@ final class StatusTableViewController: LoopChartsTableViewController { let attributedString = NSMutableAttributedString(attachment: symbolAttachment) attributedString.append(NSAttributedString(string: NSLocalizedString(" Workout Preset", comment: "Status row title for workout override enabled (leading space is to separate from symbol)"))) cell.titleLabel.attributedText = attributedString + cell.titleLabel.accessibilityIdentifier = "text_WorkoutPresetCellTitle" case .preset(let preset): cell.titleLabel.text = String(format: NSLocalizedString("%@ %@", comment: "The format for an active custom preset. (1: preset symbol)(2: preset name)"), preset.symbol, preset.name) case .custom: @@ -967,13 +969,16 @@ final class StatusTableViewController: LoopChartsTableViewController { if override.isActive() { if let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == override.presetId }), case .preMeal(_) = preset { cell.subtitleLabel.text = NSLocalizedString("on until carbs added", comment: "The format for the description of a premeal preset end date") + cell.subtitleLabel.accessibilityIdentifier = "text_PresetActiveOn" } else { switch override.duration { case .finite: let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) cell.subtitleLabel.text = String(format: NSLocalizedString("on until %@", comment: "The format for the description of a finite custom preset end date"), endTimeText) + cell.subtitleLabel.accessibilityIdentifier = "text_PresetActiveOn" case .indefinite: cell.subtitleLabel.text = NSLocalizedString("on indefinitely", comment: "The format for the description of an indefinite custom preset end date") + cell.subtitleLabel.accessibilityIdentifier = "text_PresetActiveOn" } } } else { @@ -1204,6 +1209,7 @@ final class StatusTableViewController: LoopChartsTableViewController { .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 36) .padding(.vertical) + .accessibilityIdentifier("text_ActiveInsulinFooter") } } @@ -1229,6 +1235,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case .iob: if let currentIOB = currentIOBDescription { cell.setSubtitleLabel(label: currentIOB) + cell.setTitleLabelAccessibilityIdentifier("ActiveInsulin_\(currentIOB.string)") } else { cell.setSubtitleLabel(label: nil) } diff --git a/Loop/Views/Presets/Components/CorrectionRangePreview.swift b/Loop/Views/Presets/Components/CorrectionRangePreview.swift index 93d356eeff..75aae6cd5c 100644 --- a/Loop/Views/Presets/Components/CorrectionRangePreview.swift +++ b/Loop/Views/Presets/Components/CorrectionRangePreview.swift @@ -82,7 +82,8 @@ public struct CorrectionRangePreview: View { HStack(alignment: .top, spacing: 12) { Text(Image(systemName: "exclamationmark.triangle.fill")) .foregroundColor(color) - Text(SafetyClassification.captionForCrossedThresholds(crossedThresholds, isRange: true)); + Text(SafetyClassification.captionForCrossedThresholds(crossedThresholds, isRange: true)) + .accessibilityIdentifier("text_CorrectionRangeWarning"); } .padding(12) .background(color.opacity(0.1)) @@ -104,10 +105,10 @@ public struct CorrectionRangePreview: View { }.padding(.bottom, 10) VStack(spacing: 4) { if let range { - correctionRangeLabel(range: range) + correctionRangeLabel(range: range).accessibilityIdentifier("text_CorrectionRangePreview") Text("Adjusted Range") } else { - correctionRangeLabel(range: scheduledRange) + correctionRangeLabel(range: scheduledRange).accessibilityIdentifier("text_CorrectionRangePreview") Text("Scheduled Range") } } diff --git a/Loop/Views/Presets/Components/EditPresetDurationView.swift b/Loop/Views/Presets/Components/EditPresetDurationView.swift index 2c9b65a093..076435dd32 100644 --- a/Loop/Views/Presets/Components/EditPresetDurationView.swift +++ b/Loop/Views/Presets/Components/EditPresetDurationView.swift @@ -62,6 +62,7 @@ struct EditPresetDurationView: View { .padding([.top, .horizontal]) .background(Color(UIColor.secondarySystemBackground)) .disabled(buttonDisabled) + .accessibilityIdentifier("button_Save") } } .onAppear { diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index 7b0bcb34a6..a4ecfca602 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -39,6 +39,7 @@ struct PresetCard: View { Text(presetName) .fontWeight(.semibold) + .accessibilityIdentifier("text_Preset\(presetName)") } } diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 18f58ad4d9..c4b23ad015 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -46,6 +46,7 @@ struct PresetDetentView: View { case .finite: let endTimeText = DateFormatter.localizedString(from: activeOverride.activeInterval.end, dateStyle: .none, timeStyle: .short) Text(String(format: NSLocalizedString("on until %@", comment: "The format for the description of a custom preset end date"), endTimeText)) + .accessibilityIdentifier("text_PresetActionSheetActiveOn") case .indefinite: EmptyView() } @@ -123,6 +124,7 @@ struct PresetDetentView: View { } .tint(.accentColor) .padding(.bottom, -8) + .accessibilityIdentifier("button_EditPreset") } } diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 182b20a207..f150d9a36b 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -111,7 +111,7 @@ struct EditPresetView: View { scheduledRange: scheduledRange, showDisclosure: true ) - } + }.accessibilityIdentifier("button_CorrectionRange") } CardSection("Preset Details") { diff --git a/Loop/Views/Presets/ExistingPresetRangeEdit.swift b/Loop/Views/Presets/ExistingPresetRangeEdit.swift index 15d459d529..1d12daf9db 100644 --- a/Loop/Views/Presets/ExistingPresetRangeEdit.swift +++ b/Loop/Views/Presets/ExistingPresetRangeEdit.swift @@ -75,6 +75,7 @@ struct ExistingPresetRangeEdit: View { dismiss() } .foregroundColor(.blue) + .accessibilityIdentifier("button_Cancel") } } } @@ -87,6 +88,7 @@ struct ExistingPresetRangeEdit: View { } .disabled(editedRange == range || (editedRange == nil && !presetAdjustsInsulinNeeds)) .buttonStyle(ActionButtonStyle(.primary)) + .accessibilityIdentifier("button_Save") } diff --git a/Loop/Views/Presets/PresetRangeEditor.swift b/Loop/Views/Presets/PresetRangeEditor.swift index 7f1c07abd3..515793372d 100644 --- a/Loop/Views/Presets/PresetRangeEditor.swift +++ b/Loop/Views/Presets/PresetRangeEditor.swift @@ -111,6 +111,7 @@ struct PresetRangeEditor: View { + boundText(for: (displayedRange).upperBound) ) + .accessibilityIdentifier("text_AdjustedCorrectionRange") Text("mg/dL") diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index dd1f69c203..e2112d2879 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -113,6 +113,7 @@ struct PresetsView: View { HStack { Text("All Presets") .font(.title2.bold()) + .accessibilityIdentifier("text_AllPresets") Spacer() Button("Sort") { @@ -287,7 +288,9 @@ struct PresetsView: View { private var dismissButton: some View { Button("Done") { dismiss() - }.bold() + } + .bold() + .accessibilityIdentifier("button_done") } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 217c963a19..7d4932017b 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -222,7 +222,7 @@ extension SettingsView { private var dismissButton: some View { Button(action: dismiss) { Text("Done").bold() - } + }.accessibilityIdentifier("button_done") } private var loopSection: some View { diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 18c530a4a2..5546f96f60 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -155,6 +155,7 @@ struct StatusTableView: View { .sheet(item: $viewModel.pendingPreset) { preset in // This is the active preset; edit disabled PresetDetentView(preset: preset, didTapEdit: { }) + .accessibilityIdentifier("bar_Presets") } .toolbar { ToolbarItem(placement: .bottomBar) { @@ -227,6 +228,7 @@ enum ToolbarAction: String, Identifiable, CaseIterable { .resizable() .renderingMode(.template) .foregroundStyle(Color.presets) + .accessibilityIdentifier("image_\(isActive ? "PresetsSelected" : "Presets")") case .settings: Image("settings") .resizable() From 5a41dd7e529899d3732d0630f81f6c3b737abbdf Mon Sep 17 00:00:00 2001 From: Petr Zywczok Date: Tue, 3 Jun 2025 09:24:16 +0200 Subject: [PATCH 260/421] [QAE-487] Add accessibility identifiers --- Loop/Views/BolusEntryView.swift | 1 + Loop/Views/ManualGlucoseEntryRow.swift | 1 + Loop/Views/Presets/EditPresetView.swift | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 4a23be8ff2..faac687d36 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -231,6 +231,7 @@ struct BolusEntryView: View { Text(viewModel.recommendedBolusString) .font(.title) .foregroundColor(Color(.label)) + .accessibilityIdentifier("staticText_RecommendedBolus") bolusUnitsLabel } } diff --git a/Loop/Views/ManualGlucoseEntryRow.swift b/Loop/Views/ManualGlucoseEntryRow.swift index 8c5a4481fb..482c5f4c70 100644 --- a/Loop/Views/ManualGlucoseEntryRow.swift +++ b/Loop/Views/ManualGlucoseEntryRow.swift @@ -51,6 +51,7 @@ struct ManualGlucoseEntryRow: View { unitsChanged() }) .accessibilityIdentifier("textField_FingerstickGlucose") + Text(displayGlucosePreference.formatter.localizedUnitStringWithPlurality()) .foregroundColor(Color(.secondaryLabel)) } diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index f150d9a36b..ae3d1d368b 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -305,7 +305,7 @@ struct EditPresetView: View { case .editInsulinNeeds: ExistingPresetInsulinNeedsEdit( insulinScaleFactor: $preset.insulinNeedsScaleFactor, - presetUsesScheduledRange: preset.correctionRange == nil, + presetUsesScheduledRange: preset.correctionRange == nil ) case .editCorrectionRange: ExistingPresetRangeEdit( From 892ea8ffd640a5a48ca1c5adbaf3cf3d2ac7c115 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 10 Jun 2025 10:53:53 -0500 Subject: [PATCH 261/421] Preset correction guardrail for lower bound stays the same regardless of suspend threshold; we prevent selection of lower values with the minValue param (#802) --- Loop/Managers/SettingsManager.swift | 6 +----- Loop/Views/Presets/CreatePresetView.swift | 2 +- Loop/Views/Presets/PresetRangeEditor.swift | 4 +++- Loop/Views/Presets/ReviewNewPresetView.swift | 6 +++++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index 9db2cce112..c475d6b73b 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -345,7 +345,7 @@ extension SettingsManager { case .legacyWorkout: return legacyWorkoutPresetGuardrail default: - return customPresetGuardRail + return Guardrail.temporaryPresetCorrectionRange } } @@ -373,10 +373,6 @@ extension SettingsManager { } } - public var customPresetGuardRail: Guardrail { - return Guardrail.temporaryPresetCorrectionRange(suspendThreshold: settings.suspendThreshold) - } - func savePreset(_ preset: SelectablePreset) { switch(preset) { case .preMeal(let range): diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift index 9fe10ced79..710779cf91 100644 --- a/Loop/Views/Presets/CreatePresetView.swift +++ b/Loop/Views/Presets/CreatePresetView.swift @@ -101,7 +101,7 @@ struct CreatePresetView: View { NewPresetRangeEdit( preset: $preset, path: $path, - guardrail: Guardrail.temporaryPresetCorrectionRange(suspendThreshold: suspendThreshold), + guardrail: Guardrail.temporaryPresetCorrectionRange, scheduledRange: scheduledRange, onCancel: { dismiss() } ) diff --git a/Loop/Views/Presets/PresetRangeEditor.swift b/Loop/Views/Presets/PresetRangeEditor.swift index 7f1c07abd3..f005566380 100644 --- a/Loop/Views/Presets/PresetRangeEditor.swift +++ b/Loop/Views/Presets/PresetRangeEditor.swift @@ -14,6 +14,8 @@ struct PresetRangeEditor: View { @Environment(\.dismiss) private var dismiss @Environment(\.guidanceColors) private var guidanceColors @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.settingsManager) private var settingsManager + @State private var presentInfoView: Bool = false @Binding var range: ClosedRange? @@ -125,7 +127,7 @@ struct PresetRangeEditor: View { get: { displayedRange }, set: { range = $0 }), unit: displayGlucosePreference.unit, - minValue: nil, + minValue: settingsManager.settings.suspendThreshold?.quantity, guardrail: guardrail) .padding(.vertical, -20) } diff --git a/Loop/Views/Presets/ReviewNewPresetView.swift b/Loop/Views/Presets/ReviewNewPresetView.swift index eca342dc8c..bff0ca6bd4 100644 --- a/Loop/Views/Presets/ReviewNewPresetView.swift +++ b/Loop/Views/Presets/ReviewNewPresetView.swift @@ -57,7 +57,11 @@ struct ReviewNewPresetView: View { sensitivitySection CardSection { - CorrectionRangePreview(range: preset.correctionRange, guardrail: Guardrail.correctionRange, scheduledRange: scheduledRange) + CorrectionRangePreview( + range: preset.correctionRange, + guardrail: Guardrail.temporaryPresetCorrectionRange, + scheduledRange: scheduledRange + ) } // Name Field From e0f73cd8f7dc6859dd58835baceb22a78bc9865d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 10 Jun 2025 11:16:22 -0500 Subject: [PATCH 262/421] LOOP-5352 Fix guardrail colors for create preset summary (#801) * Fix guardrail colors for create preset summary * Capitalization fix * Cleanup --- Loop/Views/Presets/CreatePresetView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift index 710779cf91..a44ad41436 100644 --- a/Loop/Views/Presets/CreatePresetView.swift +++ b/Loop/Views/Presets/CreatePresetView.swift @@ -70,7 +70,6 @@ struct CreatePresetView: View { @State private var path = NavigationPath() @State private var preset = NewCustomPreset() - @State private var navigateToRangeEdit: Bool = false var scheduledRange: ClosedRange? { settingsManager.settings.glucoseTargetRangeSchedule?.quantityRange(at: Date()) From 9141df25a1bad716b4fa324b265b62093db60a2b Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 20 Jun 2025 10:51:52 -0700 Subject: [PATCH 263/421] [LOOP-5295] enactedTempBasal updates to StoredDosingDecision (#804) --- Loop/Extensions/TempBasalRecommendation.swift | 4 ++-- Loop/Managers/LoopAppManager.swift | 4 +++- Loop/Managers/LoopDataManager.swift | 3 +++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Loop/Extensions/TempBasalRecommendation.swift b/Loop/Extensions/TempBasalRecommendation.swift index 8d60a52687..0d7103b7f6 100644 --- a/Loop/Extensions/TempBasalRecommendation.swift +++ b/Loop/Extensions/TempBasalRecommendation.swift @@ -40,7 +40,7 @@ extension TempBasalRecommendation { currentTempBasal: DoseEntry?, continuationInterval: TimeInterval, neutralBasalRateMatchesPump: Bool - ) -> TempBasalRecommendation? { + ) -> EnactedTempBasal? { // Adjust behavior for the currently active temp basal if let currentTempBasal, currentTempBasal.type == .tempBasal, currentTempBasal.endDate > date { @@ -60,7 +60,7 @@ extension TempBasalRecommendation { return self } - public static var cancel: TempBasalRecommendation { + public static var cancel: EnactedTempBasal { return self.init(unitsPerHour: 0, duration: 0) } } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index e7bc8d1dd4..7beec06903 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -501,7 +501,9 @@ class LoopAppManager: NSObject { analyticsServicesManager.application(didFinishLaunchingWithOptions: launchOptions) withObservationTracking(of: self.automaticDosingStatus.isAutomaticDosingAllowed && self.settingsManager.dosingEnabled) { [weak self] enabled in - self?.automaticDosingStatus.automaticDosingEnabled = enabled + if self?.automaticDosingStatus.automaticDosingEnabled != enabled { + self?.automaticDosingStatus.automaticDosingEnabled = enabled + } } state = state.next diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index e006172551..9d15e1761d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -614,6 +614,9 @@ final class LoopDataManager: ObservableObject { logger.default("loop() completed successfully.") lastLoopCompleted = Date() let duration = lastLoopCompleted!.timeIntervalSince(loopBaseTime) + + dosingDecision.enactedTempBasal = basalAdjustment + dosingDecision.enactedBolusAmount = recommendationToEnact.bolusUnits analyticsServicesManager?.loopDidSucceed(duration) } else { From 1187c6ad0d0930bf0ddd63ba13131443c311ec96 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 20 Jun 2025 10:56:09 -0700 Subject: [PATCH 264/421] [LOOP-5259] StatusTableView Toolbar Background Update (#805) --- Loop/Views/StatusTableView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 5546f96f60..d797330bc4 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -185,6 +185,7 @@ struct StatusTableView: View { } } } + .toolbarBackground(.visible, for: .bottomBar) } } From 8d7a7dfe87dd3b7d2fad059a22dd3966bb584f95 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 20 Jun 2025 12:55:34 -0700 Subject: [PATCH 265/421] [LOOP-5259] Dose Chart Icon Updates (#806) --- .../Contents.json | 22 +++++++++++++++++++ .../DIY Dark.svg | 10 +++++++++ .../DIY Light.svg | 10 +++++++++ .../insulin.colorset/Contents.json | 2 +- 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/Contents.json create mode 100644 Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Dark.svg create mode 100644 Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Light.svg diff --git a/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/Contents.json new file mode 100644 index 0000000000..a2394dbc45 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "DIY Light.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "DIY Dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Dark.svg b/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Dark.svg new file mode 100644 index 0000000000..16747e827a --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Dark.svg @@ -0,0 +1,10 @@ + + + DIY Dark + + + + + + + \ No newline at end of file diff --git a/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Light.svg b/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Light.svg new file mode 100644 index 0000000000..1c9495d630 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/dose-chart-bolus-icon.imageset/DIY Light.svg @@ -0,0 +1,10 @@ + + + DIY Light + + + + + + + \ No newline at end of file diff --git a/Loop/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json b/Loop/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json index b431287b2a..d90b065476 100644 --- a/Loop/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json +++ b/Loop/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json @@ -2,7 +2,7 @@ "colors" : [ { "color" : { - "platform" : "ios", + "platform" : "universal", "reference" : "systemOrangeColor" }, "idiom" : "universal" From c2bd2e783f0e1681502534a779bf372dee11beef Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 17 Jul 2025 10:07:25 -0700 Subject: [PATCH 266/421] [LOOP-5328] Fix Deeplinking and Widgets (#786) --- .../Components/DeeplinkView.swift | 2 +- .../Widgets/SystemStatusWidget.swift | 2 +- Loop.xcodeproj/project.pbxproj | 4 -- Loop/Managers/DeeplinkManager.swift | 36 ------------- Loop/Managers/LoopAppManager.swift | 53 ++++++++++++++----- Loop/Models/Deeplink.swift | 51 +++++++++++++++--- .../RootNavigationController.swift | 24 +-------- .../StatusTableViewController.swift | 6 ++- 8 files changed, 92 insertions(+), 86 deletions(-) delete mode 100644 Loop/Managers/DeeplinkManager.swift diff --git a/Loop Widget Extension/Components/DeeplinkView.swift b/Loop Widget Extension/Components/DeeplinkView.swift index 79fdf05862..86880f572e 100644 --- a/Loop Widget Extension/Components/DeeplinkView.swift +++ b/Loop Widget Extension/Components/DeeplinkView.swift @@ -10,7 +10,7 @@ import SwiftUI fileprivate extension Deeplink { var deeplinkURL: URL { - URL(string: "loop://\(rawValue)")! + URL(string: "loop://\(host.rawValue)")! } var accentColor: Color { diff --git a/Loop Widget Extension/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift index 67f211ebc1..2f62f55b03 100644 --- a/Loop Widget Extension/Widgets/SystemStatusWidget.swift +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -63,7 +63,7 @@ struct SystemStatusWidgetEntryView: View { if widgetFamily != .systemSmall { VStack(alignment: .center, spacing: 5) { HStack(alignment: .center, spacing: 5) { - DeeplinkView(destination: .carbEntry) + DeeplinkView(destination: .carbEntry(nil)) DeeplinkView(destination: .bolus) } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 7818a9dddb..bf5d8777fb 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -250,7 +250,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 */; }; - 84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */; }; 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E62A4A4DEF000B658B /* PumpView.swift */; }; 84C170ED2CCA362A0098E52F /* ImpactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EC2CCA361F0098E52F /* ImpactView.swift */; }; 84C170EF2CCA37680098E52F /* PresetCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EE2CCA37680098E52F /* PresetCard.swift */; }; @@ -1154,7 +1153,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 = ""; }; - 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkManager.swift; sourceTree = ""; }; 84AA81E62A4A4DEF000B658B /* PumpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpView.swift; sourceTree = ""; }; 84C170EC2CCA361F0098E52F /* ImpactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImpactView.swift; sourceTree = ""; }; 84C170EE2CCA37680098E52F /* PresetCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetCard.swift; sourceTree = ""; }; @@ -2289,7 +2287,6 @@ 439BED291E76093C00B0AED5 /* CGMManager.swift */, C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */, A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */, - 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */, C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */, 43DBF0521C93EC8200B3C386 /* DeviceDataManager.swift */, C16B983D26B4893300256B05 /* DoseEnactor.swift */, @@ -3721,7 +3718,6 @@ B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */, 14C970842C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift in Sources */, 84E8BBB32CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift in Sources */, - 84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */, 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */, 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, diff --git a/Loop/Managers/DeeplinkManager.swift b/Loop/Managers/DeeplinkManager.swift deleted file mode 100644 index 80e3df02b2..0000000000 --- a/Loop/Managers/DeeplinkManager.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// DeeplinkManager.swift -// Loop -// -// Created by Cameron Ingham on 6/26/23. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import UIKit - -class DeeplinkManager { - - private weak var rootViewController: UIViewController? - - init(rootViewController: UIViewController?) { - self.rootViewController = rootViewController - } - - func handle(_ url: URL) -> Bool { - guard let rootViewController = rootViewController as? RootNavigationController, let deeplink = Deeplink(url: url) else { - return false - } - - rootViewController.navigate(to: deeplink) - return true - } - - func handle(_ deeplink: Deeplink) -> Bool { - guard let rootViewController = rootViewController as? RootNavigationController else { - return false - } - - rootViewController.navigate(to: deeplink) - return true - } -} diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 7beec06903..0047d443a6 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -94,7 +94,6 @@ class LoopAppManager: NSObject { private var analyticsServicesManager = AnalyticsServicesManager() private(set) var testingScenariosManager: TestingScenariosManager? private var resetLoopManager: ResetLoopManager! - private var deeplinkManager: DeeplinkManager! private var temporaryPresetsManager: TemporaryPresetsManager! private var loopDataManager: LoopDataManager! private var mealDetectionManager: MealDetectionManager! @@ -466,8 +465,6 @@ class LoopAppManager: NSObject { windowProvider: windowProvider, userDefaults: UserDefaults.appGroup!) - deeplinkManager = DeeplinkManager(rootViewController: rootViewController) - for support in supportManager.availableSupports { if let analyticsService = support as? AnalyticsService { analyticsServicesManager.addService(analyticsService) @@ -626,22 +623,28 @@ class LoopAppManager: NSObject { ) let statusTableView = StatusTableView(viewModel: viewModel) - .environmentObject(deviceDataManager.displayGlucosePreference) - .environment(\.appName, Bundle.main.bundleDisplayName) - .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) - .environment(\.guidanceColors, .default) - .environment(\.loopStatusColorPalette, .loopStatus) - .environment(\.settingsManager, settingsManager) - .environment(\.temporaryPresetsManager, temporaryPresetsManager) - .edgesIgnoringSafeArea(.top) + self.statusTableViewController = statusTableView.viewController + var rootNavigationController = rootViewController as? RootNavigationController if rootNavigationController == nil { rootNavigationController = RootNavigationController() rootViewController = rootNavigationController } - rootNavigationController?.setViewControllers([UIHostingController(rootView: statusTableView)], animated: true) + rootNavigationController?.setViewControllers([ + UIHostingController( + rootView: statusTableView + .environmentObject(deviceDataManager.displayGlucosePreference) + .environment(\.appName, Bundle.main.bundleDisplayName) + .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) + .environment(\.guidanceColors, .default) + .environment(\.loopStatusColorPalette, .loopStatus) + .environment(\.settingsManager, settingsManager) + .environment(\.temporaryPresetsManager, temporaryPresetsManager) + .edgesIgnoringSafeArea(.top) + ) + ], animated: true) await deviceDataManager.refreshDeviceData() @@ -693,7 +696,29 @@ class LoopAppManager: NSObject { // MARK: - Deeplinking func handle(_ url: URL) -> Bool { - deeplinkManager.handle(url) + guard let deeplink = Deeplink(url: url) else { + return false + } + + switch deeplink { + case let .carbEntry(carbEntryLink): + if let carbEntryLink { + switch carbEntryLink { + case let .carbEntryDetected(value): + statusTableViewController?.presentCarbEntryScreen(nil, value: value) + } + } else { + statusTableViewController?.presentCarbEntryScreen(nil) + } + case .preMeal: + statusTableViewController?.presentPresets() + case .bolus: + statusTableViewController?.presentBolusScreen() + case .customPresets: + statusTableViewController?.presentPresets() + } + + return true } // MARK: - Continuity @@ -769,6 +794,8 @@ class LoopAppManager: NSObject { get { windowProvider?.window?.rootViewController } set { windowProvider?.window?.rootViewController = newValue } } + + private var statusTableViewController: StatusTableViewController? } // MARK: - AlertPresenter diff --git a/Loop/Models/Deeplink.swift b/Loop/Models/Deeplink.swift index b3ccbb4855..aaede07661 100644 --- a/Loop/Models/Deeplink.swift +++ b/Loop/Models/Deeplink.swift @@ -7,18 +7,55 @@ // import Foundation +import LoopAlgorithm -enum Deeplink: String, CaseIterable { - case carbEntry = "carb-entry" - case bolus = "manual-bolus" - case preMeal = "pre-meal-preset" - case customPresets = "custom-presets" +enum Deeplink: Hashable { + enum Host: String, CaseIterable { + case carbEntry = "carb-entry" + case bolus = "manual-bolus" + case preMeal = "pre-meal-preset" + case customPresets = "custom-presets" + } + + enum CarbEntryLink: Hashable { + case carbEntryDetected(value: LoopQuantity) + } + + case carbEntry(CarbEntryLink?) + + case bolus + case preMeal + case customPresets + + var host: Host { + switch self { + case .carbEntry: .carbEntry + case .bolus: .bolus + case .preMeal: .preMeal + case .customPresets: .customPresets + } + } init?(url: URL?) { - guard let url, let host = url.host, let deeplink = Deeplink.allCases.first(where: { $0.rawValue == host }) else { + guard let url, let host = url.host, let deeplinkHost = Deeplink.Host.allCases.first(where: { $0.rawValue == host }) else { return nil } + + let components = URLComponents(url: url, resolvingAgainstBaseURL: true) - self = deeplink + switch deeplinkHost { + case .carbEntry: + if let value = components?.queryItems?.first(where: { $0.name == "value" })?.value, let doubleValue = Double(value) { + self = .carbEntry(.carbEntryDetected(value: LoopQuantity(unit: .gram, doubleValue: doubleValue))) + } else { + self = .carbEntry(nil) + } + case .bolus: + self = .bolus + case .preMeal: + self = .preMeal + case .customPresets: + self = .customPresets + } } } diff --git a/Loop/View Controllers/RootNavigationController.swift b/Loop/View Controllers/RootNavigationController.swift index b003162776..7664b009d8 100644 --- a/Loop/View Controllers/RootNavigationController.swift +++ b/Loop/View Controllers/RootNavigationController.swift @@ -6,30 +6,9 @@ // import UIKit -import LoopKit -import LoopKitUI /// The root view controller in Loop class RootNavigationController: UINavigationController { - - /// Its root view controller is always StatusTableViewController after loading - var statusTableViewController: StatusTableViewController? { - return viewControllers.first as? StatusTableViewController - } - - func navigate(to deeplink: Deeplink) { - switch deeplink { - case .carbEntry: - statusTableViewController?.presentCarbEntryScreen(nil) - case .preMeal: - statusTableViewController?.presentPresets() - case .bolus: - statusTableViewController?.presentBolusScreen() - case .customPresets: - statusTableViewController?.presentPresets() - } - } - override func restoreUserActivityState(_ activity: NSUserActivity) { switch activity.activityType { case NSUserActivity.viewLoopStatusActivityType: @@ -41,8 +20,7 @@ class RootNavigationController: UINavigationController { popToRootViewController(animated: false) } default: - statusTableViewController?.restoreUserActivityState(activity) + viewControllers.first?.restoreUserActivityState(activity) } } - } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 2ebf8f07d4..856d2adb67 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1454,13 +1454,16 @@ final class StatusTableViewController: LoopChartsTableViewController { presentCarbEntryScreen(nil) } - func presentCarbEntryScreen(_ activity: NSUserActivity?) { + func presentCarbEntryScreen(_ activity: NSUserActivity?, value: LoopQuantity? = nil) { let navigationWrapper: UINavigationController if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { let viewModel = SimpleBolusViewModel(delegate: loopManager, displayMealEntry: true, displayGlucosePreference: deviceManager.displayGlucosePreference) if let activity = activity { viewModel.restoreUserActivityState(activity) } + if let carbString = value?.doubleValue(for: .gram) { + viewModel.enteredCarbString = carbString.formatted() + } let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(deviceManager.displayGlucosePreference) let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) navigationWrapper = UINavigationController(rootViewController: hostingController) @@ -1468,6 +1471,7 @@ final class StatusTableViewController: LoopChartsTableViewController { present(navigationWrapper, animated: true) } else { let viewModel = CarbEntryViewModel(delegate: loopManager) + viewModel.carbsQuantity = value?.doubleValue(for: .gram) viewModel.deliveryDelegate = deviceManager viewModel.analyticsServicesManager = loopManager.analyticsServicesManager if let activity { From 9366353a6bd0052d271ee355a6102e4e97915e8f Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 17 Jul 2025 13:35:55 -0700 Subject: [PATCH 267/421] [LOOP-5295] Insulin Delivery Log (#807) --- Loop.xcodeproj/project.pbxproj | 32 + .../Delivery Log/Contents.json | 6 + .../Contents.json | 12 + .../autobolus.png | Bin 0 -> 1703 bytes .../Contents.json | 12 + .../automation-off.png | Bin 0 -> 1657 bytes .../Contents.json | 12 + .../automation-off-range.png | Bin 0 -> 1706 bytes .../Contents.json | 12 + .../automation-on.png | Bin 0 -> 1900 bytes .../Contents.json | 12 + .../automation-unavailable.png | Bin 0 -> 1967 bytes .../basal-delivery-log.imageset/Contents.json | 12 + .../basal-delivery-log.imageset/basal.png | Bin 0 -> 410 bytes .../bolus-delivery-log.imageset/Contents.json | 12 + .../bolus-delivery-log.imageset/bolus.png | Bin 0 -> 2094 bytes Loop/Extensions/UserDefaults+Loop.swift | 20 + Loop/Managers/LoopAppManager.swift | 6 +- Loop/Models/AutomaticDosingStatus.swift | 17 +- Loop/Models/AutomationHistoryEntry.swift | 19 +- Loop/Models/InsulinDeliveryLogEvent.swift | 134 +++++ Loop/Models/SelectablePreset.swift | 2 +- .../StatusTableViewController.swift | 63 +- .../InsulinDeliveryEventDetailsView.swift | 131 +++++ .../InsulinDeliveryLog.swift | 161 +++++ .../InsulinDeliveryLogEventRow.swift | 433 ++++++++++++++ .../InsulinDeliveryLogViewModel.swift | 548 ++++++++++++++++++ .../InsulinDeliveryOverview.swift | 361 ++++++++++++ 28 files changed, 2006 insertions(+), 11 deletions(-) create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/autobolus.png create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/automation-off-delivery-log.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/automation-off-delivery-log.imageset/automation-off.png create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/automation-off-range.png create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/automation-on.png create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/automation-unavailable-delivery-log.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/automation-unavailable-delivery-log.imageset/automation-unavailable.png create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/basal.png create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/bolus.png create mode 100644 Loop/Models/InsulinDeliveryLogEvent.swift create mode 100644 Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift create mode 100644 Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift create mode 100644 Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift create mode 100644 Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift create mode 100644 Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index bf5d8777fb..9b7477e9f6 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -243,6 +243,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 */; }; + 84213C752D932F0F00642E78 /* InsulinDeliveryLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */; }; + 84213C892D94941000642E78 /* InsulinDeliveryLogEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */; }; + 84213C8B2D94948900642E78 /* InsulinDeliveryOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */; }; + 84213C8D2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */; }; + 843F32EA2E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */; }; 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; 84A7B5502D2D972C00B6D202 /* Image+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */; }; 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; @@ -251,6 +256,7 @@ 84AA81DB2A4A2973000B658B /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DA2A4A2973000B658B /* Date.swift */; }; 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */; }; 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E62A4A4DEF000B658B /* PumpView.swift */; }; + 84BC5BDF2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BC5BDE2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift */; }; 84C170ED2CCA362A0098E52F /* ImpactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EC2CCA361F0098E52F /* ImpactView.swift */; }; 84C170EF2CCA37680098E52F /* PresetCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EE2CCA37680098E52F /* PresetCard.swift */; }; 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A62D09053A00CB271F /* StatusTableView.swift */; }; @@ -1146,6 +1152,11 @@ 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; }; + 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLog.swift; sourceTree = ""; }; + 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogEvent.swift; sourceTree = ""; }; + 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryOverview.swift; sourceTree = ""; }; + 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogEventRow.swift; sourceTree = ""; }; + 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogViewModel.swift; sourceTree = ""; }; 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Optional.swift"; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; @@ -1154,6 +1165,7 @@ 84AA81DA2A4A2973000B658B /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelineProvider.swift; sourceTree = ""; }; 84AA81E62A4A4DEF000B658B /* PumpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpView.swift; sourceTree = ""; }; + 84BC5BDE2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryEventDetailsView.swift; sourceTree = ""; }; 84C170EC2CCA361F0098E52F /* ImpactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImpactView.swift; sourceTree = ""; }; 84C170EE2CCA37680098E52F /* PresetCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetCard.swift; sourceTree = ""; }; 84D1F1A62D09053A00CB271F /* StatusTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableView.swift; sourceTree = ""; }; @@ -1952,6 +1964,7 @@ C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */, + 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */, ); path = Models; sourceTree = ""; @@ -2237,6 +2250,7 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( + 84213C732D932EF400642E78 /* Insulin Delivery Log */, B4001CED28CBBC82002FB414 /* AlertManagementView.swift */, 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */, C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */, @@ -2487,6 +2501,18 @@ path = Common; sourceTree = ""; }; + 84213C732D932EF400642E78 /* Insulin Delivery Log */ = { + isa = PBXGroup; + children = ( + 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */, + 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */, + 84BC5BDE2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift */, + 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */, + 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */, + ); + path = "Insulin Delivery Log"; + sourceTree = ""; + }; 84AA81D12A4A2778000B658B /* Components */ = { isa = PBXGroup; children = ( @@ -3620,10 +3646,12 @@ 84E8BBAE2CC9791E0078E6CF /* PresetsTrainingViewModel.swift in Sources */, C1742332259BEADC00399C9D /* ManualEntryDoseView.swift in Sources */, 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */, + 84213C8D2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift in Sources */, 43FCEEA9221A615B0013DD30 /* StatusChartsManager.swift in Sources */, A9F703752489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift in Sources */, E95D380524EADF78005E2F50 /* GlucoseStoreProtocol.swift in Sources */, 43E3449F1B9D68E900C85C07 /* StatusTableViewController.swift in Sources */, + 84213C892D94941000642E78 /* InsulinDeliveryLogEvent.swift in Sources */, B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */, A96DAC242838325900D94E38 /* DiagnosticLog.swift in Sources */, 84C170ED2CCA362A0098E52F /* ImpactView.swift in Sources */, @@ -3649,6 +3677,7 @@ C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, A977A2F424ACFECF0059C207 /* CriticalEventLogExportManager.swift in Sources */, 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, + 843F32EA2E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift in Sources */, 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */, C1AC039E2D6FC8C8004D4D2B /* NewPresetRangeEdit.swift in Sources */, 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */, @@ -3685,6 +3714,7 @@ 89CAB36324C8FE96009EE3CE /* PredictedGlucoseChartView.swift in Sources */, C105096D2D80E23A00118A37 /* DayPickerPopup.swift in Sources */, DDC389F82A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift in Sources */, + 84BC5BDF2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift in Sources */, 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */, A9F703732489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift in Sources */, C105097B2D8B947B00118A37 /* SelectablePreset.swift in Sources */, @@ -3729,6 +3759,7 @@ C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, C10509772D8B591200118A37 /* StatusTableViewModel.swift in Sources */, 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */, + 84213C752D932F0F00642E78 /* InsulinDeliveryLog.swift in Sources */, DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */, 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, @@ -3750,6 +3781,7 @@ 4328E0331CFC091100E199AA /* WatchContext+LoopKit.swift in Sources */, 4F526D611DF8D9A900A04910 /* NetBasal.swift in Sources */, C1DE5D23251BFC4D00439E49 /* SimpleBolusView.swift in Sources */, + 84213C8B2D94948900642E78 /* InsulinDeliveryOverview.swift in Sources */, 89ADE13B226BFA0F0067222B /* TestingScenariosManager.swift in Sources */, 4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */, 436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */, diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/Contents.json new file mode 100644 index 0000000000..175c132ab1 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "autobolus.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/autobolus.png b/Loop/DefaultAssets.xcassets/Delivery Log/autobolus-delivery-log.imageset/autobolus.png new file mode 100644 index 0000000000000000000000000000000000000000..8c0d5195bf0d2ba5eef6878b48e10f220c09cd05 GIT binary patch literal 1703 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 DIeHzF literal 0 HcmV?d00001 diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/Contents.json new file mode 100644 index 0000000000..ce55c209e4 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "automation-off-range.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/automation-off-range.png b/Loop/DefaultAssets.xcassets/Delivery Log/automation-off-range-delivery-log.imageset/automation-off-range.png new file mode 100644 index 0000000000000000000000000000000000000000..d25148ab98e751db93c9e6de497e620dc80c67ca GIT binary patch literal 1706 zcmV;b237fqP)>h+C@Co^DJdx_DJdx_DJdx_DJdx_0-esC z4bO~zzrT~_^K#V>_Ih950f?JUhhCotolf5x47`4tdlsvFsMotc@;vw$Ijq%aygY{j zLt+AWHvr0_-=&1(y}jSAmmRlMP&%tctBOgC zMl#V#V+BQVnu~{e9IBZ|QG31q39VpO6*I&RG@2*LLN({be2E%}Il2N3a8zEzE&AsK z)w9khs@d({_tQogn}!^&u9yUQ{jXn#XS46eSt#3UlQ;bFV`M`{xxKxud7f$klS)OL z&b~J#DBn5*@M6zbi7K9oz(Xr`P(oHZJD1Ao52#aMf|L98!NIE*WSmCBzeWexsFUb# z6G3U+iDJ}%g>W%E%guUpFsK}(l|2FvJDP=ySg2t>0=!}_BTf^7h}?6Su{ zM~A!(EQE{6k}Jp=;WazJAnKfaLCNd!osstjatqHv12v)=*IKD`4GM-T9UjalPsX1`q1>ba&1#Qo(ly7M=Z-oSc2kye5Ut}biifg(+P@8uEGh5 zl32h%7Spllqm;)9!yjp3&k)qJpkQ#EG#RJlth><5{|p6#6FDL-&7s=S4thiz3I^^E zS)d8(@h^7{wX$9zl7^d`{tjp7mm)`+Ls{YDw{O|45J}667rcAHW#j`6dFcQT8wvt< zE2vuaTuJ-roMA5|oD^u-+n@<8nW)4k+@#ab8d3aOgkjIE>!RE+y@s1~ylo8pLfAD? zPtSCXQtG2a4cQhNV&}QbF1&Ud7*wmup;uo}+UVzppd!cJAd*>iE!=Sv!z4L^Sr*nt zKP;#_&oU-zVxwE>(QK0gOp@x(vr5o9WTIkQPHLkjf*Pvt*%=I$cbjDerA8M9Dz=?y z3Fk~c)JTnzormr^YFfaddIW5yHJj_CYq#(J;iRy}NLgv_?bS0`M8;{SnZ~c{`X&0# zT*lsjbMm1JrVEFRVNOh+xw_IhK?j5CrKv1FU3j~FcLo{3teEjthjW6Osymz5O7!{ zD-1L3Hmng^`3SN6AucODW(ckH2yb-5AthPwc#AtIg2gp5eW_MZ0<A-W|Wl$C7z?GX~ZN_2}(RCQPYY^q!yHT4x;LaNoWa56j@YVF$rx!iEj7q z3_b1z6oeotq|W#EUtNMGEJ2B+XT{|Y;bQXS_~2ldwFAtov8SyQTIl+*ZEib~(K0`5 zZOJ-UOoDBpQNqN>yZl(IaHUjlO+LZLsGvkJ1q_>6c343esorCVduh70(zu{w!aQUP zf6UZ22*&+awC%gDyzJTp9rM4i;`gVT3f5_nv#edvC}EWbS*#74!OviAZ&$B2)iu3Q zLC1tkEgNt4H4ed=Da#EkQILcAgkJmlDO%6}11OF9hrt`Jh5!Hn07*qoM6N<$f<93k A%m4rY literal 0 HcmV?d00001 diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/Contents.json new file mode 100644 index 0000000000..b50709ccc0 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "automation-on.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/automation-on.png b/Loop/DefaultAssets.xcassets/Delivery Log/automation-on-delivery-log.imageset/automation-on.png new file mode 100644 index 0000000000000000000000000000000000000000..ca91f747da5f3f7b1bbde324122156cb5e5884fd GIT binary patch literal 1900 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 BqoM!+ literal 0 HcmV?d00001 diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/Contents.json new file mode 100644 index 0000000000..c5c9e4fb30 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "basal.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/basal.png b/Loop/DefaultAssets.xcassets/Delivery Log/basal-delivery-log.imageset/basal.png new file mode 100644 index 0000000000000000000000000000000000000000..0f5629dd42be41864492ea69e88f429830e95289 GIT binary patch literal 410 zcmeAS@N?(olHy`uVBq!ia0vp^;XrJ`!3HEdvQi!cDb50q$YKTtMGFvSbe(3H02E{_ z4sv&5Sa(k5C6L3C?&#~tz_78O`%fY(P)^^|#WAFU@$C&mufv8S4IjTdY0AocVUxA4 z;&ETTX!c?c{)d8AmeyQx$CSTt+&UVRv#6x_g~&ep+@E#X3Wvo%DV($rD(}C4IcgjK z#g#p;U*GgTe~DWM4f>uaCe literal 0 HcmV?d00001 diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/Contents.json new file mode 100644 index 0000000000..68e615cdbb --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bolus.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/bolus.png b/Loop/DefaultAssets.xcassets/Delivery Log/bolus-delivery-log.imageset/bolus.png new file mode 100644 index 0000000000000000000000000000000000000000..6597b7b99553435845549d1f5479c83e7ceddced GIT binary patch literal 2094 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 YKhxuVD?{A>%K!iX07*qoM6N<$f|P*qLjV8( literal 0 HcmV?d00001 diff --git a/Loop/Extensions/UserDefaults+Loop.swift b/Loop/Extensions/UserDefaults+Loop.swift index fe57219067..b0f670d7a8 100644 --- a/Loop/Extensions/UserDefaults+Loop.swift +++ b/Loop/Extensions/UserDefaults+Loop.swift @@ -19,6 +19,7 @@ extension UserDefaults { case inFlightAutomaticDose = "com.loopkit.Loop.inFlightAutomaticDose" case favoriteFoods = "com.loopkit.Loop.favoriteFoods" case automationHistory = "com.loopkit.Loop.automationHistory" + case automaticDosingStatus = "com.loopkit.Loop.automaticDosingStatus" } var legacyPumpManagerRawValue: PumpManager.RawValue? { @@ -130,4 +131,23 @@ extension UserDefaults { } } } + + var automaticDosingStatus: AutomaticDosingStatus? { + get { + let decoder = JSONDecoder() + guard let data = object(forKey: Key.automaticDosingStatus.rawValue) as? Data else { + return nil + } + return try? decoder.decode(AutomaticDosingStatus.self, from: data) + } + set { + do { + let encoder = JSONEncoder() + let data = try encoder.encode(newValue) + set(data, forKey: Key.automaticDosingStatus.rawValue) + } catch { + assertionFailure("Unable to encode automatic dosing status") + } + } + } } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 0047d443a6..4e16855f9f 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -117,7 +117,7 @@ class LoopAppManager: NSObject { private let log = DiagnosticLog(category: "LoopAppManager") private let widgetLog = DiagnosticLog(category: "LoopWidgets") - private let automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: false, isAutomaticDosingAllowed: false) + private var automaticDosingStatus: AutomaticDosingStatus! lazy private var cancellables = Set() @@ -308,6 +308,9 @@ class LoopAppManager: NSObject { } let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear + + self.automaticDosingStatus = UserDefaults.standard.automaticDosingStatus ?? AutomaticDosingStatus(automaticDosingEnabled: UserDefaults.standard.automationHistory.last?.enabled ?? false, isAutomaticDosingAllowed: false) + crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) loopDataManager = LoopDataManager( @@ -327,7 +330,6 @@ class LoopAppManager: NSObject { cacheStore.delegate = loopDataManager - Task { @MainActor in alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) } diff --git a/Loop/Models/AutomaticDosingStatus.swift b/Loop/Models/AutomaticDosingStatus.swift index 87cf764d12..b7a39a6f70 100644 --- a/Loop/Models/AutomaticDosingStatus.swift +++ b/Loop/Models/AutomaticDosingStatus.swift @@ -9,14 +9,25 @@ import Foundation @Observable -public class AutomaticDosingStatus { - public var automaticDosingEnabled: Bool - public var isAutomaticDosingAllowed: Bool +public class AutomaticDosingStatus: Codable { + public var automaticDosingEnabled: Bool { + didSet { + UserDefaults.standard.automaticDosingStatus = self + } + } + + public var isAutomaticDosingAllowed: Bool { + didSet { + UserDefaults.standard.automaticDosingStatus = self + } + } public init(automaticDosingEnabled: Bool, isAutomaticDosingAllowed: Bool) { self.automaticDosingEnabled = automaticDosingEnabled self.isAutomaticDosingAllowed = isAutomaticDosingAllowed + + UserDefaults.standard.automaticDosingStatus = self } } diff --git a/Loop/Models/AutomationHistoryEntry.swift b/Loop/Models/AutomationHistoryEntry.swift index 8d55541924..c459d674d6 100644 --- a/Loop/Models/AutomationHistoryEntry.swift +++ b/Loop/Models/AutomationHistoryEntry.swift @@ -9,13 +9,13 @@ import Foundation import LoopAlgorithm -struct AutomationHistoryEntry: Codable { +struct AutomationHistoryEntry: Codable, Hashable { var startDate: Date var enabled: Bool } extension Array where Element == AutomationHistoryEntry { - func toTimeline(from start: Date, to end: Date) -> [AbsoluteScheduleValue] { + func toTimeline(from start: Date, to end: Date = .now) -> [AbsoluteScheduleValue] { guard !isEmpty else { return [] } @@ -46,4 +46,19 @@ extension Array where Element == AutomationHistoryEntry { return out } + + func automationEnabled(at date: Date) -> Bool? { + let clampedValues = toTimeline(from: date) + guard let first = clampedValues.first else { + return nil + } + + if date < first.startDate { + return !first.value + } else if let enabled = clampedValues.last(where: { $0.startDate <= date })?.value { + return enabled + } else { + return nil + } + } } diff --git a/Loop/Models/InsulinDeliveryLogEvent.swift b/Loop/Models/InsulinDeliveryLogEvent.swift new file mode 100644 index 0000000000..e5f2ce7cfd --- /dev/null +++ b/Loop/Models/InsulinDeliveryLogEvent.swift @@ -0,0 +1,134 @@ +// +// InsulinDeliveryLogEvent.swift +// Loop +// +// Created by Cameron Ingham on 3/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopAlgorithm + +struct InsulinDeliveryLogEvent: Hashable, Identifiable { + enum EventType: Hashable { + enum PumpEventType: Hashable { + enum BasalEventType: Hashable { + enum AutomatedBasalStatus: Hashable { + case scheduled + case moreThanScheduled + case lessThanScheduled + } + + case automationOn(basalStatus: AutomatedBasalStatus) + case automationOff + case automatedPresetBasal + case manualTempBasal(endDate: Date) + } + + case basal(BasalEventType, rate: LoopQuantity) + + enum BolusEventType: Hashable { + case automated + case meal(recommendedAmount: LoopQuantity, carbAmount: LoopQuantity, emoji: String) + case correction(recommendedAmount: LoopQuantity?) + } + + case bolus(BolusEventType, programmedAmount: LoopQuantity?, deliveryAmount: LoopQuantity) + + enum InsulinEventType: Hashable { + case suspended + case resumed + } + + case insulin(InsulinEventType) + } + + case pumpEvent(PumpEventType, DoseEntry?) + + enum AutomationEventType: Hashable { + case on + case off(endDate: Date?) + case unavailable + } + + case automation(AutomationEventType) + + enum PresetEventType: Hashable { + case enabled + case disabled + } + + case preset(PresetEventType, icon: PresetIcon, name: String) + } + + let id: String + let type: EventType + let date: Date +} + +extension InsulinDeliveryLogEvent { + var endDate: Date? { + if case let .automation(.off(endDate)) = type { + return endDate + } else if case let .pumpEvent(.basal(.manualTempBasal(endDate), _), _) = type { + return endDate + } else { + return nil + } + } +} + +extension Array { + + struct LogSegment { + let start: Date + let end: Date + var events: [InsulinDeliveryLogEvent] + } + + func sortedByDate() -> [InsulinDeliveryLogEvent] { + sorted { + var isComparingSuspend = false + if case .pumpEvent(.insulin(.suspended), _) = $0.type { + isComparingSuspend = true + } + + if $0.date == $1.date, case .pumpEvent(.insulin(.resumed), _) = $1.type, !isComparingSuspend { + return true + } else { + return $0.date > $1.date + } + } + } + + func segmentItemsByHour() -> [LogSegment] { + let calendar = Calendar.current + + var itemsByHourRange = [LogSegment]() + + for item in sortedByDate() { + let components = calendar.dateComponents([.day, .hour], from: item.endDate ?? item.date) + + guard let hourStart = calendar.date(from: components), let hourEnd = calendar.date(byAdding: .hour, value: 1, to: hourStart) else { + continue + } + + let hourRange = hourStart.. some View { let formatter = QuantityFormatter(for: .internationalUnit) - if let lastManualDose = lastDoseEntry, let formattedBolusValue = formatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: lastManualDose.deliveredUnits ?? lastManualDose.value)) { + if let lastManualDose = lastDoseEntry, let formattedBolusValue = formatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: lastManualDose.deliveredUnits ?? lastManualDose.value)), lastManualDose.endDate <= Date() { + let hoursDifference = Date().timeIntervalSince(lastManualDose.endDate) / 3600 let lastBolusLabel = Text("Last Bolus: ") let lastBolusValue = Text("\(formattedBolusValue) ").fontWeight(.semibold) let icon = Text(Image(systemName: "hourglass.bottomhalf.filled")).foregroundStyle(.secondary) - let exactTime = Text("at \(lastManualDose.endDate.formatted(date: .omitted, time: .shortened))").foregroundStyle(.secondary) + let exactTime = Text("at \(lastManualDose.startDate.formatted(date: .omitted, time: .shortened))").foregroundStyle(.secondary) let roundedTime = Text(" \(Int(hoursDifference.rounded())) hours ago").foregroundStyle(.secondary) Group { @@ -1362,7 +1363,63 @@ final class StatusTableViewController: LoopChartsTableViewController { performSegue(withIdentifier: PredictionTableViewController.className, sender: indexPath) } case .iob: - performSegue(withIdentifier: InsulinDeliveryTableViewController.className, sender: indexPath) + let showLegacy = false + + if !showLegacy { + let hostingController = UIHostingController( + rootView: InsulinDeliveryLog( + viewModel: InsulinDeliveryLogViewModel( + loopDataManager: loopManager, + pumpManager: deviceManager.pumpManager + ), + onTapGesture: { [weak navigationController] doseEntry in + Task { + var dosingDecision: StoredDosingDecision? + if let decisionId = doseEntry.decisionId { + dosingDecision = try await self.loopManager.dosingDecisionStore.findDosingDecisionsById(decisionId) + } + + let viewController = CommandResponseViewController(command: { (completionHandler) -> String in + var description = [String]() + + let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + + formatter.dateStyle = .none + formatter.timeStyle = .short + + return formatter + }() + + description.append(timeFormatter.string(from: doseEntry.startDate)) + + description.append(String(describing: doseEntry)) + + if let dosingDecision { + description.append(String(describing: dosingDecision)) + } + + return description.joined(separator: "\n\n") + }) + + navigationController?.pushViewController(viewController, animated: true) + } + } + ) + .navigationTitle(Text("Insulin")) + .environment(\.colorPalette, .default) + .environment(\.loopStatusColorPalette, .loopStatus) + ) + + hostingController.hidesBottomBarWhenPushed = true + + navigationController?.pushViewController( + hostingController, + animated: true + ) + } else { + performSegue(withIdentifier: InsulinDeliveryTableViewController.className, sender: indexPath) + } case .cob: performSegue(withIdentifier: CarbAbsorptionViewController.className, sender: indexPath) } diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift new file mode 100644 index 0000000000..ba5b2d22df --- /dev/null +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift @@ -0,0 +1,131 @@ +// +// InsulinDeliveryEventDetailsView.swift +// Loop +// +// Created by Cameron Ingham on 7/7/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import SwiftUI + +struct InsulinDeliveryEventDetailsView: View { + + let basalUnitsFormatter = QuantityFormatter(for: .internationalUnitsPerHour) + let bolusUnitsFormatter = QuantityFormatter(for: .internationalUnit) + let durationFormatter = DateComponentsFormatter() + + let pumpEventType: InsulinDeliveryLogEvent.EventType.PumpEventType + let doseEntry: DoseEntry + let onTapGesture: (DoseEntry) -> Void + + var doseTypeValue: String { + switch pumpEventType { + case .basal(let basalEventType, _): + switch basalEventType { + case .automatedPresetBasal: + return NSLocalizedString("Temp Basal", comment: "") + case .automationOff: + return NSLocalizedString("Scheduled Basal", comment: "") + case .automationOn(basalStatus: let basalStatus): + switch basalStatus { + case .lessThanScheduled: + return NSLocalizedString("Temp Basal", comment: "") + case .moreThanScheduled: + return NSLocalizedString("Temp Basal", comment: "") + case .scheduled: + return NSLocalizedString("Scheduled Basal", comment: "") + } + case .manualTempBasal: + return NSLocalizedString("Temp Basal", comment: "") + } + case .bolus: + return NSLocalizedString("Bolus", comment: "") + case .insulin(let insulinEventType): + switch insulinEventType { + case .resumed: + return NSLocalizedString("Insulin Resumed", comment: "") + case .suspended: + return NSLocalizedString("Insulin Suspended", comment: "") + } + } + } + + var startTimeValue: String? { + doseEntry.startDate.formatted(date: .omitted, time: .shortened) + } + + var durationValue: String? { + durationFormatter.unitsStyle = .abbreviated + + return durationFormatter.string(from: doseEntry.duration) + } + + var deliveredUnitsValue: String? { + switch pumpEventType { + case .basal(_, let rate): + return basalUnitsFormatter.string(from: rate) + case .bolus(_, _, let deliveryAmount): + return bolusUnitsFormatter.string(from: deliveryAmount) + case .insulin: + return basalUnitsFormatter.string(from: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 0)) + } + } + + var body: some View { + List { + Section { + VStack(alignment: .leading) { + Text("Dose Type") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(doseTypeValue) + } + + if let startTimeValue { + VStack(alignment: .leading) { + Text("Start Time") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(startTimeValue) + } + } + + switch pumpEventType { + case .basal, .bolus: + if let durationValue { + VStack(alignment: .leading) { + Text("Duration") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(durationValue) + } + } + + if let deliveredUnitsValue { + VStack(alignment: .leading) { + Text("Insulin Delivery") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(deliveredUnitsValue) + } + } + case .insulin: + EmptyView() + } + } header: { + Text("Delivery Details") + } + .navigationTitle(Text("Insulin Event")) + .contentShape(Rectangle()) + .onTapGesture { + onTapGesture(doseEntry) + } + } + } +} diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift new file mode 100644 index 0000000000..159d149974 --- /dev/null +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift @@ -0,0 +1,161 @@ +// +// InsulinDeliveryLog.swift +// Loop +// +// Created by Cameron Ingham on 3/25/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +struct InsulinDeliveryLog: View { + + @State private var viewModel: InsulinDeliveryLogViewModel + @State var showingFilterMenu = false + + let onTapGesture: (DoseEntry) -> Void + + init(viewModel: InsulinDeliveryLogViewModel, onTapGesture: @escaping (DoseEntry) -> Void) { + self.viewModel = viewModel + self.onTapGesture = onTapGesture + } + + private func totalInsulinDeliveredLabel(from total: LoopQuantity) -> some View { + LabeledContent { + Text(viewModel.totalDeliveredFormatter.string(from: total) ?? "Unknown") + .foregroundStyle(.secondary) + } label: { + VStack(alignment: .leading, spacing: 0) { + Text("Total Insulin Delivery") + + Text("since \(Calendar.current.startOfDay(for: Date()).formatted(date: .omitted, time: .shortened))") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + private var filterMenu: some View { + Menu("Filter") { + Button { } label: { + Text("Filter") + Text("Event") + } + + Picker("Filter", selection: $viewModel.selectedFilterOption) { + ForEach(InsulinDeliveryLogViewModel.FilterOptions.allCases, id: \.self) { option in + Text(option.localizedMenuTitle) + .tag(option) + } + } + } + } + + private var deliveryLogHeader: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 0) { + Text("Insulin Delivery Log") + .font(.headline.weight(.semibold)) + .foregroundStyle(Color(UIColor.label)) + + Spacer() + + filterMenu + } + + if viewModel.selectedFilterOption != .all { + HStack(spacing: 8) { + Text("Filtered by:") + .foregroundStyle(Color(UIColor.systemGray)) + + HStack(spacing: 4) { + Text(viewModel.selectedFilterOption.localizedMenuTitle) + + Button { + viewModel.selectedFilterOption = .all + } label: { + Image(systemName: "xmark.circle.fill") + } + } + .padding(4) + .padding(.leading, 4) + .background(Color.accentColor.clipShape(Capsule())) + .foregroundStyle(Color(UIColor.systemBackground)) + } + .font(.subheadline) + } + } + .textCase(nil) + .padding(.bottom, 4) + } + + private var deliveryLog: some View { + ForEach(viewModel.logEventDisplays) { displayEvent in + switch displayEvent { + case .title(_, let title): + Text(title) + .padding(.vertical) + .frame(maxWidth: .infinity) + .background(Color(UIColor.systemGray5)) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) + case .event(let event): + ZStack { + InsulinDeliveryLogEventRow(event: event) + + if case let .pumpEvent(pumpEventType, doseEntry) = event.type, let doseEntry { + NavigationLink { + InsulinDeliveryEventDetailsView(pumpEventType: pumpEventType, doseEntry: doseEntry, onTapGesture: onTapGesture) + } label: { + EmptyView() + } + .opacity(0) + } + } + } + } + .alignmentGuide(.listRowSeparatorLeading) { _ in + return 0 + } + } + + var body: some View { + List { + switch viewModel.state { + case .loading: + ActivityIndicator(isAnimating: .constant(true), style: .default) + .frame(maxWidth: .infinity) + case .fetched(let data), .refreshing(let data): + Section { + InsulinDeliveryOverview( + state: data.insulinDeliveryState, + time: data.insulinDeliveryStateUpdatedDate, + currentBasalRate: data.currentBasalRate, + lastAutoBolus: data.lastAutoBolus + ) + } + + Section { + totalInsulinDeliveredLabel(from: data.totalInsulinDelivered) + } + case .error(let fetchError): + switch fetchError { + case .noBasalRateSchedule: // FIXME: Needed? + Text("No Basal Rate Schedule") + } + } + + Section { + deliveryLog + } header: { + deliveryLogHeader + } + } + .refreshable { + await viewModel.fetchData() + } + } +} diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift new file mode 100644 index 0000000000..3f62412b11 --- /dev/null +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift @@ -0,0 +1,433 @@ +// +// InsulinDeliveryLogEventRow.swift +// Loop +// +// Created by Cameron Ingham on 3/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +struct InsulinDeliveryLogEventRow: View { + + @Environment(\.colorPalette) private var colorPalette + + @ScaledMetric private var dateFontSize: Double = 14 + + private let rateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) + private let bolusFormatter = QuantityFormatter(for: .internationalUnit) + private let carbFormatter = QuantityFormatter(for: .gram) + + private let event: InsulinDeliveryLogEvent + + init(event: InsulinDeliveryLogEvent) { + self.event = event + } + + @ViewBuilder + var icon: some View { + switch event.type { + case .pumpEvent(.basal, _): + Image("basal-delivery-log") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + case .pumpEvent(.bolus(let bolusEventType, _, _), _): + Group { + switch bolusEventType { + case .automated: + Image("autobolus-delivery-log") + .resizable() + .scaledToFit() + default: + Image("bolus-delivery-log") + .resizable() + .scaledToFit() + } + } + .frame(width: 24, height: 24) + case .pumpEvent(.insulin(let insulinEventType), _): + Group { + switch insulinEventType { + case .suspended: + Image(systemName: "pause.circle.fill") + .resizable() + .foregroundStyle(colorPalette.guidanceColors.warning) + case .resumed: + Image(systemName: "play.circle") + .resizable() + .foregroundStyle(Color.accentColor) + } + } + .frame(width: 24, height: 24) + case .automation(let automationEventType): + Group { + switch automationEventType { + case .on: + Image("automation-on-delivery-log") + .resizable() + .scaledToFit() + case .off(let endDate): + if endDate == nil { + Image("automation-off-delivery-log") + .resizable() + .scaledToFit() + } else { + Image("automation-off-range-delivery-log") + .resizable() + .scaledToFit() + } + case .unavailable: + Image("automation-unavailable-delivery-log") + .resizable() + .scaledToFit() + } + } + .frame(width: 24, height: 24) + case .preset: + Image("presets") + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.presets) + .frame(width: 24, height: 24) + } + } + + @ViewBuilder + var title: some View { + switch event.type { + case .pumpEvent(let pumpEventType, _): + switch pumpEventType { + case .basal(let basalEventType, let rate): + switch basalEventType { + case .automationOn(let basalStatus): + switch basalStatus { + case .scheduled: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Basal: ") + Text(rateFormatter.string(from: rate, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(rate.unit.localizedUnitString(in: .short) ?? "U/hr") + + Text("Automated (Scheduled)") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .moreThanScheduled: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Basal: ") + Text(rateFormatter.string(from: rate, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(rate.unit.localizedUnitString(in: .short) ?? "U/hr") + + Text("Automated (↑ Increase)") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .lessThanScheduled: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Basal: ") + Text(rateFormatter.string(from: rate, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(rate.unit.localizedUnitString(in: .short) ?? "U/hr") + + Text("Automated (↓ Decrease)") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + } + case .automationOff: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Basal: ") + Text(rateFormatter.string(from: rate, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(rate.unit.localizedUnitString(in: .short) ?? "U/hr") + + Text("Scheduled") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .automatedPresetBasal: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Basal: ") + Text(rateFormatter.string(from: rate, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(rate.unit.localizedUnitString(in: .short) ?? "U/hr") + + Text("Automated (Preset Basal Rate)") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .manualTempBasal(let endDate): + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Temp Basal: ") + Text(rateFormatter.string(from: rate, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(rate.unit.localizedUnitString(in: .short) ?? "U") + + Text("Manual") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 0) { + Text(event.date.formatted(date: .omitted, time: .shortened)) + Text(" -") + Text(endDate.formatted(date: .omitted, time: .shortened)) + } + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + } + case .bolus(let bolusEventType, let programmedAmount, let deliveryAmount): + let programmedAmount = programmedAmount ?? deliveryAmount + + switch bolusEventType { + case .automated: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Bolus: ") + Text(bolusFormatter.string(from: deliveryAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(deliveryAmount.unit.localizedUnitString(in: .short) ?? "U") + + Text("Automated") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .meal(let recommendedAmount as LoopQuantity?, _, _), .correction(let recommendedAmount): + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + if deliveryAmount != programmedAmount { + Text("Bolus: ") + Text(bolusFormatter.string(from: deliveryAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(deliveryAmount.unit.localizedUnitString(in: .short) ?? "U") + Text(" of ") + Text(bolusFormatter.string(from: programmedAmount, includeUnit: false) ?? "Unknown") + Text(" ") + Text(programmedAmount.unit.localizedUnitString(in: .short) ?? "U") + } else { + Text("Bolus: ") + Text(bolusFormatter.string(from: deliveryAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(deliveryAmount.unit.localizedUnitString(in: .short) ?? "U") + } + + if let recommendedAmount { + Group { + Text("Recommended: ") + Text(bolusFormatter.string(from: recommendedAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(recommendedAmount.unit.localizedUnitString(in: .short) ?? "U") + } + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + } + case .insulin(let insulinEventType): + switch insulinEventType { + case .suspended: + HStack(spacing: 0) { + Text("Insulin Suspended") + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .resumed: + HStack(spacing: 0) { + Text("Insulin Resumed") + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + } + } + case .automation(let automationEventType): + switch automationEventType { + case .on: + HStack(spacing: 0) { + Text("Automation ON") + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .off(let endDate): + HStack(spacing: 0) { + Text("Automation OFF") + + Spacer() + + VStack(alignment: .trailing, spacing: 0) { + Text(event.date.formatted(date: .omitted, time: .shortened)) + Text(endDate != nil ? " -" : "") + + if let endDate { + Text(endDate.formatted(date: .omitted, time: .shortened)) + } + } + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .unavailable: + HStack(spacing: 0) { + Text("Automation unavailable") + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + } + case .preset(let presetEventType, _, _): + switch presetEventType { + case .enabled: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Preset Enabled") + + Text("Automation") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .disabled: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text("Preset Disabled") + + Text("Automation") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + } + } + } + + @ViewBuilder + var content: some View { + switch event.type { + case .pumpEvent(.basal, _): + EmptyView() + case .pumpEvent(.bolus(let bolusEventType, _, _), _): + switch bolusEventType { + case .automated, .correction: + EmptyView() + case .meal(_, let carbAmount, let emoji): + VStack(alignment: .leading, spacing: 8) { + Divider() + .padding(.trailing, -20) + + VStack(alignment: .leading, spacing: 2) { + Text("Meal Summary") + .font(.caption) + .foregroundStyle(.secondary) + + Group { + Text(carbFormatter.string(from: carbAmount) ?? "Unknown") + .foregroundStyle(colorPalette.carbTintColor) + + Text(" ") + + Text(emoji) + } + .font(.title2.weight(.semibold)) + } + } + } + case .pumpEvent(.insulin, _), .automation: + EmptyView() + case .preset(_, let icon, let name): + VStack(alignment: .leading, spacing: 8) { + Divider() + .padding(.trailing, -20) + + VStack(alignment: .leading, spacing: 2) { + Text("Preset Summary") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 6) { + switch icon { + case .emoji(let emoji): + Text(emoji) + case .image(let name, let iconColor): + Image(name) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(iconColor) + .frame(width: UIFontMetrics.default.scaledValue(for: 20), height: UIFontMetrics.default.scaledValue(for: 20)) + } + + Text(name) + .fontWeight(.semibold) + } + } + } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8){ + HStack(spacing: 12) { + icon + + title + } + + content + .padding(.leading, 36) + } + } +} + +#Preview { + InsulinDeliveryLogEventRow(event: InsulinDeliveryLogEvent(id: UUID().uuidString, type: .pumpEvent(.bolus(.correction(recommendedAmount: nil), programmedAmount: nil, deliveryAmount: LoopQuantity(unit: .internationalUnit, doubleValue: 5)), nil), date: Date())) + .environment(\.colorPalette, .default) +} diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift new file mode 100644 index 0000000000..f989c83d4f --- /dev/null +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -0,0 +1,548 @@ +// +// InsulinDeliveryLogViewModel.swift +// Loop +// +// Created by Cameron Ingham on 7/16/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit + +@MainActor +@Observable +class InsulinDeliveryLogViewModel { + + enum FilterOptions: Hashable, CaseIterable { + case userInitiated + case all + + var localizedMenuTitle: String { + switch self { + case .userInitiated: + NSLocalizedString("Self-Initiated Events", comment: "") + case .all: + NSLocalizedString("All Events", comment: "") + } + } + } + + enum LogEventDisplay: Hashable, Identifiable { + case title(id: UUID, String) + case event(InsulinDeliveryLogEvent) + + var id: Int { + hashValue + } + } + + struct DisplayData: Hashable { + let insulinDeliveryState: InsulinDeliveryOverview.State, insulinDeliveryStateUpdatedDate: Date, currentBasalRate: DatedQuantity, lastAutoBolus: DatedQuantity?, totalInsulinDelivered: LoopQuantity, events: [InsulinDeliveryLogEvent] + } + + enum State: Hashable { + enum FetchError: Error { + case noBasalRateSchedule + } + + case loading + case fetched(DisplayData) + case refreshing(DisplayData) + case error(FetchError) + } + + let totalDeliveredFormatter: QuantityFormatter = { + let formatter = QuantityFormatter(for: .internationalUnit) + + formatter.numberFormatter.maximumFractionDigits = 1 + + return formatter + }() + + private let loopDataManager: LoopDataManager + private let pumpManager: PumpManager? + + private(set) var state: State + + var selectedFilterOption: FilterOptions = .all + + var logEventDisplays: [LogEventDisplay] { + var displayEvents: [LogEventDisplay] = [] + + switch state { + case .fetched(let data), .refreshing(let data): + data.events.filter { + switch selectedFilterOption { + case .userInitiated: + switch $0.type { + case .automation, + .preset, + .pumpEvent(.basal(.manualTempBasal, rate: _), _), + .pumpEvent(.insulin, _), + .pumpEvent(.bolus(.correction, _, _), _), + .pumpEvent(.bolus(.meal, _, _), _): + return true + default: + return false + } + case .all: + return true + } + }.segmentItemsByHour().forEach { events in + displayEvents.append(.title(id: UUID(), "\(events.start.formatted(date: .omitted, time: .shortened)) - \(events.end.formatted(date: .omitted, time: .shortened))")) + events.events.forEach { event in + displayEvents.append(.event(event)) + } + } + case .loading, .error: + break + } + + return displayEvents + } + + var eventCount: Int { + logEventDisplays.filter { display in + switch display { + case .event: + return true + case .title: + return false + } + }.count + } + + private var doseStoreObserver: Any? { + willSet { + if let observer = doseStoreObserver { + NotificationCenter.default.removeObserver(observer) + } + } + } + + private var doseStore: DoseStore! { + didSet { + if let doseStore = doseStore { + doseStoreObserver = NotificationCenter.default.addObserver(forName: nil, object: doseStore, queue: OperationQueue.main, using: { [weak self] note in + + switch note.name { + case DoseStore.valuesDidChange: + Task { @MainActor in + await self?.fetchData() + } + default: + break + } + }) + } else { + doseStoreObserver = nil + } + } + } + + init( + loopDataManager: LoopDataManager, + pumpManager: PumpManager?, + initialState: State = .loading + ) { + self.loopDataManager = loopDataManager + self.pumpManager = pumpManager + self.state = initialState + + self.doseStore = (loopDataManager.doseStore as? DoseStore) + + Task { + await fetchData() + } + } + + func fetchData() async { + if case let .fetched(data) = state { + state = .refreshing(data) + } + + // fetch all events within the last 24hrs + let fetchedDate = Date() + let startDate = fetchedDate.addingTimeInterval(.days(-1)) + + guard let currentBasalRate = fetchCurrentBasal(startDate: startDate) else { + state = .error(.noBasalRateSchedule) + return + } + + let statusState = fetchStatusState() + let doses = await fetchDoses(since: startDate) + let lastAutoBolus = fetchLastAutoBolus(doses: doses) + let totalInsulinDelivered = await fetchTotalInsulinDeliveredToday() + + // map raw event data into delivery log events for display + var events = [InsulinDeliveryLogEvent]() + await handleDoseEvents(doses: doses, fetchedDate: fetchedDate, events: &events) + handleAutomationEvents(&events) + handlePresetEvents(startDate: startDate, &events) + + // update the state of delivery log with the fetched & mapped data + state = .fetched( + .init( + insulinDeliveryState: statusState, + insulinDeliveryStateUpdatedDate: fetchedDate, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus, + totalInsulinDelivered: totalInsulinDelivered, + events: events + ) + ) + } + + private func fetchStatusState() -> InsulinDeliveryOverview.State { + var insulinSuspended = false + if case .suspended = pumpManager?.status.basalDeliveryState { + insulinSuspended = true + } + + let automationEnabled = loopDataManager.automaticDosingStatus.automaticDosingEnabled + let automatedTreatmentState = pumpManager?.pumpManagerDelegate?.automatedTreatmentState ?? .neutralNoOverride + + if insulinSuspended { + return .error(status: .suspended) + } else if automationEnabled { + let basalStatus: InsulinDeliveryOverview.State.AutomatedBasalStatus + switch automatedTreatmentState { + case .neutralNoOverride, .neutralOverride: + basalStatus = .scheduled + case .increasedInsulin: + basalStatus = .moreThanScheduled + case .decreasedInsulin, .minimumDelivery: + basalStatus = .lessThanScheduled + } + + return .automationOn(basalStatus: basalStatus, preset: loopDataManager.temporaryPresetsManager.activePreset) + } else { + return .automationOff + } + } + + private func fetchCurrentBasal(startDate: Date) -> DatedQuantity? { + guard let basalRateSchedule = loopDataManager.temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory ?? loopDataManager.settings.basalRateSchedule else { + return nil + } + + let currentValue = basalRateSchedule.scheduleSegment(at: startDate) + return DatedQuantity(date: currentValue.startDate, quantity: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: currentValue.value)) + } + + private func fetchLastAutoBolus(doses: [DoseEntry]) -> DatedQuantity? { + guard let lastAutoBolusDose = doses.last(where: { $0.automatic == true }) else { + return nil + } + + return DatedQuantity(date: lastAutoBolusDose.startDate, quantity: LoopQuantity(unit: .internationalUnit, doubleValue: lastAutoBolusDose.deliveredUnits ?? lastAutoBolusDose.value)) + } + + private func fetchDoses(since startDate: Date) async -> [DoseEntry] { + (try? await loopDataManager.doseStore.getNormalizedDoseEntries(start: startDate, end: nil)) ?? [] + } + + private func fetchTotalInsulinDeliveredToday() async -> LoopQuantity { + await LoopQuantity(unit: .internationalUnit, doubleValue: loopDataManager.totalDeliveredToday()?.value ?? 0) + } + + private func handleBasalEvent(dose: DoseEntry, events: inout [InsulinDeliveryLogEvent]) async { + let automationEnabledDuringDose = loopDataManager.automationHistory.automationEnabled(at: dose.startDate) ?? loopDataManager.automaticDosingStatus.automaticDosingEnabled + + if dose.type == .tempBasal && dose.automatic == false { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .manualTempBasal(endDate: dose.endDate), + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + } else if automationEnabledDuringDose { + if let decision = await dose.dosingDecision(from: loopDataManager.dosingDecisionStore) { + if decision.scheduleOverride != nil { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .automatedPresetBasal, + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + } else { + if let direction = decision.automaticDoseRecommendation?.direction { + switch direction { + case .decrease: + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .automationOn(basalStatus: .lessThanScheduled), + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + case .neutral: + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .automationOn(basalStatus: .scheduled), + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + case .increase: + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .automationOn(basalStatus: .moreThanScheduled), + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + } + } else { + fatalError("No `decision.automaticDoseRecommendation`") + } + } + } else if let scheduledBasalRate = dose.scheduledBasalRate, scheduledBasalRate.doubleValue(for: .internationalUnitsPerHour) == dose.value { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .automationOn(basalStatus: .scheduled), + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + } else { + fatalError("No `decision` or `scheduledBasalRate`") + } + } else { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .basal( + .automationOff, + rate: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: dose.unitsPerHour + ) + ), + dose + ), + date: dose.startDate + ) + ) + } + } + + private func handleBolusEvents(dose: DoseEntry, events: inout [InsulinDeliveryLogEvent]) async { + let decision = await dose.dosingDecision(from: loopDataManager.dosingDecisionStore) + + if dose.automatic == true { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .bolus( + .automated, + programmedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.programmedUnits + ), + deliveryAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.deliveredUnits ?? dose.programmedUnits + ) + ), + dose + ), + date: dose.startDate + ) + ) + } else { + if let recommendedUnits = decision?.manualBolusRecommendation?.recommendation.amount { + if let carbEntry = decision?.carbEntry { + events.append( + InsulinDeliveryLogEvent( + id: decision?.syncIdentifier.uuidString ?? UUID().uuidString, + type: .pumpEvent( + .bolus( + .meal( + recommendedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: recommendedUnits + ), + carbAmount: LoopQuantity( + unit: .gram, + doubleValue: carbEntry.amount + ), + emoji: carbEntry.foodType ?? "" + ), + programmedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: decision?.manualBolusRequested ?? 0 + ), + deliveryAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.deliveredUnits ?? dose.programmedUnits + ) + ), + dose + ), + date: dose.startDate + ) + ) + } else { + events.append( + InsulinDeliveryLogEvent( + id: decision?.syncIdentifier.uuidString ?? UUID().uuidString, + type: .pumpEvent( + .bolus( + .correction( + recommendedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: recommendedUnits + ) + ), + programmedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: decision?.manualBolusRequested ?? 0 + ), + deliveryAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.deliveredUnits ?? dose.programmedUnits + ) + ), + dose + ), + date: dose.startDate + ) + ) + } + } else { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .bolus( + .correction(recommendedAmount: nil), + programmedAmount: nil, + deliveryAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.deliveredUnits ?? dose.programmedUnits + ) + ), + dose + ), + date: dose.startDate + ) + ) + } + } + } + + private func handleDoseEvents(doses: [DoseEntry], fetchedDate: Date, events: inout [InsulinDeliveryLogEvent]) async { + for dose in doses { + switch dose.type { + case .basal, .tempBasal: + await handleBasalEvent(dose: dose, events: &events) + case .bolus: + await handleBolusEvents(dose: dose, events: &events) + case .resume, .suspend: + handleSuspendResumeEvents(dose: dose, fetchedDate: fetchedDate, events: &events) + } + } + } + + private func handleSuspendResumeEvents(dose: DoseEntry, fetchedDate: Date, events: inout [InsulinDeliveryLogEvent]) { + guard dose.type == .suspend else { return } + + events.append(InsulinDeliveryLogEvent(id: dose.syncIdentifier ?? UUID().uuidString, type: .pumpEvent(.insulin(.suspended), dose), date: dose.startDate)) + + if !dose.isMutable || dose.endDate <= fetchedDate { + events.append(InsulinDeliveryLogEvent(id: dose.syncIdentifier ?? UUID().uuidString, type: .pumpEvent(.insulin(.resumed), dose), date: dose.endDate)) + } + } + + private func handleAutomationEvents(_ events: inout [InsulinDeliveryLogEvent]) { + loopDataManager.automationHistory.forEach { event in + if event.enabled { + events.append(InsulinDeliveryLogEvent(id: String(event.hashValue), type: .automation(.on), date: event.startDate)) + } else { + events.append(InsulinDeliveryLogEvent(id: String(event.hashValue), type: .automation(.off(endDate: nil)), date: event.startDate)) + } + } + } + + private func handlePresetEvents(startDate: Date, _ events: inout [InsulinDeliveryLogEvent]) { + loopDataManager.temporaryPresetsManager.presetHistory.recentEvents.filter({ $0.override.actualEndDate >= startDate }).forEach { event in + if let preset = loopDataManager.temporaryPresetsManager.selectablePresets.first(where: { $0.id == event.override.presetId }) { + events.append(InsulinDeliveryLogEvent(id: String(event.hashValue), type: .preset(.enabled, icon: preset.icon, name: preset.name), date: event.override.startDate)) + + if event.override.hasFinished() { + events.append(InsulinDeliveryLogEvent(id: String(event.hashValue), type: .preset(.disabled, icon: preset.icon, name: preset.name), date: event.override.actualEndDate)) + } + } + } + } +} + +private extension DoseEntry { + func dosingDecision(from store: DosingDecisionStoreProtocol) async -> StoredDosingDecision? { + if let decisionId = decisionId { + return try? await store.findDosingDecisionsById(decisionId) + } else { + return nil + } + } +} diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift new file mode 100644 index 0000000000..d6c2093699 --- /dev/null +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift @@ -0,0 +1,361 @@ +// +// InsulinDeliveryOverview.swift +// Loop +// +// Created by Cameron Ingham on 3/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import SwiftUI + +struct DatedQuantity: Hashable { + let date: Date + let quantity: LoopQuantity +} + +struct InsulinDeliveryOverview: View { + enum State: Hashable { + enum AutomatedBasalStatus: Hashable { + case scheduled + case moreThanScheduled + case lessThanScheduled + } + + case automationOn(basalStatus: AutomatedBasalStatus, preset: SelectablePreset?) + + case automationOff + + enum ErrorStatus: Hashable { + case noDelivery + case suspended + } + + case error(status: ErrorStatus) + } + + @Environment(\.colorPalette) private var colorPalette + + @ScaledMetric private var iconSize: Double = 26 + + private let rateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) + private let bolusFormatter = QuantityFormatter(for: .internationalUnit) + + private let state: State + private let time: Date + private let currentBasalRate: DatedQuantity + private let lastAutoBolus: DatedQuantity? + + init(state: State, time: Date, currentBasalRate: DatedQuantity, lastAutoBolus: DatedQuantity?) { + self.state = state + self.time = time + self.currentBasalRate = currentBasalRate + self.lastAutoBolus = lastAutoBolus + } + + @ViewBuilder + var icon: some View { + VStack { + switch state { + case .automationOn(let basalStatus, _): + VStack { + switch basalStatus { + case .scheduled: + Text(Image(systemName: "arrow.right.square.fill")) + case .moreThanScheduled: + Text(Image(systemName: "arrow.up.square.fill")) + case .lessThanScheduled: + Text(Image(systemName: "arrow.down.square.fill")) + } + } + .foregroundStyle(Color.accentColor) + case .automationOff: + Text(Image(systemName: "arrow.right.square.fill")) + .foregroundStyle(Color.accentColor) + case .error(let status): + VStack { + switch status { + case .noDelivery: + Text(Image(systemName: "xmark.circle.fill")) + .foregroundStyle(colorPalette.guidanceColors.critical) + case .suspended: + Text(Image(systemName: "pause.circle.fill")) + .foregroundStyle(colorPalette.guidanceColors.warning) + } + } + } + } + .font(.system(size: iconSize)) + } + + var statusTitle: Text { + switch state { + case .automationOn(let basalStatus, _): + switch basalStatus { + case .scheduled: + Text("Scheduled basal") + case .moreThanScheduled: + Text("More than scheduled") + case .lessThanScheduled: + Text("Less than scheduled") + } + case .automationOff: + Text("Scheduled basal") + case .error(let status): + switch status { + case .noDelivery: + Text("No Delivery") + case .suspended: + Text("Insulin Suspended") + } + } + } + + var statusSubtitle: Text? { + switch state { + case .automationOn(let basalStatus, let preset): + if let preset, preset.insulinNeedsScaleFactor != 1.0 { + switch basalStatus { + case .scheduled: + Text("A preset with \(preset.insulinNeedsScaleFactor.formatted(.percent)) overall insulin is on. This is your new preset baseline and it overrides your Scheduled Basal.") + case .moreThanScheduled: + Text("A preset with \(preset.insulinNeedsScaleFactor.formatted(.percent)) overall insulin is on. The system is currently delivering more than your preset baseline.") + case .lessThanScheduled: + Text("A preset with \(preset.insulinNeedsScaleFactor.formatted(.percent)) overall insulin is on. The system is currently delivering less than your preset baseline.") + } + } else if basalStatus == .moreThanScheduled { + Text("Includes basal and automated boluses") + } else { + nil + } + default: + nil + } + } + + private var errorAdjustedBasalRate: LoopQuantity { + if case .error = state { + return LoopQuantity(unit: currentBasalRate.quantity.unit, doubleValue: 0) + } else { + return currentBasalRate.quantity + } + } + + private var currentBasalRateForegroundColor: Color { + switch state { + case .error: + return .secondary + default: + return .primary + } + } + + var currentBasalRateSection: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Current Basal Rate") + + Group { + Text(rateFormatter.string(from: errorAdjustedBasalRate, includeUnit: false) ?? "Unknown").fontWeight(.semibold) + Text(" ") + Text(errorAdjustedBasalRate.unit.localizedUnitString(in: .short) ?? "U/hr") + } + .font(.title2) + + Text("since \(currentBasalRate.date.formatted(date: .omitted, time: .shortened))") + .font(.footnote) + .foregroundStyle(.secondary) + } + .foregroundStyle(currentBasalRateForegroundColor) + } + + private var lastAutoBolusForegroundColor: Color { + guard lastAutoBolus != nil else { + return .secondary + } + + switch state { + case .automationOff, .error: + return .secondary + default: + return .primary + } + } + + private var isAutomationOff: Bool { + if case .automationOff = state { + return true + } else { + return false + } + } + + var lastAutoBolusSection: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Last Auto Bolus") + + Group { + if let lastAutoBolus, !isAutomationOff { + Text(bolusFormatter.string(from: lastAutoBolus.quantity, includeUnit: false) ?? "Unknown").fontWeight(.semibold) + Text(" ") + Text(lastAutoBolus.quantity.unit.localizedUnitString(in: .short) ?? "U") + } else { + Text("-.--") + Text(" ") + Text(LoopUnit.internationalUnit.localizedUnitString(in: .short) ?? "U") + } + } + .font(.title2) + + Group { + if state == .automationOff { + Text("Automation is off") + .italic() + } else if let lastAutoBolus { + Text("at \(lastAutoBolus.date.formatted(date: .omitted, time: .shortened))") + } else { + Text("None in last 24 hours") + } + } + .font(.footnote) + .foregroundStyle(.secondary) + } + .foregroundStyle(lastAutoBolusForegroundColor) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Current Delivery") + + HStack(spacing: 4) { + icon + + statusTitle + .font(.title3.weight(.heavy)) + } + + if let statusSubtitle { + statusSubtitle + .font(.caption.italic()) + .foregroundStyle(.secondary) + } + + Text("at \(time.formatted(date: .omitted, time: .shortened))") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Divider() + + ViewThatFits { + HStack(spacing: 0) { + currentBasalRateSection + + Spacer() + + lastAutoBolusSection + } + } + } + } +} + +let time = Date() +let currentBasalRate = DatedQuantity(date: Date(), quantity: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 0.5)) +let lastAutoBolus = DatedQuantity(date: Date().addingTimeInterval(-57600), quantity: LoopQuantity(unit: .internationalUnit, doubleValue: 0.05)) + +let preset = SelectablePreset.custom(TemporaryPreset(symbol: "🏃", name: "Running", settings: .init(targetRange: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 80)...LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), insulinNeedsScaleFactor: 0.5), duration: .indefinite)) + +#Preview("Automated Delivery (scheduled)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOn(basalStatus: .scheduled, preset: nil), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Automated Delivery (less than scheduled)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOn(basalStatus: .lessThanScheduled, preset: nil), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Automated Delivery (more than scheduled)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOn(basalStatus: .moreThanScheduled, preset: nil), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: nil + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Preset (scheduled)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOn(basalStatus: .scheduled, preset: preset), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Preset (less than scheduled)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOn(basalStatus: .lessThanScheduled, preset: preset), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Preset (more than scheduled)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOn(basalStatus: .moreThanScheduled, preset: preset), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Automation OFF", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .automationOff, + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: nil + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Error (No Delivery)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .error(status: .noDelivery), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} + +#Preview("Error (Suspended)", traits: .sizeThatFitsLayout) { + InsulinDeliveryOverview( + state: .error(status: .suspended), + time: time, + currentBasalRate: currentBasalRate, + lastAutoBolus: lastAutoBolus + ) + .environment(\.guidanceColors, .default) + .padding() +} From bd31ab0fa163d227f1991e36350ad35401286998 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 29 Jul 2025 09:13:40 -0500 Subject: [PATCH 268/421] LOOP-5235 Enable scheduled presets (#808) * Enable scheduled presets * Updates for async alert issuing * async updates * starting presets from notifications * Forward notification actions from watch to phone * Playback alerts after views are set up * Add reminder icon * Scheduled preset editing fix. Display updated forecast when turning on/off presets * Fix tests * Fix tests * fix warnings * Fix warnings * Cleanup prints --- .../Models/NotificationActionSelection.swift | 47 + Common/Models/PumpManagerUI.swift | 1 + Loop.xcodeproj/project.pbxproj | 10 +- Loop/Managers/AlertPermissionsChecker.swift | 50 +- Loop/Managers/Alerts/AlertManager.swift | 506 ++++---- Loop/Managers/Alerts/AlertStore.swift | 355 +++--- .../Alerts/InAppModalAlertScheduler.swift | 69 +- .../UserNotificationAlertScheduler.swift | 22 +- .../DeliveryUncertaintyAlertManager.swift | 24 +- Loop/Managers/DeviceDataManager.swift | 82 +- Loop/Managers/LoopAppManager.swift | 161 ++- Loop/Managers/LoopDataManager.swift | 1 + Loop/Managers/NotificationManager.swift | 23 +- Loop/Managers/RemoteDataServicesManager.swift | 22 +- Loop/Managers/ResetLoopManager.swift | 63 +- Loop/Managers/ServicesManager.swift | 12 +- Loop/Managers/SettingsManager.swift | 2 +- Loop/Managers/SupportManager.swift | 10 +- Loop/Managers/TemporaryPresetsManager.swift | 105 +- Loop/Managers/TrustedTimeChecker.swift | 8 +- Loop/Managers/WatchDataManager.swift | 176 +-- Loop/Models/CrashRecoveryManager.swift | 6 +- Loop/Models/SelectablePreset.swift | 13 + Loop/Plugins/PluginManager.swift | 1 + .../StatusTableViewController.swift | 6 +- Loop/View Models/SettingsViewModel.swift | 1 + .../Views/Presets/Components/PresetCard.swift | 20 +- .../Presets/Components/PresetDetentView.swift | 13 +- .../Presets/Components/PresetStatsView.swift | 10 +- Loop/Views/Presets/CreatePresetView.swift | 3 + Loop/Views/Presets/EditPresetView.swift | 10 +- Loop/Views/Presets/PresetsView.swift | 10 +- .../Managers/Alerts/AlertManagerTests.swift | 376 +++--- .../Managers/Alerts/AlertStoreTests.swift | 1096 ++++++----------- .../InAppModalAlertSchedulerTests.swift | 85 +- .../Managers/Alerts/StoredAlertTests.swift | 4 +- .../Managers/DeviceDataManagerTests.swift | 4 +- LoopTests/Mock Stores/HKHealthStoreMock.swift | 2 +- LoopTests/Mocks/AlertMocks.swift | 54 +- LoopTests/Mocks/MockCGMManager.swift | 4 +- LoopTests/Mocks/MockPumpManager.swift | 3 +- .../ViewModels/BolusEntryViewModelTests.swift | 4 +- WatchApp Extension/ExtensionDelegate.swift | 10 +- WatchApp Extension/Extensions/WCSession.swift | 14 + .../Managers/LoopDataManager.swift | 8 + 45 files changed, 1706 insertions(+), 1800 deletions(-) create mode 100644 Common/Models/NotificationActionSelection.swift diff --git a/Common/Models/NotificationActionSelection.swift b/Common/Models/NotificationActionSelection.swift new file mode 100644 index 0000000000..6edae29309 --- /dev/null +++ b/Common/Models/NotificationActionSelection.swift @@ -0,0 +1,47 @@ +// +// NotificationActionSelection.swift +// Loop +// +// Created by Pete Schwamb on 7/16/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +struct NotificationActionSelection { + let version = 1 + let alertIdentifier: String + let managerIdentifier: String + let actionIdentifier: String +} + +extension NotificationActionSelection: RawRepresentable { + typealias RawValue = [String: Any] + + static let name = "NotificationActionSelection" + + init?(rawValue: RawValue) { + guard + rawValue["v"] as? Int == version, + rawValue["name"] as? String == NotificationActionSelection.name, + let alertIdentifier = rawValue["alertIdentifier"] as? String, + let managerIdentifier = rawValue["managerIdentifier"] as? String, + let actionIdentifier = rawValue["actionIdentifier"] as? String + else { + return nil + } + + self.alertIdentifier = alertIdentifier + self.managerIdentifier = managerIdentifier + self.actionIdentifier = actionIdentifier + } + + var rawValue: RawValue { + return [ + "v": version, + "name": NotificationActionSelection.name, + "alertIdentifier": alertIdentifier, + "managerIdentifier": managerIdentifier, + "actionIdentifier": actionIdentifier, + ] + } +} diff --git a/Common/Models/PumpManagerUI.swift b/Common/Models/PumpManagerUI.swift index e9250d4939..465d2dfd7c 100644 --- a/Common/Models/PumpManagerUI.swift +++ b/Common/Models/PumpManagerUI.swift @@ -12,6 +12,7 @@ import LoopKitUI typealias PumpManagerHUDViewRawValue = [String: Any] +@MainActor func PumpManagerHUDViewFromRawValue(_ rawValue: PumpManagerHUDViewRawValue, pluginManager: PluginManager) -> BaseHUDView? { guard let identifier = rawValue["managerIdentifier"] as? String, diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 9b7477e9f6..698ae49860 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -505,6 +505,8 @@ 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 */; }; C19F48742560ABFB003632D7 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; + C1ABA1612E281D470049DF41 /* NotificationActionSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */; }; + C1ABA1622E281D470049DF41 /* NotificationActionSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */; }; C1AC03962D6E07D6004D4D2B /* CreatePresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC03952D6E07D3004D4D2B /* CreatePresetView.swift */; }; C1AC039A2D6E3C88004D4D2B /* InsulinScaleInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC03992D6E3C7B004D4D2B /* InsulinScaleInformationView.swift */; }; C1AC039C2D6E7551004D4D2B /* ExistingPresetRangeEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC039B2D6E751C004D4D2B /* ExistingPresetRangeEdit.swift */; }; @@ -1509,6 +1511,7 @@ 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 = ""; }; C19F496225630504003632D7 /* Minizip.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Minizip.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationActionSelection.swift; sourceTree = ""; }; C1AC03952D6E07D3004D4D2B /* CreatePresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePresetView.swift; sourceTree = ""; }; C1AC03992D6E3C7B004D4D2B /* InsulinScaleInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinScaleInformationView.swift; sourceTree = ""; }; C1AC039B2D6E751C004D4D2B /* ExistingPresetRangeEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExistingPresetRangeEdit.swift; sourceTree = ""; }; @@ -2451,10 +2454,13 @@ 4FF4D0FB1E1834C400846527 /* Models */ = { isa = PBXGroup; children = ( + C110888C2A3913C600BA4898 /* BuildDetails.swift */, 89F9119324358E4500ECCAF3 /* CarbAbsorptionTime.swift */, A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */, 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */, + E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */, 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */, + C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */, 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */, C1FB428E217921D600FAB378 /* PumpManagerUI.swift */, 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */, @@ -2465,8 +2471,6 @@ A9347F2E24E7508A00C99C34 /* WatchHistoricalCarbs.swift */, 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */, 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */, - E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */, - C110888C2A3913C600BA4898 /* BuildDetails.swift */, ); path = Models; sourceTree = ""; @@ -3756,6 +3760,7 @@ E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, C10509612D7B3DF400118A37 /* CardSectionScrollView.swift in Sources */, + C1ABA1622E281D470049DF41 /* NotificationActionSelection.swift in Sources */, C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, C10509772D8B591200118A37 /* StatusTableViewModel.swift in Sources */, 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */, @@ -3843,6 +3848,7 @@ files = ( 894F6DDD243C0A2300CCE676 /* CarbAmountLabel.swift in Sources */, B455C7352BD14E30002B847E /* Comparable.swift in Sources */, + C1ABA1612E281D470049DF41 /* NotificationActionSelection.swift in Sources */, 89A605E524327F45009C1096 /* DoseVolumeInput.swift in Sources */, 4372E488213C862B0068E043 /* SampleValue.swift in Sources */, 89A605EB243288E4009C1096 /* TopDownTriangle.swift in Sources */, diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index f2bd2a7cee..211d498123 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -11,6 +11,7 @@ import Combine import LoopKit import SwiftUI +@MainActor protocol AlertPermissionsCheckerDelegate: AnyObject { func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool, permissions: NotificationCenterSettingsFlags) } @@ -84,12 +85,7 @@ public class AlertPermissionsChecker: ObservableObject { } static func gotoSettings() { - // TODO with iOS 16 this API changes to UIApplication.openNotificationSettingsURLString - if #available(iOS 15.4, *) { - UIApplication.shared.open(URL(string: UIApplicationOpenNotificationSettingsURLString)!) - } else { - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) - } + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) } } @@ -238,11 +234,11 @@ extension AlertPermissionsChecker { } } - static func constructUnsafeNotificationPermissionsInAppAlert(alert: UnsafeNotificationPermissionAlert, acknowledgementCompletion: @escaping () -> Void ) -> UIAlertController { + static func constructUnsafeNotificationPermissionsInAppAlert(alert: UnsafeNotificationPermissionAlert) async -> UIAlertController { dispatchPrecondition(condition: .onQueue(.main)) - let alertController = UIAlertController(title: alert.alertTitle, - message: alert.alertBody, - preferredStyle: .alert) + let alertController = await 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) @@ -250,17 +246,21 @@ extension AlertPermissionsChecker { titleWithImage.append(NSMutableAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: 8)])) titleWithImage.append(NSMutableAttributedString(string: alert.alertTitle, attributes: [.font: UIFont.preferredFont(forTextStyle: .headline)])) alertController.setValue(titleWithImage, forKey: "attributedTitle") - - alertController.addAction(UIAlertAction(title: NSLocalizedString("Settings", comment: "Label of button that navigation user to iOS Settings"), - style: .default, - handler: { _ in - AlertPermissionsChecker.gotoSettings() - acknowledgementCompletion() - })) - alertController.addAction(UIAlertAction(title: NSLocalizedString("Close", comment: "The button label of the action used to dismiss the unsafe notification permission alert"), - style: .cancel, - handler: { _ in acknowledgementCompletion() - })) + + await withCheckedContinuation { continuation in + alertController.addAction(UIAlertAction(title: NSLocalizedString("Settings", comment: "Label of button that navigation user to iOS Settings"), + style: .default, + handler: { _ in + AlertPermissionsChecker.gotoSettings() + continuation.resume() + })) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Close", comment: "The button label of the action used to dismiss the unsafe notification permission alert"), + style: .cancel, + handler: { _ in + continuation.resume() + })) + } + return alertController } @@ -285,7 +285,13 @@ extension AlertPermissionsChecker { trigger: .immediate) private func notificationCenterSettingsChanged(_ newValue: NotificationCenterSettingsFlags) { - delegate?.notificationsPermissions(requiresRiskMitigation: newValue.requiresRiskMitigation, scheduledDeliveryEnabled: newValue.scheduledDeliveryEnabled, permissions: newValue) + Task { + await 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 e30acf9d69..970bfa159b 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -12,7 +12,9 @@ import Combine protocol AlertManagerResponder: AnyObject { /// Method for our Handlers to call to kick off alert response. Differs from AlertResponder because here we need the whole `Identifier`. - func acknowledgeAlert(identifier: Alert.Identifier) + @MainActor + func acknowledgeAlert(identifier: Alert.Identifier) async throws + func userDidSelectAction(alertIdentifier: Alert.Identifier, actionIdentifier: String) async throws } public enum AlertUserNotificationUserInfoKey: String { @@ -26,6 +28,7 @@ public enum AlertUserNotificationUserInfoKey: String { /// - etc. @MainActor public final class AlertManager { + nonisolated private static let soundsDirectoryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).last!.appendingPathComponent("Sounds") private let log = DiagnosticLog(category: "AlertManager") @@ -94,7 +97,7 @@ public final class AlertManager { .sink { [weak self] publisher in if let loopDataManager = publisher.object as? LoopDataManager { Task { @MainActor in - self?.loopDidComplete(loopDataManager.lastLoopCompleted) + await self?.loopDidComplete(loopDataManager.lastLoopCompleted) } } } @@ -134,12 +137,16 @@ public final class AlertManager { let content = Alert.Content(title: title, body: body, acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal")) - issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate)) + Task { + await issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate)) + } } private func onBluetoothPoweredOn() { log.default("Bluetooth powered on") - retractAlert(identifier: bluetoothPoweredOffIdentifier) + Task { + await retractAlert(identifier: bluetoothPoweredOffIdentifier) + } } private func onBluetoothPoweredOff() { @@ -153,31 +160,35 @@ public final class AlertManager { let fgcontent = Alert.Content(title: title, body: fgBody, acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal")) - issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, - foregroundContent: fgcontent, - backgroundContent: bgcontent, - trigger: .immediate, - interruptionLevel: .critical)) + Task { + await issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, + foregroundContent: fgcontent, + backgroundContent: bgcontent, + trigger: .immediate, + interruptionLevel: .critical)) + } } // MARK: - Loop Not Running alerts - func loopDidComplete(_ lastLoopDate: Date? = nil) { + func loopDidComplete(_ lastLoopDate: Date? = nil) async { // use now if there is no lastLoopDate - rescheduleLoopNotRunningNotifications(lastLoopDate ?? Date()) + await rescheduleLoopNotRunningNotifications(lastLoopDate ?? Date()) } private func rescheduleLoopNotRunningNotifications() { - guard let lastLoopDate = getLastLoopDate() else { return } - rescheduleLoopNotRunningNotifications(lastLoopDate) + Task { + guard let lastLoopDate = getLastLoopDate() else { return } + await rescheduleLoopNotRunningNotifications(lastLoopDate) + } } - func rescheduleLoopNotRunningNotifications(_ lastLoopDate: Date) { - clearLoopNotRunningNotifications() - scheduleLoopNotRunningNotifications(lastLoopDate) + func rescheduleLoopNotRunningNotifications(_ lastLoopDate: Date) async { + await clearLoopNotRunningNotifications() + await scheduleLoopNotRunningNotifications(lastLoopDate) } - func scheduleLoopNotRunningNotifications(_ lastLoopDate: Date) { + func scheduleLoopNotRunningNotifications(_ lastLoopDate: Date) async { // Give a little extra time for a loop-in-progress to complete let gracePeriod = TimeInterval(minutes: 0.5) @@ -234,12 +245,16 @@ public final class AlertManager { isCritical: isCritical) scheduledNotifications.append(scheduledNotification) } - UNUserNotificationCenter.current().add(request) + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + self.log.error("Error scheduling loop not running notification %{public}@", String(describing: error)) + } } UserDefaults.appGroup?.loopNotRunningNotifications = scheduledNotifications } - func inferDeliveredLoopNotRunningNotifications() { + func inferDeliveredLoopNotRunningNotifications() async { // Infer that any past alerts have been delivered at this point let now = getCurrentDate() var stillPendingNotifications = [StoredLoopNotRunningNotification]() @@ -249,7 +264,7 @@ public final class AlertManager { let content = Alert.Content(title: notification.title, body: notification.body, acknowledgeActionButtonLabel: "ios-notification-default") let interruptionLevel: Alert.InterruptionLevel = notification.isCritical ? .critical : .timeSensitive let alert = Alert(identifier: alertIdentifier, foregroundContent: nil, backgroundContent: content, trigger: .immediate, interruptionLevel: interruptionLevel) - recordIssued(alert: alert, at: notification.alertAt) + await recordIssued(alert: alert, at: notification.alertAt) } else { stillPendingNotifications.append(notification) } @@ -257,19 +272,18 @@ public final class AlertManager { UserDefaults.appGroup?.loopNotRunningNotifications = stillPendingNotifications } - func clearLoopNotRunningNotifications() { - inferDeliveredLoopNotRunningNotifications() + func clearLoopNotRunningNotifications() async { + await inferDeliveredLoopNotRunningNotifications() // Clear out any existing not-running notifications - UNUserNotificationCenter.current().getDeliveredNotifications { (notifications) in - let loopNotRunningIdentifiers = notifications.filter({ - $0.request.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue - }).map({ - $0.request.identifier - }) + let notifications = await UNUserNotificationCenter.current().deliveredNotifications() + let loopNotRunningIdentifiers = notifications.filter({ + $0.request.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue + }).map({ + $0.request.identifier + }) - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: loopNotRunningIdentifiers) - } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: loopNotRunningIdentifiers) } private func getLastLoopDate() -> Date? { @@ -278,11 +292,15 @@ public final class AlertManager { // MARK: - Workout reminder private func scheduleWorkoutOverrideReminder() { - issueAlert(workoutOverrideReminderAlert) + Task { + await issueAlert(workoutOverrideReminderAlert) + } } private func retractWorkoutOverrideReminder() { - retractAlert(identifier: AlertManager.workoutOverrideReminderAlertIdentifier) + Task { + await retractAlert(identifier: AlertManager.workoutOverrideReminderAlertIdentifier) + } } static var workoutOverrideReminderAlertIdentifier: Alert.Identifier { @@ -307,52 +325,73 @@ public final class AlertManager { UserDefaults.standard.alertMuterConfiguration = newValue rescheduleLoopNotRunningNotifications() - lookupAllPendingDelayedOrRepeatingAlerts() { [weak self] result in - switch result { - case .success(let persistedAlerts): + Task { + do { + let persistedAlerts = try await lookupAllPendingDelayedOrRepeatingAlerts() for persistedAlert in persistedAlerts { - self?.rescheduleAlertWithSchedulers(persistedAlert.alert, issuedDate: persistedAlert.issuedDate) + await self.rescheduleAlertWithSchedulers(persistedAlert.alert, issuedDate: persistedAlert.issuedDate) } - case .failure(let error): - self?.log.error("error looking up all delayed or repeating alerts: %{public}@", String(describing: error)) + } catch { + self.log.error("error looking up all delayed or repeating alerts: %{public}@", String(describing: error)) } } } + } // MARK: AlertManagerResponder implementation extension AlertManager: AlertManagerResponder { - func acknowledgeAlert(identifier: Alert.Identifier) { - if let responder = responders[identifier.managerIdentifier]?.value { - responder.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier) { (error) in - if let error = error { - self.presentAcknowledgementFailedAlert(error: error) + func userDidSelectAction(alertIdentifier: Alert.Identifier, actionIdentifier: String) async throws { + if let responder = responders[alertIdentifier.managerIdentifier]?.value { + do { + let storedAlert = try await alertStore.lookupAllMatching(identifier: alertIdentifier, limit: 1).first + + if let storedAlert, + let alert = try? Alert(from: storedAlert, adjustedForStorageTime: false) + { + try await responder.handleAlertAction(actionIdentifier: actionIdentifier, from: alert) + } else { + log.error("Unable to get preset name from stored alert: %{public}@", String(describing: storedAlert)) } + } catch { + log.error("Unable to fetch alert for preset action: %{public}@, ${public}@", String(describing: alertIdentifier), String(describing: error)) } } - userNotificationAlertScheduler.acknowledgeAlert(identifier: identifier) - alertStore.recordAcknowledgement(of: identifier) + + try await acknowledgeAlert(identifier: alertIdentifier); } - func presentAcknowledgementFailedAlert(error: Error) { - DispatchQueue.main.async { - let message: String - if let localizedError = error as? LocalizedError { - message = [localizedError.localizedDescription, localizedError.recoverySuggestion].compactMap({$0}).joined(separator: "\n\n") - } else { - message = String(format: NSLocalizedString("%1$@ is unable to clear the alert from your device", comment: "Message for alert shown when alert acknowledgement fails for a device, and the device does not provide a LocalizedError. (1: app name)"), Bundle.main.bundleDisplayName) + func acknowledgeAlert(identifier: Alert.Identifier) async throws { + if let responder = responders[identifier.managerIdentifier]?.value { + do { + try await responder.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier) + } catch { + await self.presentAcknowledgementFailedAlert(error: error) } - self.log.info("Alert acknowledgement failed: %{public}@", message) + } + userNotificationAlertScheduler.alertWasAcknowledged(identifier: identifier) + await modalAlertScheduler.removePresentedAlert(identifier: identifier) + try await alertStore.recordAcknowledgement(of: identifier) + } - let alert = UIAlertController( - title: NSLocalizedString("Unable To Clear Alert", comment: "Title for alert shown when alert acknowledgement fails"), - message: message, - preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Default action for alert when alert acknowledgment fails"), style: .default)) - - self.alertPresenter.present(alert, animated: true) + + func presentAcknowledgementFailedAlert(error: Error) async { + let message: String + if let localizedError = error as? LocalizedError { + message = [localizedError.localizedDescription, localizedError.recoverySuggestion].compactMap({$0}).joined(separator: "\n\n") + } else { + message = String(format: NSLocalizedString("%1$@ is unable to clear the alert from your device", comment: "Message for alert shown when alert acknowledgement fails for a device, and the device does not provide a LocalizedError. (1: app name)"), Bundle.main.bundleDisplayName) } + self.log.info("Alert acknowledgement failed: %{public}@", message) + + let alert = UIAlertController( + title: NSLocalizedString("Unable To Clear Alert", comment: "Title for alert shown when alert acknowledgement fails"), + message: message, + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Default action for alert when alert acknowledgment fails"), style: .default)) + + await self.alertPresenter.present(alert, animated: true) } } @@ -360,23 +399,27 @@ extension AlertManager: AlertManagerResponder { extension AlertManager: AlertIssuer { - public func issueAlert(_ alert: Alert) { + public func issueAlert(_ alert: Alert) async { guard playbackFinished else { deferredAlerts.append(alert) return } analyticsServicesManager.didIssueAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel) scheduleAlertWithSchedulers(alert) - alertStore.recordIssued(alert: alert) + await alertStore.recordIssued(alert: alert) } - public func retractAlert(identifier: Alert.Identifier) { + public func retractAlert(identifier: Alert.Identifier) async { guard playbackFinished else { deferredRetractions.append(identifier) return } - unscheduleAlertWithSchedulers(identifier: identifier) - alertStore.recordRetraction(of: identifier) + await unscheduleAlertWithSchedulers(identifier: identifier) + do { + try await alertStore.recordRetraction(of: identifier) + } catch { + log.error("Unable to recordRetraction of %@: %@", String(describing: identifier), String(describing: error)) + } } private func replayAlert(_ alert: Alert) { @@ -392,13 +435,13 @@ extension AlertManager: AlertIssuer { userNotificationAlertScheduler.scheduleAlert(alert, muted: alertMuter.shouldMuteAlert(alert, issuedDate: issuedDate)) } - private func unscheduleAlertWithSchedulers(identifier: Alert.Identifier) { - modalAlertScheduler.unscheduleAlert(identifier: identifier) + private func unscheduleAlertWithSchedulers(identifier: Alert.Identifier) async { + await modalAlertScheduler.unscheduleAlert(identifier: identifier) userNotificationAlertScheduler.unscheduleAlert(identifier: identifier) } - private func rescheduleAlertWithSchedulers(_ alert: Alert, issuedDate: Date) { - unscheduleAlertWithSchedulers(identifier: alert.identifier) + private func rescheduleAlertWithSchedulers(_ alert: Alert, issuedDate: Date) async { + await unscheduleAlertWithSchedulers(identifier: alert.identifier) scheduleAlertWithSchedulers(alert, issuedDate: issuedDate) } } @@ -444,200 +487,148 @@ extension AlertManager { extension AlertManager { - func playbackAlertsFromPersistence() { + func playbackAlertsFromPersistence() async { guard !playbackFinished else { return } - playbackAlertsFromAlertStore() - } - - private func playbackAlertsFromAlertStore() { - let updateGroup = DispatchGroup() - updateGroup.enter() - alertStore.lookupAllUnacknowledgedUnretracted { - switch $0 { - case .failure(let error): - self.log.error("Could not fetch unacknowledged alerts: %@", error.localizedDescription) - case .success(let alerts): - alerts.forEach { alert in - do { - if let alert = try Alert(from: alert, adjustedForStorageTime: true) { - self.replayAlert(alert) - } - } catch { - self.log.error("Error decoding alert from persistent storage: %@", error.localizedDescription) + await playbackAlertsFromAlertStore() + } + + private func playbackAlertsFromAlertStore() async { + do { + let alerts = try await alertStore.lookupAllUnacknowledgedUnretracted() + alerts.forEach { alert in + do { + if let alert = try Alert(from: alert, adjustedForStorageTime: true) { + self.replayAlert(alert) } + } catch { + self.log.error("Error decoding alert from persistent storage: %@", error.localizedDescription) } } - updateGroup.leave() - } - updateGroup.enter() - alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts { - switch $0 { - case .failure(let error): - self.log.error("Could not fetch acknowledged unretracted repeating alerts: %@", error.localizedDescription) - case .success(let alerts): - alerts.forEach { alert in - do { - if let alert = try Alert(from: alert, adjustedForStorageTime: true) { - self.replayAlert(alert) - } - } catch { - self.log.error("Error decoding alert from persistent storage: %@", error.localizedDescription) + } catch { + self.log.error("Could not fetch unacknowledged alerts: %@", error.localizedDescription) + } + do { + let alerts = try await alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts() + alerts.forEach { alert in + do { + if let alert = try Alert(from: alert, adjustedForStorageTime: true) { + self.replayAlert(alert) } + } catch { + self.log.error("Error decoding alert from persistent storage: %@", error.localizedDescription) } } - updateGroup.leave() + } catch { + self.log.error("Could not fetch acknowledged unretracted repeating alerts: %@", error.localizedDescription) } - updateGroup.notify(queue: .main) { - self.playbackFinished = true + self.playbackFinished = true + Task { @MainActor in for alert in self.deferredAlerts { - self.issueAlert(alert) + await self.issueAlert(alert) } for identifier in self.deferredRetractions { - self.retractAlert(identifier: identifier) + await self.retractAlert(identifier: identifier) } } } - } // MARK: Alert storage access extension AlertManager { func generateDiagnosticReport() async -> String { - await withCheckedContinuation { continuation in - let startDate = Date() - .days(3.5) // Report the last 3 and half days of alerts - let header = "## Alerts\n" - alertStore.executeQuery(since: startDate, limit: 100, ascending: false) { result in - switch result { - case .failure: - continuation.resume(returning: header) - case .success(_, let objects): - let encoder = JSONEncoder() - let report = header + objects.map { object in - return """ - **\(object.title ?? "??")** - - * identifier: \(object.identifier.value) - * issued: \(object.issuedDate) - * acknowledged: \(object.acknowledgedDate?.description ?? "n/a") - * retracted: \(object.retractedDate?.description ?? "n/a") - * trigger: \(object.trigger) - * interruptionLevel: \(object.interruptionLevel) - * foregroundContent: \((try? encoder.encodeToStringIfPresent(object.foregroundContent)) ?? "n/a") - * backgroundContent: \((try? encoder.encodeToStringIfPresent(object.backgroundContent)) ?? "n/a") - * sound: \((try? encoder.encodeToStringIfPresent(object.sound)) ?? "n/a") - * metadata: \((try? encoder.encodeToStringIfPresent(object.metadata)) ?? "n/a") - - """ - }.joined(separator: "\n") - continuation.resume(returning: report) - } - } + let startDate = Date() - .days(3.5) // Report the last 3 and half days of alerts + let header = "## Alerts\n" + do { + let (_, objects) = try await alertStore.executeQuery(since: startDate, limit: 100, ascending: false) + let encoder = JSONEncoder() + let report = header + objects.map { object in + return """ + **\(object.title ?? "??")** + + * identifier: \(object.identifier.value) + * issued: \(object.issuedDate) + * acknowledged: \(object.acknowledgedDate?.description ?? "n/a") + * retracted: \(object.retractedDate?.description ?? "n/a") + * trigger: \(object.trigger) + * interruptionLevel: \(object.interruptionLevel) + * foregroundContent: \((try? encoder.encodeToStringIfPresent(object.foregroundContent)) ?? "n/a") + * backgroundContent: \((try? encoder.encodeToStringIfPresent(object.backgroundContent)) ?? "n/a") + * sound: \((try? encoder.encodeToStringIfPresent(object.sound)) ?? "n/a") + * metadata: \((try? encoder.encodeToStringIfPresent(object.metadata)) ?? "n/a") + + """ + }.joined(separator: "\n") + return report + } catch { + return header } } } // MARK: PersistedAlertStore extension AlertManager: PersistedAlertStore { - public func doesIssuedAlertExist(identifier: Alert.Identifier, completion: @escaping (Result) -> Void) { - alertStore.lookupAllMatching(identifier: identifier) { result in - switch result { - case .success(let storedAlerts): - completion(.success(!storedAlerts.isEmpty)) - case .failure(let error): - completion(.failure(error)) - } - } + public func doesIssuedAlertExist(identifier: LoopKit.Alert.Identifier) async throws -> Bool { + let storedAlerts = try await alertStore.lookupAllMatching(identifier: identifier) + return !storedAlerts.isEmpty } - - public func lookupAllUnretracted(managerIdentifier: String, completion: @escaping (Result<[PersistedAlert], Error>) -> Void) { - alertStore.lookupAllUnretracted(managerIdentifier: managerIdentifier) { - switch $0 { - case .success(let alerts): - do { - let result = try alerts.compactMap { - if let alert = try Alert(from: $0, adjustedForStorageTime: false) { - return PersistedAlert( - alert: alert, - issuedDate: $0.issuedDate, - retractedDate: $0.retractedDate, - acknowledgedDate: $0.acknowledgedDate - ) - } else { - return nil - } - } - completion(.success(result)) - } catch { - completion(.failure(error)) - } - case .failure(let error): - completion(.failure(error)) + + public func lookupAllUnretracted(managerIdentifier: String) async throws -> [LoopKit.PersistedAlert] { + let alerts = try await alertStore.lookupAllUnretracted(managerIdentifier: managerIdentifier) + return try alerts.compactMap { + if let alert = try Alert(from: $0, adjustedForStorageTime: false) { + return PersistedAlert( + alert: alert, + issuedDate: $0.issuedDate, + retractedDate: $0.retractedDate, + acknowledgedDate: $0.acknowledgedDate + ) + } else { + return nil } } } - - public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String, completion: @escaping (Result<[PersistedAlert], Error>) -> Void) { - alertStore.lookupAllUnacknowledgedUnretracted(managerIdentifier: managerIdentifier) { - switch $0 { - case .success(let alerts): - do { - let result = try alerts.compactMap { - if let alert = try Alert(from: $0, adjustedForStorageTime: false) { - return PersistedAlert( - alert: alert, - issuedDate: $0.issuedDate, - retractedDate: $0.retractedDate, - acknowledgedDate: $0.acknowledgedDate - ) - } else { - return nil - } - } - completion(.success(result)) - } catch { - completion(.failure(error)) - } - case .failure(let error): - completion(.failure(error)) + + public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String) async throws -> [LoopKit.PersistedAlert] { + let alerts = try await alertStore.lookupAllUnacknowledgedUnretracted(managerIdentifier: managerIdentifier) + let result = try alerts.compactMap { + if let alert = try Alert(from: $0, adjustedForStorageTime: false) { + return PersistedAlert( + alert: alert, + issuedDate: $0.issuedDate, + retractedDate: $0.retractedDate, + acknowledgedDate: $0.acknowledgedDate + ) + } else { + return nil } } + return result } - private func lookupAllPendingDelayedOrRepeatingAlerts(completion: @escaping (Result<[PersistedAlert], Error>) -> Void) { + private func lookupAllPendingDelayedOrRepeatingAlerts() async throws -> [PersistedAlert] { // the interval provided is not used in the search. Just the trigger stored type value - alertStore.lookupAllUnacknowledgedUnretracted(filteredByTriggers: [Alert.Trigger.delayed(interval: 0).storedType, Alert.Trigger.repeating(repeatInterval: 0).storedType]) { - switch $0 { - case .success(let alerts): - do { - let result = try alerts.compactMap { - if let alert = try Alert(from: $0, adjustedForStorageTime: false) { - return PersistedAlert( - alert: alert, - issuedDate: $0.issuedDate, - retractedDate: $0.retractedDate, - acknowledgedDate: $0.acknowledgedDate - ) - } else { - return nil - } - } - completion(.success(result)) - } catch { - completion(.failure(error)) - } - case .failure(let error): - completion(.failure(error)) + let alerts = try await alertStore.lookupAllUnacknowledgedUnretracted(filteredByTriggers: [Alert.Trigger.delayed(interval: 0).storedType, Alert.Trigger.repeating(repeatInterval: 0).storedType]) + return try alerts.compactMap { + if let alert = try Alert(from: $0, adjustedForStorageTime: false) { + return PersistedAlert( + alert: alert, + issuedDate: $0.issuedDate, + retractedDate: $0.retractedDate, + acknowledgedDate: $0.acknowledgedDate + ) + } else { + return nil } } } - public func recordRetractedAlert(_ alert: Alert, at date: Date) { - alertStore.recordRetractedAlert(alert, at: date) + public func recordRetractedAlert(_ alert: Alert, at date: Date) async throws { + try await alertStore.recordRetractedAlert(alert, at: date) } - private func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { - alertStore.recordIssued(alert: alert, at: date, completion: completion) + private func recordIssued(alert: Alert, at date: Date = Date()) async { + await alertStore.recordIssued(alert: alert, at: date) } } @@ -706,21 +697,27 @@ extension AlertManager: BluetoothObserver { // MARK: - PresetActivationObserver extension AlertManager: PresetActivationObserver { + nonisolated func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) { switch context { case .legacyWorkout: if duration == .indefinite { - scheduleWorkoutOverrideReminder() + Task { + await scheduleWorkoutOverrideReminder() + } } default: break } } + nonisolated func presetDeactivated(context: TemporaryScheduleOverride.Context) { switch context { case .legacyWorkout: - retractWorkoutOverrideReminder() + Task { + await retractWorkoutOverrideReminder() + } default: break } @@ -748,12 +745,16 @@ extension AlertManager: AlertPermissionsCheckerDelegate { alert, muted: self.alertMuter.shouldMuteAlert(alert) ) - self.recordIssued(alert: alert) + Task { + await 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() + Task { + try await self.recordRetractedAlert(alert, at: Date()) + await self.dismissUnsafeNotificationPermissionsInAppAlert() + } } ) { _ = issueOrRetract( @@ -763,11 +764,15 @@ extension AlertManager: AlertPermissionsCheckerDelegate { setAlreadyIssued: { UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0 }, - issueHandler: { - alert in self.issueAlert(alert) + issueHandler: { alert in + Task { + await self.issueAlert(alert) + } }, - retractionHandler: { - alert in self.retractAlert(identifier: alert.identifier) + retractionHandler: { alert in + Task { + await self.retractAlert(identifier: alert.identifier) + } } ) } @@ -796,33 +801,30 @@ extension AlertManager: AlertPermissionsCheckerDelegate { } private func presentUnsafeNotificationPermissionsInAppAlert(_ alert: AlertPermissionsChecker.UnsafeNotificationPermissionAlert) { - DispatchQueue.main.async { - 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 + Task { @MainActor in + let alertController = await AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert(alert: alert) + for alert in AlertPermissionsChecker.UnsafeNotificationPermissionAlert.allCases { + UserDefaults.standard.hasIssuedNotificationPermissionsAlert = false + try await self.acknowledgeAlert( + identifier: alert.alertIdentifier + ) } + + await self.alertPresenter.present(alertController, animated: true) + // the completion is called after the alert is presented + unsafeNotificationPermissionsAlertController = alertController } } - private func dismissUnsafeNotificationPermissionsInAppAlert() { + private func dismissUnsafeNotificationPermissionsInAppAlert() async { guard let alertController = unsafeNotificationPermissionsAlertController else { return } - alertPresenter.dismissAlert(alertController, animated: true) { [weak self] in - self?.unsafeNotificationPermissionsAlertController = nil - } + await alertPresenter.dismissAlert(alertController, animated: true) + unsafeNotificationPermissionsAlertController = nil } } extension AlertManager { - func presentLoopResetConfirmationAlert(confirmAction: @escaping (@escaping () -> Void) -> Void, cancelAction: @escaping () -> Void) { + func presentLoopResetConfirmationAlert(confirmAction: @escaping (@escaping () -> Void) -> Void, cancelAction: @escaping () -> Void) async { let alert = UIAlertController(title: "Loop Reset Requested", message: "We've detected a Loop reset may be needed. Tapping confirm will reset Loop and quit the app.", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "Confirm", style: .default, handler: { _ in confirmAction() { @@ -833,16 +835,16 @@ extension AlertManager { cancelAction() })) - alertPresenter.present(alert, animated: true) + await alertPresenter.present(alert, animated: true) } - func presentCouldNotResetLoopAlert(error: Error) { + func presentCouldNotResetLoopAlert(error: Error) async { let titleString = String(format: NSLocalizedString("Could Not Restart %1$@", comment: "Format string for title of reset loop alert. (1: App name)"), Bundle.main.bundleDisplayName) let message = String(format: NSLocalizedString("While trying to restart %1$@ an error occured.\n\n%2$@", comment: "Format string for message of reset loop alert. (1: App name) (2: error description)"), Bundle.main.bundleDisplayName, error.localizedDescription) let alert = UIAlertController(title: titleString, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel button for reset loop alert"), style: .cancel)) - alertPresenter.present(alert, animated: true) + await alertPresenter.present(alert, animated: true) } } diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift index cc2e7837eb..c0438c5721 100644 --- a/Loop/Managers/Alerts/AlertStore.swift +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -82,150 +82,125 @@ public class AlertStore { self.expireAfter = expireAfter } - public func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { - self.managedObjectContext.performAndWait { + public func recordIssued(alert: Alert, at date: Date = Date()) async { + await self.managedObjectContext.perform { _ = StoredAlert(from: alert, context: self.managedObjectContext, issuedDate: date) do { try self.managedObjectContext.save() self.log.default("Recorded alert: %{public}@", alert.identifier.value) self.purgeExpired() self.delegate?.alertStoreHasUpdatedAlertData(self) - completion?(.success) } catch { self.log.error("Could not store alert: %{public}@, %{public}@", alert.identifier.value, String(describing: error)) - completion?(.failure(error)) } } } - public func recordRetractedAlert(_ alert: Alert, at date: Date, completion: ((Result) -> Void)? = nil) { - self.managedObjectContext.performAndWait { + public func recordRetractedAlert(_ alert: Alert, at date: Date) async throws { + try await self.managedObjectContext.perform { let storedAlert = StoredAlert(from: alert, context: self.managedObjectContext, issuedDate: date) storedAlert.retractedDate = date - do { - try self.managedObjectContext.save() - self.log.default("Recorded retracted alert: %{public}@", alert.identifier.value) - self.purgeExpired() - self.delegate?.alertStoreHasUpdatedAlertData(self) - completion?(.success) - } catch { - self.log.error("Could not store retracted alert: %{public}@, %{public}@", alert.identifier.value, String(describing: error)) - completion?(.failure(error)) - } + try self.managedObjectContext.save() + self.log.default("Recorded retracted alert: %{public}@", alert.identifier.value) + self.purgeExpired() + self.delegate?.alertStoreHasUpdatedAlertData(self) } } - public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date(), - completion: ((Result) -> Void)? = nil) { - recordUpdateOfAll(identifier: identifier, + public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date()) async throws { + try await recordUpdateOfAll(identifier: identifier, addingPredicate: NSPredicate(format: "acknowledgedDate == nil"), with: { $0.acknowledgedDate = date return .save - }, - completion: completion) + }) } - public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date(), - completion: ((Result) -> Void)? = nil) { - recordUpdateOfLatest(identifier: identifier, - addingPredicate: NSPredicate(format: "retractedDate == nil"), - with: { - // if the alert was retracted before it was ever shown, delete it. - // Note: this only applies to .delayed or .repeating alerts! - if let delay = $0.trigger.interval, $0.issuedDate + delay >= date { - return .delete - } else { - $0.retractedDate = date - return .save - } - }, - completion: completion) - } - - public func lookupAllMatching(identifier: Alert.Identifier, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - managedObjectContext.perform { - do { - let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() - let predicates = [ - NSPredicate(format: "managerIdentifier = %@", identifier.managerIdentifier), - NSPredicate(format: "alertIdentifier = %@", identifier.alertIdentifier), - ] - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) - fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] - let result = try self.managedObjectContext.fetch(fetchRequest) - completion(.success(result)) - } catch { - completion(.failure(error)) + public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date()) async throws { + try await recordUpdateOfLatest( + identifier: identifier, + addingPredicate: NSPredicate(format: "retractedDate == nil"), + with: { + // if the alert was retracted before it was ever shown, delete it. + // Note: this only applies to .delayed or .repeating alerts! + if let delay = $0.trigger.interval, $0.issuedDate + delay >= date { + return .delete + } else { + $0.retractedDate = date + return .save + } + }) + } + + public func lookupAllMatching(identifier: Alert.Identifier, limit: Int? = nil, mostRecentFirst: Bool = false) async throws -> [StoredAlert] { + try await managedObjectContext.perform { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + let predicates = [ + NSPredicate(format: "managerIdentifier = %@", identifier.managerIdentifier), + NSPredicate(format: "alertIdentifier = %@", identifier.alertIdentifier), + ] + if let limit { + fetchRequest.fetchLimit = limit } + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: !mostRecentFirst) ] + return try self.managedObjectContext.fetch(fetchRequest) } } - public func lookupAllUnretracted(managerIdentifier: String? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - managedObjectContext.perform { - do { - let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() - var predicates = [ - NSPredicate(format: "retractedDate == nil"), - ] - if let managerIdentifier = managerIdentifier { - predicates.insert(NSPredicate(format: "managerIdentifier = %@", managerIdentifier), at: 0) - } - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) - fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] - let result = try self.managedObjectContext.fetch(fetchRequest) - completion(.success(result)) - } catch { - completion(.failure(error)) + public func lookupAllUnretracted(managerIdentifier: String? = nil) async throws -> [StoredAlert] { + try await managedObjectContext.perform { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + var predicates = [ + NSPredicate(format: "retractedDate == nil"), + ] + if let managerIdentifier = managerIdentifier { + predicates.insert(NSPredicate(format: "managerIdentifier = %@", managerIdentifier), at: 0) } + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] + return try self.managedObjectContext.fetch(fetchRequest) } } - public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - managedObjectContext.perform { - do { - let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() - var predicates = [ - NSPredicate(format: "acknowledgedDate == nil"), - NSPredicate(format: "retractedDate == nil"), - ] - if let managerIdentifier = managerIdentifier { - predicates.insert(NSPredicate(format: "managerIdentifier = %@", managerIdentifier), at: 0) - } - if let triggersStoredType = triggersStoredType { - var triggerPredicates: [NSPredicate] = [] - for triggerStoredType in triggersStoredType { - triggerPredicates.append(NSPredicate(format: "triggerType == %d", triggerStoredType)) - } - let triggerFilterPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: triggerPredicates) - predicates.append(triggerFilterPredicate) + public func lookupAllUnacknowledgedUnretracted( + managerIdentifier: String? = nil, + filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil + ) async throws -> [StoredAlert] { + try await managedObjectContext.perform { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + var predicates = [ + NSPredicate(format: "acknowledgedDate == nil"), + NSPredicate(format: "retractedDate == nil"), + ] + if let managerIdentifier = managerIdentifier { + predicates.insert(NSPredicate(format: "managerIdentifier = %@", managerIdentifier), at: 0) + } + if let triggersStoredType = triggersStoredType { + var triggerPredicates: [NSPredicate] = [] + for triggerStoredType in triggersStoredType { + triggerPredicates.append(NSPredicate(format: "triggerType == %d", triggerStoredType)) } - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) - fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] - let result = try self.managedObjectContext.fetch(fetchRequest) - completion(.success(result)) - } catch { - completion(.failure(error)) + let triggerFilterPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: triggerPredicates) + predicates.append(triggerFilterPredicate) } + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] + return try self.managedObjectContext.fetch(fetchRequest) } } - public func lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - managedObjectContext.perform { - do { - let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() - let repeatingTrigger = Alert.Trigger.repeating(repeatInterval: 0) - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - NSPredicate(format: "acknowledgedDate != nil"), - NSPredicate(format: "retractedDate == nil"), - NSPredicate(format: "triggerType == \(repeatingTrigger.storedType)") - ]) - fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] - let result = try self.managedObjectContext.fetch(fetchRequest) - completion(.success(result)) - } catch { - completion(.failure(error)) - } + public func lookupAllAcknowledgedUnretractedRepeatingAlerts() async throws -> [StoredAlert] { + try await managedObjectContext.perform { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + let repeatingTrigger = Alert.Trigger.repeating(repeatInterval: 0) + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "acknowledgedDate != nil"), + NSPredicate(format: "retractedDate == nil"), + NSPredicate(format: "triggerType == \(repeatingTrigger.storedType)") + ]) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] + return try self.managedObjectContext.fetch(fetchRequest) } } @@ -237,49 +212,35 @@ extension AlertStore { private func recordUpdateOfAll(identifier: Alert.Identifier, addingPredicate predicate: NSPredicate, - with updateBlock: @escaping ManagedObjectUpdateBlock, - completion: ((Result) -> Void)?) { - managedObjectContext.performAndWait { - self.lookupAll(identifier: identifier, predicate: predicate) { - switch $0 { - case .success(let objects): - if objects.count > 0 { - let result = self.update(objects: objects, with: updateBlock) - completion?(result) - } else { - self.log.error("Alert not found for update: %{public}@", identifier.value) - completion?(.failure(AlertStoreError.notFound)) - } - case .failure(let error): - completion?(.failure(error)) - } + with updateBlock: @escaping ManagedObjectUpdateBlock) async throws + { + try await managedObjectContext.perform { + let objects = try self.lookupAll(identifier: identifier, predicate: predicate) + if objects.count > 0 { + try self.update(objects: objects, with: updateBlock) + } else { + self.log.error("Alert not found for update: %{public}@", identifier.value) + throw AlertStoreError.notFound } } } private func recordUpdateOfLatest(identifier: Alert.Identifier, addingPredicate predicate: NSPredicate, - with updateBlock: @escaping ManagedObjectUpdateBlock, - completion: ((Result) -> Void)?) { - managedObjectContext.performAndWait { - self.lookupLatest(identifier: identifier, predicate: predicate) { - switch $0 { - case .success(let object): - if let object = object { - let result = self.update(objects: [object], with: updateBlock) - completion?(result) - } else { - self.log.error("Alert not found for update: %{public}@", identifier.value) - completion?(.failure(AlertStoreError.notFound)) - } - case .failure(let error): - completion?(.failure(error)) - } + with updateBlock: @escaping ManagedObjectUpdateBlock) async throws + { + try await managedObjectContext.perform { + let object = try self.lookupLatest(identifier: identifier, predicate: predicate) + if let object = object { + try self.update(objects: [object], with: updateBlock) + } else { + self.log.error("Alert not found for update: %{public}@", identifier.value) + throw AlertStoreError.notFound } } } - private func update(objects: [StoredAlert], with updateBlock: @escaping ManagedObjectUpdateBlock) -> Result { + private func update(objects: [StoredAlert], with updateBlock: @escaping ManagedObjectUpdateBlock) throws { objects.forEach { alert in let shouldDelete = updateBlock(alert) == .delete if shouldDelete { @@ -287,50 +248,31 @@ extension AlertStore { } self.log.default("%{public}@ alert: %{public}@", shouldDelete ? "Deleted" : "Recorded", alert.identifier.value) } - do { - try self.managedObjectContext.save() - } catch { - return .failure(error) - } + try self.managedObjectContext.save() self.purgeExpired() self.delegate?.alertStoreHasUpdatedAlertData(self) - return .success } - private func lookupAll(identifier: Alert.Identifier, predicate: NSPredicate, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - managedObjectContext.perform { - do { - let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - identifier.equalsPredicate, - predicate - ]) - fetchRequest.fetchLimit = Self.totalFetchLimit - let result = try self.managedObjectContext.fetch(fetchRequest) - completion(.success(result)) - } catch { - completion(.failure(error)) - } - } + private func lookupAll(identifier: Alert.Identifier, predicate: NSPredicate) throws -> [StoredAlert] { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + identifier.equalsPredicate, + predicate + ]) + fetchRequest.fetchLimit = Self.totalFetchLimit + return try managedObjectContext.fetch(fetchRequest) } - private func lookupLatest(identifier: Alert.Identifier, predicate: NSPredicate, completion: @escaping (Result) -> Void) { - managedObjectContext.perform { - do { - let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - identifier.equalsPredicate, - predicate - ]) - fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: false) ] - fetchRequest.fetchLimit = 1 - let result = try self.managedObjectContext.fetch(fetchRequest) - completion(.success(result.last)) - } catch { - completion(.failure(error)) - } - } + private func lookupLatest(identifier: Alert.Identifier, predicate: NSPredicate) throws -> StoredAlert? { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + identifier.equalsPredicate, + predicate + ]) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: false) ] + fetchRequest.fetchLimit = 1 + return try self.managedObjectContext.fetch(fetchRequest).last } } @@ -417,30 +359,50 @@ extension AlertStore { } } - public enum AlertQueryResult { - case success(QueryAnchor, [SyncAlertObject]) - case failure(Error) - } + typealias AlertQueryResult = (QueryAnchor, [SyncAlertObject]) - func executeQuery(fromQueryAnchor queryAnchor: QueryAnchor? = nil, since date: Date, excludingFutureAlerts: Bool = true, now: Date = Date(), limit: Int, ascending: Bool = true, completion: @escaping (AlertQueryResult) -> Void) { + func executeQuery( + fromQueryAnchor queryAnchor: QueryAnchor? = nil, + since date: Date, + excludingFutureAlerts: Bool = true, + now: Date = Date(), + limit: Int, + ascending: Bool = true + ) async throws -> AlertQueryResult { let sinceDateFilter = SinceDateFilter(predicateExpressionNotYetExpired: predicateExpressionNotYetExpired, date: date, excludingFutureAlerts: excludingFutureAlerts, now: now) - executeAlertQuery(fromQueryAnchor: queryAnchor, queryFilter: sinceDateFilter, limit: limit, ascending: ascending, completion: completion) + return try await executeAlertQuery(fromQueryAnchor: queryAnchor, queryFilter: sinceDateFilter, limit: limit, ascending: ascending) + } + + func executeAlertQuery(fromQueryAnchor queryAnchor: QueryAnchor?, queryFilter: QueryFilter? = nil, limit: Int, ascending: Bool = true, completion: @escaping (Result) -> Void) + { + Task { + do { + let result = try await executeAlertQuery(fromQueryAnchor: queryAnchor, queryFilter: queryFilter, limit: limit, ascending: ascending) + completion(.success(result)) + } catch { + completion(.failure(error)) + } + } } - func executeAlertQuery(fromQueryAnchor queryAnchor: QueryAnchor?, queryFilter: QueryFilter? = nil, limit: Int, ascending: Bool = true, completion: @escaping (AlertQueryResult) -> Void) { + func executeAlertQuery( + fromQueryAnchor queryAnchor: QueryAnchor?, + queryFilter: QueryFilter? = nil, + limit: Int, + ascending: Bool = true + ) async throws -> AlertQueryResult { var queryAnchor = queryAnchor ?? QueryAnchor() var queryResult = [SyncAlertObject]() var queryError: Error? guard limit > 0 else { - completion(.success(queryAnchor, [])) - return + return (queryAnchor, []) } - self.managedObjectContext.performAndWait { + await self.managedObjectContext.perform { let storedRequest: NSFetchRequest = StoredAlert.fetchRequest() let queryAnchorPredicate = NSPredicate(format: "modificationCounter > %d", queryAnchor.modificationCounter) @@ -465,25 +427,20 @@ extension AlertStore { } if let queryError = queryError { - completion(.failure(queryError)) - return + throw queryError } - completion(.success(queryAnchor, queryResult)) + return (queryAnchor, queryResult) } // At the moment, this is only used for unit testing - internal func fetch(identifier: Alert.Identifier? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - self.managedObjectContext.perform { + internal func fetch(identifier: Alert.Identifier? = nil) async throws -> [StoredAlert] { + return try await self.managedObjectContext.perform { let storedRequest: NSFetchRequest = StoredAlert.fetchRequest() storedRequest.predicate = identifier?.equalsPredicate storedRequest.sortDescriptors = [NSSortDescriptor(key: "modificationCounter", ascending: true)] - do { - let stored = try self.managedObjectContext.fetch(storedRequest) - completion(.success(stored)) - } catch { - completion(.failure(error)) - } + let stored = try self.managedObjectContext.fetch(storedRequest) + return stored } } } diff --git a/Loop/Managers/Alerts/InAppModalAlertScheduler.swift b/Loop/Managers/Alerts/InAppModalAlertScheduler.swift index b00c809f7a..c6d48a3dc6 100644 --- a/Loop/Managers/Alerts/InAppModalAlertScheduler.swift +++ b/Loop/Managers/Alerts/InAppModalAlertScheduler.swift @@ -9,6 +9,7 @@ import UIKit import LoopKit +@MainActor public class InAppModalAlertScheduler { private weak var alertPresenter: AlertPresenter? @@ -47,19 +48,17 @@ public class InAppModalAlertScheduler { } } - public func unscheduleAlert(identifier: Alert.Identifier) { - DispatchQueue.main.async { - self.removePendingAlert(identifier: identifier) - self.removePresentedAlert(identifier: identifier) - } + public func unscheduleAlert(identifier: Alert.Identifier) async { + removePendingAlert(identifier: identifier) + await removePresentedAlert(identifier: identifier) } - func removePresentedAlert(identifier: Alert.Identifier, completion: (() -> Void)? = nil) { + func removePresentedAlert(identifier: Alert.Identifier) async { guard let alertPresented = alertsPresented[identifier] else { - completion?() return } - alertPresenter?.dismissAlert(alertPresented.0, animated: true, completion: completion) + + await alertPresenter?.dismissAlert(alertPresented.0, animated: true) clearPresentedAlert(identifier: identifier) } @@ -95,22 +94,27 @@ extension InAppModalAlertScheduler { guard let content = alert.foregroundContent else { return } - DispatchQueue.main.async { + Task { @MainActor in if self.isAlertPresented(identifier: alert.identifier) { return } let alertController = self.constructAlert(title: content.title, message: content.body, - action: content.acknowledgeActionButtonLabel, - isCritical: alert.interruptionLevel == .critical) { [weak self] in + actions: content.actions, + isCritical: alert.interruptionLevel == .critical) + { [weak self] (action) in // the completion is called after the alert is acknowledged self?.clearPresentedAlert(identifier: alert.identifier) - self?.alertManagerResponder?.acknowledgeAlert(identifier: alert.identifier) - } - self.alertPresenter?.present(alertController, animated: true) { [weak self] in - // the completion is called after the alert is presented - self?.addPresentedAlert(alert: alert, controller: alertController) + Task { + if action.identifier == "acknowledge" { + try await self?.alertManagerResponder?.acknowledgeAlert(identifier: alert.identifier) + } else { + try await self?.alertManagerResponder?.userDidSelectAction(alertIdentifier: alert.identifier, actionIdentifier: action.identifier) + } + } } + await self.alertPresenter?.present(alertController, animated: true) + addPresentedAlert(alert: alert, controller: alertController) } } @@ -144,11 +148,40 @@ extension InAppModalAlertScheduler { return alertsPresented.index(forKey: identifier) != nil } - private func constructAlert(title: String, message: String, action: String, isCritical: Bool, acknowledgeCompletion: @escaping () -> Void) -> UIAlertController { + private func constructAlert( + title: String, + message: String, + actions: [Alert.UserAlertAction], + isCritical: Bool, + handleAction: @escaping (Alert.UserAlertAction) -> Void + ) -> UIAlertController { dispatchPrecondition(condition: .onQueue(.main)) // For now, this is a simple alert with an "OK" button let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - alertController.addAction(newActionFunc(action, .default, { _ in acknowledgeCompletion() })) + for action in actions { + alertController.addAction( + newActionFunc( + action.label, + action.style.uiKitStyle, + { _ in + handleAction(action) + }) + ) + } return alertController } } + + +extension Alert.UserAlertAction.Style { + var uiKitStyle: UIAlertAction.Style { + switch self { + case .default: + return .default + case .destructive: + return .destructive + case .cancel: + return .cancel + } + } +} diff --git a/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift b/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift index e9381886d7..83e92f1783 100644 --- a/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift +++ b/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift @@ -34,7 +34,11 @@ public class UserNotificationAlertScheduler { func scheduleAlert(_ alert: Alert, timestamp: Date, muted: Bool = false) { DispatchQueue.main.async { - let request = UNNotificationRequest(from: alert, timestamp: timestamp, muted: muted) + let content = alert.getUserNotificationContent(timestamp: timestamp, muted: muted) + let request = UNNotificationRequest(identifier: alert.identifier.value, + content: content, + trigger: UNTimeIntervalNotificationTrigger(from: alert.trigger)) + self.userNotificationCenter.add(request) { error in if let error = error { self.log.error("Something went wrong posting the user notification: %@", error.localizedDescription) @@ -50,10 +54,8 @@ public class UserNotificationAlertScheduler { self.userNotificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier.value]) } } -} -extension UserNotificationAlertScheduler: AlertManagerResponder { - func acknowledgeAlert(identifier: Alert.Identifier) { + func alertWasAcknowledged(identifier: Alert.Identifier) { DispatchQueue.main.async { self.log.debug("Removing notification %@ from delivered notifications", identifier.value) self.userNotificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier.value]) @@ -70,8 +72,7 @@ fileprivate extension Alert { if #available(iOS 15.0, *) { userNotificationContent.interruptionLevel = interruptionLevel.userNotificationInterruptLevel } - // TODO: Once we have a final design and approval for custom UserNotification buttons, we'll need to set categoryIdentifier -// userNotificationContent.categoryIdentifier = LoopNotificationCategory.alert.rawValue + userNotificationContent.categoryIdentifier = categoryIdentifier ?? "" userNotificationContent.threadIdentifier = identifier.value // Used to match categoryIdentifier, but I /think/ we want multiple threads for multiple alert types, no? userNotificationContent.userInfo = [ LoopNotificationUserInfoKey.managerIDForAlert.rawValue: identifier.managerIdentifier, @@ -116,15 +117,6 @@ fileprivate extension Alert.InterruptionLevel { } } -fileprivate extension UNNotificationRequest { - convenience init(from alert: Alert, timestamp: Date, muted: Bool) { - let content = alert.getUserNotificationContent(timestamp: timestamp, muted: muted) - self.init(identifier: alert.identifier.value, - content: content, - trigger: UNTimeIntervalNotificationTrigger(from: alert.trigger)) - } -} - fileprivate extension UNTimeIntervalNotificationTrigger { convenience init?(from alertTrigger: Alert.Trigger) { switch alertTrigger { diff --git a/Loop/Managers/DeliveryUncertaintyAlertManager.swift b/Loop/Managers/DeliveryUncertaintyAlertManager.swift index 8bd74b7ef7..015f98342b 100644 --- a/Loop/Managers/DeliveryUncertaintyAlertManager.swift +++ b/Loop/Managers/DeliveryUncertaintyAlertManager.swift @@ -10,6 +10,7 @@ import Foundation import UIKit import LoopKitUI +@MainActor class DeliveryUncertaintyAlertManager { private let pumpManager: PumpManagerUI private let alertPresenter: AlertPresenter @@ -20,14 +21,14 @@ class DeliveryUncertaintyAlertManager { self.alertPresenter = alertPresenter } - private func showUncertainDeliveryRecoveryView() { + private func showUncertainDeliveryRecoveryView() async { var controller = pumpManager.deliveryUncertaintyRecoveryViewController(colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures) controller.completionDelegate = self controller.modalPresentationStyle = .fullScreen - self.alertPresenter.present(controller, animated: true) + await self.alertPresenter.present(controller, animated: true) } - func showAlert(animated: Bool = true) { + func showAlert(animated: Bool = true) async { if self.uncertainDeliveryAlert == nil { let alert = UIAlertController( title: NSLocalizedString("Unable To Reach Pump", comment: "Title for alert shown when delivery status is uncertain"), @@ -36,13 +37,14 @@ class DeliveryUncertaintyAlertManager { let actionTitle = NSLocalizedString("Learn More", comment: "OK button title for alert shown when delivery status is uncertain") let action = UIAlertAction(title: actionTitle, style: .default) { (_) in - self.uncertainDeliveryAlert = nil - self.showUncertainDeliveryRecoveryView() + Task { @MainActor in + self.uncertainDeliveryAlert = nil + await self.showUncertainDeliveryRecoveryView() + } } alert.addAction(action) - self.alertPresenter.dismissTopMost(animated: false) { - self.alertPresenter.present(alert, animated: animated) - } + await self.alertPresenter.dismissTopMost(animated: false) + await self.alertPresenter.present(alert, animated: animated) self.uncertainDeliveryAlert = alert } } @@ -59,8 +61,10 @@ extension DeliveryUncertaintyAlertManager: CompletionDelegate { // If delivery still uncertain after recovery view dismissal, present modal alert again. if let vc = object as? UIViewController { vc.dismiss(animated: true) { - if self.pumpManager.status.deliveryIsUncertain { - self.showAlert(animated: false) + Task { + if self.pumpManager.status.deliveryIsUncertain { + await self.showAlert(animated: false) + } } } } diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index ac22edbc30..239904e59e 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -548,7 +548,9 @@ final class DeviceDataManager { func checkDeliveryUncertaintyState() { if let pumpManager = pumpManager, pumpManager.status.deliveryIsUncertain { - self.deliveryUncertaintyAlertManager?.showAlert() + Task { + await self.deliveryUncertaintyAlertManager?.showAlert() + } } } @@ -562,14 +564,15 @@ final class DeviceDataManager { func authorizeHealthStore(_ completion: @escaping (HKAuthorizationRequestStatus) -> Void) { // Authorize all types at once for simplicity healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { (success, error) in - if success { - // Call the individual authorization methods to trigger query creation - self.carbStore.hkSampleStore?.authorizationIsDetermined() - self.doseStore.hkSampleStore?.authorizationIsDetermined() - self.glucoseStore.hkSampleStore?.authorizationIsDetermined() + Task { @MainActor in + if success { + // Call the individual authorization methods to trigger query creation + self.carbStore.hkSampleStore?.authorizationIsDetermined() + self.doseStore.hkSampleStore?.authorizationIsDetermined() + self.glucoseStore.hkSampleStore?.authorizationIsDetermined() + } + self.getHealthStoreAuthorization(completion) } - - self.getHealthStoreAuthorization(completion) } } @@ -811,8 +814,10 @@ extension DeviceDataManager { // MARK: - DeviceManagerDelegate extension DeviceDataManager: DeviceManagerDelegate { - func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) { - deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: deviceIdentifier, type: type, message: message, completion: completion) + nonisolated func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) { + Task { @MainActor in + deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: deviceIdentifier, type: type, message: message, completion: completion) + } } var allowDebugFeatures: Bool { @@ -824,35 +829,35 @@ extension DeviceDataManager: DeviceManagerDelegate { extension DeviceDataManager: AlertIssuer { static let managerIdentifier = "DeviceDataManager" - func issueAlert(_ alert: Alert) { - alertManager?.issueAlert(alert) + func issueAlert(_ alert: Alert) async { + await alertManager?.issueAlert(alert) } - func retractAlert(identifier: Alert.Identifier) { - alertManager?.retractAlert(identifier: identifier) + func retractAlert(identifier: Alert.Identifier) async { + await alertManager?.retractAlert(identifier: identifier) } } // MARK: - PersistedAlertStore extension DeviceDataManager: PersistedAlertStore { - func doesIssuedAlertExist(identifier: Alert.Identifier, completion: @escaping (Swift.Result) -> Void) { + func doesIssuedAlertExist(identifier: LoopKit.Alert.Identifier) async throws -> Bool { precondition(alertManager != nil) - alertManager.doesIssuedAlertExist(identifier: identifier, completion: completion) + return try await alertManager.doesIssuedAlertExist(identifier: identifier) } - - func lookupAllUnretracted(managerIdentifier: String, completion: @escaping (Swift.Result<[PersistedAlert], Error>) -> Void) { + + func lookupAllUnretracted(managerIdentifier: String) async throws -> [LoopKit.PersistedAlert] { precondition(alertManager != nil) - alertManager.lookupAllUnretracted(managerIdentifier: managerIdentifier, completion: completion) + return try await alertManager.lookupAllUnretracted(managerIdentifier: managerIdentifier) } - func lookupAllUnacknowledgedUnretracted(managerIdentifier: String, completion: @escaping (Swift.Result<[PersistedAlert], Error>) -> Void) { + func lookupAllUnacknowledgedUnretracted(managerIdentifier: String) async throws -> [LoopKit.PersistedAlert] { precondition(alertManager != nil) - alertManager.lookupAllUnacknowledgedUnretracted(managerIdentifier: managerIdentifier, completion: completion) + return try await alertManager.lookupAllUnacknowledgedUnretracted(managerIdentifier: managerIdentifier) } - func recordRetractedAlert(_ alert: Alert, at date: Date) { + func recordRetractedAlert(_ alert: Alert, at date: Date) async throws { precondition(alertManager != nil) - alertManager.recordRetractedAlert(alert, at: date) + try await alertManager.recordRetractedAlert(alert, at: date) } } @@ -989,27 +994,26 @@ extension DeviceDataManager: PumpManagerDelegate { return !(cgmManager?.providesBLEHeartbeat == true) } - func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { - dispatchPrecondition(condition: .onQueue(.main)) - log.default("PumpManager:%{public}@ did update status: %{public}@", String(describing: type(of: pumpManager)), String(describing: status)) + nonisolated func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { + Task { @MainActor in + log.default("PumpManager:%{public}@ did update status: %{public}@", String(describing: type(of: pumpManager)), String(describing: status)) - doseStore.device = status.device - - if let newBatteryValue = status.pumpBatteryChargeRemaining, - let oldBatteryValue = oldStatus.pumpBatteryChargeRemaining, - newBatteryValue - oldBatteryValue >= LoopConstants.batteryReplacementDetectionThreshold { - analyticsServicesManager.pumpBatteryWasReplaced() - } + doseStore.device = status.device - updatePumpIsAllowingAutomation(status: status) + if let newBatteryValue = status.pumpBatteryChargeRemaining, + let oldBatteryValue = oldStatus.pumpBatteryChargeRemaining, + newBatteryValue - oldBatteryValue >= LoopConstants.batteryReplacementDetectionThreshold { + analyticsServicesManager.pumpBatteryWasReplaced() + } - // Update the pump-schedule based settings - settingsManager.setScheduleTimeZone(status.timeZone) + updatePumpIsAllowingAutomation(status: status) - if status.deliveryIsUncertain != oldStatus.deliveryIsUncertain { - DispatchQueue.main.async { + // Update the pump-schedule based settings + settingsManager.setScheduleTimeZone(status.timeZone) + + if status.deliveryIsUncertain != oldStatus.deliveryIsUncertain { if status.deliveryIsUncertain { - self.deliveryUncertaintyAlertManager?.showAlert() + await self.deliveryUncertaintyAlertManager?.showAlert() } else { self.deliveryUncertaintyAlertManager?.clearAlert() } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 4e16855f9f..916e0a223b 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -26,13 +26,14 @@ enum SimulatorError: Error { } #endif +@MainActor public protocol AlertPresenter: AnyObject { /// Present the alert view controller, with or without animation. /// - Parameters: /// - viewControllerToPresent: The alert view controller to present. /// - animated: Animate the alert view controller presentation or not. /// - completion: Completion to call once view controller is presented. - func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)?) + func present(_ viewControllerToPresent: UIViewController, animated flag: Bool) async /// Retract any alerts with the given identifier. This includes both pending and delivered alerts. @@ -40,20 +41,14 @@ public protocol AlertPresenter: AnyObject { /// - Parameters: /// - animated: Animate the alert view controller dismissal or not. /// - completion: Completion to call once view controller is dismissed. - func dismissTopMost(animated: Bool, completion: (() -> Void)?) + func dismissTopMost(animated: Bool) async /// Dismiss an alert, even if it is not the top most alert. /// - Parameters: /// - alertToDismiss: The alert to dismiss /// - animated: Animate the alert view controller dismissal or not. /// - completion: Completion to call once view controller is dismissed. - func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) -} - -public extension AlertPresenter { - func present(_ viewController: UIViewController, animated: Bool) { present(viewController, animated: animated, completion: nil) } - func dismissTopMost(animated: Bool) { dismissTopMost(animated: animated, completion: nil) } - func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool) { dismissAlert(alertToDismiss, animated: animated, completion: nil) } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool) async } protocol WindowProvider: AnyObject { @@ -127,7 +122,7 @@ class LoopAppManager: NSObject { self.windowProvider = windowProvider self.launchOptions = launchOptions - + if FeatureFlags.siriEnabled && INPreferences.siriAuthorizationStatus() == .notDetermined { INPreferences.requestSiriAuthorization { _ in } } @@ -154,6 +149,8 @@ class LoopAppManager: NSObject { func launch() { precondition(isLaunchPending) + UNUserNotificationCenter.current().delegate = self + registerBackgroundTasks() Task { @@ -200,7 +197,6 @@ class LoopAppManager: NSObject { windowProvider?.window?.tintColor = .loopAccent OrientationLock.deviceOrientationController = self - UNUserNotificationCenter.current().delegate = self resetLoopManager = ResetLoopManager(delegate: self) @@ -249,12 +245,14 @@ class LoopAppManager: NSObject { observationStart: Date().addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) ) - temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsManager) + temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsManager, alertIssuer: alertManager) temporaryPresetsManager.presetHistory.delegate = self temporaryPresetsManager.addTemporaryPresetObserver(alertManager) temporaryPresetsManager.addTemporaryPresetObserver(analyticsServicesManager) + await temporaryPresetsManager.scheduleNextPresetReminder() + self.carbStore = CarbStore( healthKitSampleStore: carbHealthStore, cacheStore: cacheStore, @@ -330,9 +328,8 @@ class LoopAppManager: NSObject { cacheStore.delegate = loopDataManager - Task { @MainActor in - alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) - } + alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) + alertManager.addAlertResponder(managerIdentifier: temporaryPresetsManager.managerIdentifier, alertResponder: temporaryPresetsManager) cgmEventStore = CgmEventStore(cacheStore: cacheStore, cacheLength: localCacheDuration) @@ -428,6 +425,7 @@ class LoopAppManager: NSObject { glucoseStore: glucoseStore, analyticsServicesManager: analyticsServicesManager, temporaryPresetsManager: temporaryPresetsManager, + alertManager: alertManager, healthStore: healthStore ) @@ -532,9 +530,9 @@ class LoopAppManager: NSObject { onboardingManager.launch { DispatchQueue.main.async { self.state = self.state.next - self.alertManager.playbackAlertsFromPersistence() Task { await self.resumeLaunch() + await self.alertManager.playbackAlertsFromPersistence() } } } @@ -656,7 +654,7 @@ class LoopAppManager: NSObject { self.state = state.next - alertManager.playbackAlertsFromPersistence() + await alertManager.playbackAlertsFromPersistence() } // MARK: - Life Cycle @@ -667,8 +665,10 @@ class LoopAppManager: NSObject { } settingsManager?.didBecomeActive() deviceDataManager?.didBecomeActive() - alertManager?.inferDeliveredLoopNotRunningNotifications() - + Task { + await alertManager?.inferDeliveredLoopNotRunningNotifications() + } + widgetLog.default("Refreshing widget. Reason: App didBecomeActive") WidgetCenter.shared.reloadAllTimelines() } @@ -803,59 +803,32 @@ class LoopAppManager: NSObject { // MARK: - AlertPresenter extension LoopAppManager: AlertPresenter { - func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { - DispatchQueue.main.async { - self.rootViewController?.topmostViewController.present(viewControllerToPresent, animated: animated, completion: completion) + func present(_ viewControllerToPresent: UIViewController, animated: Bool) async { + await withCheckedContinuation { continuation in + self.rootViewController?.topmostViewController.present(viewControllerToPresent, animated: animated) { + continuation.resume() + } } } - func dismissTopMost(animated: Bool, completion: (() -> Void)?) { - rootViewController?.topmostViewController.dismiss(animated: animated, completion: completion) + func dismissTopMost(animated: Bool) async { + await withCheckedContinuation { continuation in + rootViewController?.topmostViewController.dismiss(animated: animated) { + continuation.resume() + } + } } - func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { - if rootViewController?.topmostViewController == alertToDismiss { - dismissTopMost(animated: animated, completion: completion) - } else { - // check if the alert to dismiss is presenting another alert (and so on) - // calling dismiss() on an alert presenting another alert will only dismiss the presented alert - // (and any other alerts presented by the presented alert) - - // get the stack of presented alerts that would be undesirably dismissed - var presentedAlerts: [UIAlertController] = [] - var currentAlert = alertToDismiss - while let presentedAlert = currentAlert.presentedViewController as? UIAlertController { - presentedAlerts.append(presentedAlert) - currentAlert = presentedAlert - } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool) async { + await alertToDismiss.dismiss(animated: animated) + } +} - if presentedAlerts.isEmpty { - alertToDismiss.dismiss(animated: animated, completion: completion) - } else { - // Do not animate any of these view transitions, since the alert to dismiss is not at the top of the stack - - // dismiss all the child presented alerts. - // Calling dismiss() on a VC that is presenting an other VC will dismiss the presented VC and all of its child presented VCs - alertToDismiss.dismiss(animated: false) { - // dismiss the desired alert - // Calling dismiss() on a VC that is NOT presenting any other VCs will dismiss said VC - alertToDismiss.dismiss(animated: false) { - // present the child alerts that were undesirably dismissed - var orderedPresentationBlock: (() -> Void)? = nil - for alert in presentedAlerts.reversed() { - if alert == presentedAlerts.last { - orderedPresentationBlock = { - self.present(alert, animated: false, completion: completion) - } - } else { - orderedPresentationBlock = { - self.present(alert, animated: false, completion: orderedPresentationBlock) - } - } - } - orderedPresentationBlock?() - } - } +extension UIViewController { + func dismiss(animated flag: Bool) async { + await withCheckedContinuation { continuation in + self.dismiss(animated: flag) { + continuation.resume() } } } @@ -900,7 +873,8 @@ extension LoopAppManager: @preconcurrency DeviceOrientationController { // MARK: - UNUserNotificationCenterDelegate extension LoopAppManager: UNUserNotificationCenterDelegate { - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { switch notification.request.identifier { // TODO: Until these notifications are converted to use the new alert system, they shall still show in the foreground case LoopNotificationCategory.bolusFailure.rawValue, @@ -919,7 +893,9 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { } } - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + log.default("didReceive UNNotificationResponse: %{public}@", String(describing: response)) switch response.actionIdentifier { case NotificationManager.Action.retryBolus.rawValue: if let units = response.notification.request.content.userInfo[LoopNotificationUserInfoKey.bolusAmount.rawValue] as? Double, @@ -929,18 +905,17 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { startDate.timeIntervalSinceNow >= TimeInterval(minutes: -5) { analyticsServicesManager.didRetryBolus() - - Task { @MainActor in - try? await deviceDataManager?.enactBolus(units: units, decisionId: UUID(uuidString: response.notification.request.content.userInfo[LoopNotificationUserInfoKey.decisionId.rawValue] as? String ?? ""), activationType: activationType) - completionHandler() - } + try? await deviceDataManager?.enactBolus(units: units, decisionId: UUID(uuidString: response.notification.request.content.userInfo[LoopNotificationUserInfoKey.decisionId.rawValue] as? String ?? ""), activationType: activationType) } case NotificationManager.Action.acknowledgeAlert.rawValue: let userInfo = response.notification.request.content.userInfo if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? LoopKit.Alert.AlertIdentifier, - let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String { - alertManager?.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: alertIdentifier)) + let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String + { + try? await alertManager?.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: + alertIdentifier)) } + case UNNotificationDefaultActionIdentifier: guard response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue else { break @@ -964,10 +939,14 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { rootViewController?.restoreUserActivityState(carbActivity) default: - break + let userInfo = response.notification.request.content.userInfo + if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? LoopKit.Alert.AlertIdentifier, + let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String + { + let identifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: alertIdentifier) + try? await alertManager.userDidSelectAction(alertIdentifier: identifier, actionIdentifier: response.actionIdentifier) + } } - - completionHandler() } } @@ -976,8 +955,10 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { // MARK: - UNUserNotificationCenterDelegate extension LoopAppManager: TemporaryScheduleOverrideHistoryDelegate { - func temporaryScheduleOverrideHistoryDidUpdate(_ history: TemporaryScheduleOverrideHistory) { - remoteDataServicesManager.triggerUpload(for: .overrides) + nonisolated func temporaryScheduleOverrideHistoryDidUpdate(_ history: TemporaryScheduleOverrideHistory) { + Task { + await remoteDataServicesManager.triggerUpload(for: .overrides) + } } } @@ -987,12 +968,14 @@ extension LoopAppManager: ResetLoopManagerDelegate { } func presentConfirmationAlert(confirmAction: @escaping (PumpManager?, @escaping () -> Void) -> Void, cancelAction: @escaping () -> Void) { - alertManager.presentLoopResetConfirmationAlert( - confirmAction: { [weak self] completion in - confirmAction(self?.deviceDataManager.pumpManager, completion) - }, - cancelAction: cancelAction - ) + Task { + await alertManager.presentLoopResetConfirmationAlert( + confirmAction: { [weak self] completion in + confirmAction(self?.deviceDataManager.pumpManager, completion) + }, + cancelAction: cancelAction + ) + } } func loopWillReset() { @@ -1024,7 +1007,9 @@ extension LoopAppManager: ResetLoopManagerDelegate { } func presentCouldNotResetLoopAlert(error: Error) { - alertManager.presentCouldNotResetLoopAlert(error: error) + Task { + await alertManager.presentCouldNotResetLoopAlert(error: error) + } } } @@ -1105,7 +1090,7 @@ extension LoopAppManager: SimulatedData { fatalError("\(#function) should be invoked only when simulated core data is enabled") } - guard let settingsStore = settingsManager.settingsStore else { + guard settingsManager.settingsStore != nil else { fatalError("\(#function) invoke with no settings store") } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 9d15e1761d..9aebe81dd3 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -238,6 +238,7 @@ final class LoopDataManager: ObservableObject { Task { @MainActor in self.logger.default("Received notification of settings changing") await self.updateDisplayState() + self.notify(forChange: .forecast) } } } diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 242b086c07..bf344b0936 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -16,6 +16,7 @@ enum NotificationManager { enum Action: String { case retryBolus case acknowledgeAlert + case startPreset } } @@ -40,7 +41,7 @@ extension NotificationManager { let acknowledgeAlertAction = UNNotificationAction( identifier: Action.acknowledgeAlert.rawValue, title: NSLocalizedString("OK", comment: "The title of the notification action to acknowledge a device alert"), - options: .foreground + options: [] ) categories.append(UNNotificationCategory( @@ -50,6 +51,26 @@ extension NotificationManager { options: .customDismissAction )) + let yesStartPresetAction = UNNotificationAction( + identifier: Action.startPreset.rawValue, + title: NSLocalizedString("Yes, Start Now", comment: "The title of the notification action to start a preset"), + options: .foreground + ) + + let doNotStartPresetAction = UNNotificationAction( + identifier: Action.acknowledgeAlert.rawValue, + title: NSLocalizedString("Don't Start", comment: "The title of the notification action to not start a preset"), + options: [] + ) + + categories.append(UNNotificationCategory( + identifier: LoopNotificationCategory.presetReminder.rawValue, + actions: [yesStartPresetAction, doNotStartPresetAction], + intentIdentifiers: [], + options: .customDismissAction + )) + + return Set(categories) } diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index 153dd008a7..d3e576edb8 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -352,13 +352,13 @@ 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) - self.uploadSucceeded(key) + continueUpload = queryAnchor != previousQueryAnchor + 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() } @@ -477,10 +477,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() } catch { @@ -521,10 +521,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() } @@ -558,10 +558,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() } @@ -594,10 +594,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() } diff --git a/Loop/Managers/ResetLoopManager.swift b/Loop/Managers/ResetLoopManager.swift index b14829edfa..afc0154a63 100644 --- a/Loop/Managers/ResetLoopManager.swift +++ b/Loop/Managers/ResetLoopManager.swift @@ -8,6 +8,7 @@ import LoopKit +@MainActor protocol ResetLoopManagerDelegate: AnyObject { func loopWillReset() func loopDidReset() @@ -42,33 +43,35 @@ class ResetLoopManager { if UserDefaults.appGroup?.userRequestedLoopReset == true && !resetAlertPresented { resetAlertPresented = true - - delegate?.presentConfirmationAlert( - confirmAction: { [weak self] pumpManager, completion in - self?.resetAlertPresented = false - - guard let pumpManager else { - self?.resetLoop { - completion() - } - return - } - - pumpManager.prepareForDeactivation() { [weak self] error in - guard let error = error else { - self?.resetLoop() { + + Task { @MainActor in + delegate?.presentConfirmationAlert( + confirmAction: { [weak self] pumpManager, completion in + self?.resetAlertPresented = false + + guard let pumpManager else { + self?.resetLoop { completion() } return } - - self?.delegate?.presentCouldNotResetLoopAlert(error: error) + + pumpManager.prepareForDeactivation() { [weak self] error in + guard let error = error else { + self?.resetLoop() { + completion() + } + return + } + + self?.delegate?.presentCouldNotResetLoopAlert(error: error) + } + }, cancelAction: { [weak self] in + self?.resetAlertPresented = false + UserDefaults.appGroup?.userRequestedLoopReset = false } - }, cancelAction: { [weak self] in - self?.resetAlertPresented = false - UserDefaults.appGroup?.userRequestedLoopReset = false - } - ) + ) + } } checkIfLoopIsAlreadyReset() @@ -89,13 +92,15 @@ class ResetLoopManager { } private func resetLoop(completion: @escaping () -> Void) { - delegate?.loopWillReset() - - delegate?.resetTestingData { [weak self] in - self?.resetLoopDocuments() - self?.resetLoopUserDefaults() - self?.delegate?.loopDidReset() - completion() + Task { @MainActor in + delegate?.loopWillReset() + + delegate?.resetTestingData { [weak self] in + self?.resetLoopDocuments() + self?.resetLoopUserDefaults() + self?.delegate?.loopDidReset() + completion() + } } } diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 8a8fc2cc16..a2dc00388a 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -374,16 +374,12 @@ extension ServicesManager: ServiceDelegate { } extension ServicesManager: AlertIssuer { - func issueAlert(_ alert: Alert) { - Task { @MainActor in - alertManager.issueAlert(alert) - } + func issueAlert(_ alert: Alert) async { + await alertManager.issueAlert(alert) } - func retractAlert(identifier: Alert.Identifier) { - Task { @MainActor in - alertManager.retractAlert(identifier: identifier) - } + func retractAlert(identifier: Alert.Identifier) async { + await alertManager.retractAlert(identifier: identifier) } } diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index c475d6b73b..ddee1142e5 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -17,6 +17,7 @@ import os.log import LoopAlgorithm +@MainActor protocol DeviceStatusProvider { var pumpManagerStatus: PumpManagerStatus? { get } var cgmManagerStatus: CGMManagerStatus? { get } @@ -74,7 +75,6 @@ class SettingsManager { settingsStore?.delegate = self - // Migrate old settings from UserDefaults if var legacyLoopSettings = UserDefaults.appGroup?.legacyLoopSettings { diff --git a/Loop/Managers/SupportManager.swift b/Loop/Managers/SupportManager.swift index 5e44909a8d..724c180e28 100644 --- a/Loop/Managers/SupportManager.swift +++ b/Loop/Managers/SupportManager.swift @@ -12,6 +12,7 @@ import LoopKit import LoopKitUI import SwiftUI +@MainActor public protocol DeviceSupportDelegate { var availableSupports: [SupportUI] { get } var pumpManagerStatus: LoopKit.PumpManagerStatus? { get } @@ -239,12 +240,12 @@ extension SupportManager: SupportUIDelegate { await deviceSupportDelegate.generateDiagnosticReport() } - public func issueAlert(_ alert: LoopKit.Alert) { - alertIssuer.issueAlert(alert) + public func issueAlert(_ alert: LoopKit.Alert) async { + await alertIssuer.issueAlert(alert) } - public func retractAlert(identifier: LoopKit.Alert.Identifier) { - alertIssuer.retractAlert(identifier: identifier) + public func retractAlert(identifier: LoopKit.Alert.Identifier) async { + await alertIssuer.retractAlert(identifier: identifier) } } @@ -331,6 +332,7 @@ extension SupportUI { } extension Bundle { + @MainActor fileprivate func loadAndInstantiateSupport() throws -> SupportUI? { try loadAndReturnError() diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 69486f2cdd..46f6e9f3c5 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -11,6 +11,7 @@ import LoopKit import os.log import LoopCore + protocol PresetActivationObserver: AnyObject { func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) func presetDeactivated(context: TemporaryScheduleOverride.Context) @@ -22,21 +23,26 @@ class TemporaryPresetsManager { @ObservationIgnored private let log = OSLog(category: "TemporaryPresetsManager") + let managerIdentifier = "TemporaryPresetsManager" + @ObservationIgnored private var settingsProvider: SettingsProvider var presetHistory: TemporaryScheduleOverrideHistory + @ObservationIgnored private var alertIssuer: AlertIssuer? + @ObservationIgnored private var presetActivationObservers: [PresetActivationObserver] = [] @ObservationIgnored private var overrideIntentObserver: NSKeyValueObservation? = nil - init(settingsProvider: SettingsProvider) { + init(settingsProvider: SettingsProvider, alertIssuer: AlertIssuer? = nil) { self.settingsProvider = settingsProvider - + self.alertIssuer = alertIssuer + self.presetHistory = TemporaryScheduleOverrideHistoryContainer.shared.fetch() TemporaryScheduleOverrideHistory.relevantTimeWindow = Bundle.main.localCacheDuration - scheduleOverride = presetHistory.activeOverride(at: Date()) + _scheduleOverride = presetHistory.activeOverride(at: Date()) if scheduleOverride?.context == .preMeal { preMealOverride = scheduleOverride @@ -81,7 +87,6 @@ class TemporaryPresetsManager { public var scheduleOverride: TemporaryScheduleOverride? { didSet { - print("didSet scheduleOverride called: \(scheduleOverride)") guard oldValue != scheduleOverride else { return } @@ -307,6 +312,15 @@ class TemporaryPresetsManager { ) } + func startPreset(withIdentifier identifier: String) { + guard let preset = selectablePresets.first(where: { $0.id == identifier }) else { + log.error("Unable to find preset with identifier ${public}@", identifier) + return + } + startPreset(preset) + } + + func startPreset(_ preset: SelectablePreset) { switch preset { case .custom(let temporaryScheduleOverridePreset): @@ -415,6 +429,74 @@ class TemporaryPresetsManager { return lastUsed![id] } + func scheduleNextPresetReminder() async { + + let settings = settingsProvider.settings + + let now = Date() + + let preset = settings.overridePresets.reduce(into: nil as TemporaryPreset?) { result, preset in + if let nextScheduledTime = preset.nextScheduledStartAfter(now) { + if result == nil || nextScheduledTime < (result!.nextScheduledStartAfter(now)!) { + result = preset + } + } + } + + if let preset { + + let nextScheduledPresetReminderIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: preset.id.uuidString) + await alertIssuer?.retractAlert(identifier: nextScheduledPresetReminderIdentifier) + + + let nextScheduledTime = preset.nextScheduledStartAfter(now)! + + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + + let title = NSLocalizedString("Start Scheduled Preset?", comment: "Scheduled preset reminder title") + let body = String( + format: NSLocalizedString("Your %1$@ preset is scheduled for today at %2$@. Would you like to start it now?\n\nThis will end any active preset.", comment: "Scheduled preset reminder alert body. (1: preset name) (2: time)"), + preset.name, + formatter.string( + from: nextScheduledTime + ) + ) + + let actions = [ + Alert.UserAlertAction( + label: NSLocalizedString("Don't Start", comment: "Label for do not start preset action on scheduled preset reminder alert"), + identifier: "acknowledge", + style: .default + ), + Alert.UserAlertAction( + label: NSLocalizedString("Yes, Start Now", comment: "Label for do yes, start preset now action on scheduled preset reminder alert"), + identifier: "startPreset", + style: .cancel + ) + ] + + let content = Alert.Content(title: title, + body: body, + actions: actions) + + let metadata: Alert.Metadata = ["presetId": Alert.MetadataValue(preset.id.uuidString)] + + let alert = Alert( + identifier: nextScheduledPresetReminderIdentifier, + foregroundContent: content, + backgroundContent: content, + trigger: .delayed(interval: nextScheduledTime.timeIntervalSince(now)), + interruptionLevel: .timeSensitive, + metadata: metadata, + categoryIdentifier: LoopNotificationCategory.presetReminder.rawValue + ) + + await alertIssuer?.issueAlert(alert) + } + } + } extension TemporaryPresetsManager { @@ -423,6 +505,21 @@ extension TemporaryPresetsManager { } } +extension TemporaryPresetsManager : AlertResponder { + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier) async throws { } + + func handleAlertAction(actionIdentifier: String, from alert: Alert) async throws { + if actionIdentifier == NotificationManager.Action.startPreset.rawValue, + let metdata = alert.metadata, + let presetIdentifier = metdata["presetId"]?.wrapped as? String? + { + startPreset(withIdentifier: presetIdentifier!) + } else { + log.error("Could not identify preset to activate for alert action: actionIdentifier=%{public}@, alert=%{public}@", actionIdentifier, String(describing: alert)) + } + } +} + @MainActor public protocol SettingsWithOverridesProvider { var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { get } diff --git a/Loop/Managers/TrustedTimeChecker.swift b/Loop/Managers/TrustedTimeChecker.swift index ee2704a09f..e5055817b8 100644 --- a/Loop/Managers/TrustedTimeChecker.swift +++ b/Loop/Managers/TrustedTimeChecker.swift @@ -111,10 +111,14 @@ class LoopTrustedTimeChecker: TrustedTimeChecker { let alertTitle = String(format: NSLocalizedString("%1$@ Time Settings Need Attention", comment: "Time change alert title"), UIDevice.current.model) let alertBody = String(format: NSLocalizedString("Your %1$@’s time has been changed. %2$@ needs accurate time records to make predictions about your glucose and adjust your insulin accordingly.\n\nCheck in your %1$@ Settings (General / Date & Time) and verify that 'Set Automatically' is turned ON. Failure to resolve could lead to serious under-delivery or over-delivery of insulin.", comment: "Time change alert body. (1: app name)"), UIDevice.current.model, Bundle.main.bundleDisplayName) let content = Alert.Content(title: alertTitle, body: alertBody, acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Alert acknowledgment OK button")) - alertManager?.issueAlert(Alert(identifier: alertIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate)) + Task { + await alertManager?.issueAlert(Alert(identifier: alertIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate)) + } } private func retractTimeChangedAlert() { - alertManager?.retractAlert(identifier: alertIdentifier) + Task { + await alertManager?.retractAlert(identifier: alertIdentifier) + } } } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 98fbf07e87..029e5d0d1b 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -23,6 +23,7 @@ final class WatchDataManager: NSObject { private unowned let glucoseStore: GlucoseStore private unowned let analyticsServicesManager: AnalyticsServicesManager? private unowned let temporaryPresetsManager: TemporaryPresetsManager + private unowned let alertManager: AlertManager init( deviceManager: DeviceDataManager, @@ -32,6 +33,7 @@ final class WatchDataManager: NSObject { glucoseStore: GlucoseStore, analyticsServicesManager: AnalyticsServicesManager?, temporaryPresetsManager: TemporaryPresetsManager, + alertManager: AlertManager, healthStore: HKHealthStore ) { self.deviceManager = deviceManager @@ -41,6 +43,7 @@ final class WatchDataManager: NSObject { self.glucoseStore = glucoseStore self.analyticsServicesManager = analyticsServicesManager self.temporaryPresetsManager = temporaryPresetsManager + self.alertManager = alertManager self.sleepStore = SleepStore(healthStore: healthStore) self.lastBedtimeQuery = UserDefaults.appGroup?.lastBedtimeQuery ?? .distantPast self.bedtime = UserDefaults.appGroup?.bedtime @@ -411,30 +414,21 @@ final class WatchDataManager: NSObject { // When we've started the bolus, send a new context with our new prediction self.sendWatchContextIfNeeded() } -} - -extension WatchDataManager: WCSessionDelegate { - func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + func handleWatchMessage(_ message: [String: Any]) async -> [String: Any] { switch message["name"] as? String { case PotentialCarbEntryUserInfo.name?: if let potentialCarbEntry = PotentialCarbEntryUserInfo(rawValue: message)?.carbEntry { - Task { @MainActor in - let context = await createWatchContext(recommendingBolusFor: potentialCarbEntry) - replyHandler(context.rawValue) - } + let context = await createWatchContext(recommendingBolusFor: potentialCarbEntry) + return context.rawValue } else { log.error("Could not recommend bolus from from unknown message: %{public}@", String(describing: message)) - replyHandler([:]) } case SetBolusUserInfo.name?: // Add carbs if applicable; start the bolus and reply when it's successfully requested - Task { @MainActor in + Task { try await addCarbEntryAndBolusFromWatchMessage(message) } - // Reply immediately - replyHandler([:]) - case LoopSettingsUserInfo.name?: if let userInfo = LoopSettingsUserInfo(rawValue: message) { // So far we only support watch changes of temporary schedule overrides @@ -446,107 +440,125 @@ extension WatchDataManager: WCSessionDelegate { lastSentUserInfo?.scheduleOverride = userInfo.scheduleOverride } - // Since target range affects recommended bolus, send back a new one - Task { @MainActor in - let context = await createWatchContext() - replyHandler(context.rawValue) - } + let context = await createWatchContext() + return context.rawValue case CarbBackfillRequestUserInfo.name?: if let userInfo = CarbBackfillRequestUserInfo(rawValue: message) { - carbStore.getSyncCarbObjects(start: userInfo.startDate) { (result) in - switch result { - case .failure(let error): - self.log.error("%{public}@", String(describing: error)) - replyHandler([:]) - case .success(let objects): - replyHandler(WatchHistoricalCarbs(objects: objects).rawValue) - } + do { + let objects = try await carbStore.getSyncCarbObjects(start: userInfo.startDate) + return WatchHistoricalCarbs(objects: objects).rawValue + } catch { + self.log.error("%{public}@", String(describing: error)) + return [:] } } else { - replyHandler([:]) + return [:] } case GlucoseBackfillRequestUserInfo.name?: if let userInfo = GlucoseBackfillRequestUserInfo(rawValue: message) { - 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([:]) - } + do { + let samples = try await glucoseStore.getSyncGlucoseSamples(start: userInfo.startDate.addingTimeInterval(1)) + return WatchHistoricalGlucose(samples: samples).rawValue + } catch { + self.log.error("Failure getting sync glucose objects: %{public}@", String(describing: error)) + return [:] } } else { - replyHandler([:]) + return [:] } case WatchContextRequestUserInfo.name?: - Task { @MainActor in - let context = await createWatchContext() - replyHandler(context.rawValue) + return await createWatchContext().rawValue + case NotificationActionSelection.name?: + if let selection = NotificationActionSelection(rawValue: message) { + let identifier = Alert.Identifier( + managerIdentifier: selection.managerIdentifier, + alertIdentifier: selection.alertIdentifier + ) + try? await alertManager.userDidSelectAction(alertIdentifier: identifier, actionIdentifier: selection.actionIdentifier) } default: - replyHandler([:]) + return [:] } + + return [:] } +} + - func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { +extension WatchDataManager: WCSessionDelegate { + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + Task { @MainActor in + let reply = await handleWatchMessage(message) + replyHandler(reply) + } + } + + nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { assertionFailure("We currently don't expect any userInfo messages transferred from the watch side") } - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - switch activationState { - case .activated: - if let error = error { - log.error("%{public}@", String(describing: error)) - } else { - sendSettingsIfNeeded() - sendWatchContextIfNeeded() - sendSupportedBolusVolumesIfNeeded() + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + Task { @MainActor in + switch activationState { + case .activated: + if let error = error { + log.error("%{public}@", String(describing: error)) + } else { + sendSettingsIfNeeded() + sendWatchContextIfNeeded() + sendSupportedBolusVolumesIfNeeded() + } + case .inactive, .notActivated: + break + @unknown default: + break } - case .inactive, .notActivated: - break - @unknown default: - break } } - func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) { - if let error = error { - log.error("%{public}@", String(describing: error)) - - // This might be useless, as userInfoTransfer.userInfo seems to be nil when error is non-nil. - switch userInfoTransfer.userInfo["name"] as? String { - case nil: - lastSentUserInfo = nil - sendSettingsIfNeeded() - lastSentBolusVolumes = nil - sendSupportedBolusVolumesIfNeeded() - case LoopSettingsUserInfo.name: - lastSentUserInfo = nil - sendSettingsIfNeeded() - case SupportedBolusVolumesUserInfo.name: - lastSentBolusVolumes = nil - sendSupportedBolusVolumesIfNeeded() - default: - break + nonisolated func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) { + Task { @MainActor in + if let error = error { + log.error("%{public}@", String(describing: error)) + + // This might be useless, as userInfoTransfer.userInfo seems to be nil when error is non-nil. + switch userInfoTransfer.userInfo["name"] as? String { + case nil: + lastSentUserInfo = nil + sendSettingsIfNeeded() + lastSentBolusVolumes = nil + sendSupportedBolusVolumesIfNeeded() + case LoopSettingsUserInfo.name: + lastSentUserInfo = nil + sendSettingsIfNeeded() + case SupportedBolusVolumesUserInfo.name: + lastSentBolusVolumes = nil + sendSupportedBolusVolumesIfNeeded() + default: + break + } } } } - func sessionDidBecomeInactive(_ session: WCSession) { + nonisolated func sessionDidBecomeInactive(_ session: WCSession) { // Nothing to do here } - func sessionDidDeactivate(_ session: WCSession) { - lastSentUserInfo = nil - watchSession = WCSession.default - watchSession?.delegate = self - watchSession?.activate() + nonisolated func sessionDidDeactivate(_ session: WCSession) { + Task { @MainActor in + lastSentUserInfo = nil + watchSession = WCSession.default + watchSession?.delegate = self + watchSession?.activate() + } } - func sessionReachabilityDidChange(_ session: WCSession) { - sendSettingsIfNeeded() - sendSupportedBolusVolumesIfNeeded() + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + Task { @MainActor in + sendSettingsIfNeeded() + sendSupportedBolusVolumesIfNeeded() + } } } diff --git a/Loop/Models/CrashRecoveryManager.swift b/Loop/Models/CrashRecoveryManager.swift index 2e2a249e9c..5df015dac9 100644 --- a/Loop/Models/CrashRecoveryManager.swift +++ b/Loop/Models/CrashRecoveryManager.swift @@ -63,12 +63,14 @@ class CrashRecoveryManager { trigger: .immediate, interruptionLevel: .critical) - self.alertIssuer.issueAlert(alert) + Task { + await self.alertIssuer.issueAlert(alert) + } } } extension CrashRecoveryManager: AlertResponder { - func acknowledgeAlert(alertIdentifier: LoopKit.Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + func acknowledgeAlert(alertIdentifier: LoopKit.Alert.AlertIdentifier) async throws { UserDefaults.appGroup?.inFlightAutomaticDose = nil doseRecoveredFromCrash = nil } diff --git a/Loop/Models/SelectablePreset.swift b/Loop/Models/SelectablePreset.swift index 5bcc99e9d9..b489e4c551 100644 --- a/Loop/Models/SelectablePreset.swift +++ b/Loop/Models/SelectablePreset.swift @@ -169,6 +169,19 @@ enum SelectablePreset: Hashable, Identifiable { } } + var isScheduled: Bool { + return nextScheduledStartAfter(Date()) != nil + } + + func nextScheduledStartAfter(_ date: Date) -> Date? { + switch self { + case .custom(let preset): + return preset.nextScheduledStartAfter(date) + case .preMeal, .legacyWorkout: + return nil + } + } + var scheduleStartDate: Date? { get { switch self { diff --git a/Loop/Plugins/PluginManager.swift b/Loop/Plugins/PluginManager.swift index 3128ec4c61..7064bda19f 100644 --- a/Loop/Plugins/PluginManager.swift +++ b/Loop/Plugins/PluginManager.swift @@ -11,6 +11,7 @@ import Foundation import LoopKit import LoopKitUI +@MainActor class PluginManager { let pluginBundles: [Bundle] diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index c5253445b5..ca035f1f46 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -121,12 +121,11 @@ final class StatusTableViewController: LoopChartsTableViewController { self?.refreshContext.update(with: .carbs) case .glucose?: self?.refreshContext.formUnion([.glucose, .carbs]) - default: - break + case .forecast?: + self?.refreshContext.update(with: .glucose) } self?.hudView?.loopCompletionHUD.loopInProgress = false - self?.log.debug("[reloadData] from notification with context %{public}@", String(describing: context)) await self?.reloadData(animated: true) } @@ -733,7 +732,6 @@ final class StatusTableViewController: LoopChartsTableViewController { private var canceledDose: DoseEntry? = nil private func determinePresetsRowMode() -> PresetsRowMode { - print("temporaryPresetsManager.scheduleOverride = \(String(describing: temporaryPresetsManager.scheduleOverride))") if let preset = temporaryPresetsManager.scheduleOverride ?? temporaryPresetsManager.preMealOverride, !preset.hasFinished() { return .scheduleOverrideEnabled(preset) } else { diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index d04a3f6fd1..5e310f5150 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -111,6 +111,7 @@ class SettingsViewModel { @ObservationIgnored weak var favoriteFoodInsightsDelegate: FavoriteFoodInsightsViewModelDelegate? + @MainActor var showDeleteTestData: Bool { availableSupports.contains(where: { $0.showsDeleteTestDataUI }) } diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index a4ecfca602..b35035ad93 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -23,7 +23,8 @@ struct PresetCard: View { let correctionRange: ClosedRange? let guardrail: Guardrail? let expectedEndTime: PresetExpectedEndTime? - + let isScheduled: Bool + var presetTitle: some View { HStack(spacing: 6) { switch icon { @@ -42,7 +43,14 @@ struct PresetCard: View { .accessibilityIdentifier("text_Preset\(presetName)") } } - + + var reminderIcon: some View { + Text(Image(systemName: "alarm")) + .font(.footnote) + .foregroundColor(.carbs) + .accessibilityLabel(Text("Scheduled reminder")) + } + var presetDuration: some View { Group { Text(Image(systemName: "timer")) + Text(" \(duration.localizedTitle)") } .font(.footnote) @@ -76,7 +84,10 @@ struct PresetCard: View { if expectedEndTime == nil { presetDuration - } + if isScheduled { + reminderIcon + } + } } VStack(alignment: .leading, spacing: 10) { @@ -93,7 +104,8 @@ struct PresetCard: View { insulinMultiplier: insulinMultiplier, correctionRange: correctionRange, guardrail: guardrail, - therapySettingsImpactDisplayState: .hide + therapySettingsImpactDisplayState: .hide, + isScheduled: isScheduled && expectedEndTime != nil ) } .padding(10) diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index c4b23ad015..0e13f3c54a 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -38,7 +38,15 @@ struct PresetDetentView: View { Group { switch operation { case .start: - Text("Duration: \(preset.duration.localizedTitle)") + HStack { + if preset.isScheduled { + Text(Image(systemName: "alarm")) + .font(.footnote) + .foregroundColor(.carbs) + .accessibilityLabel(Text("Scheduled reminder")) + } + Text("Duration: \(preset.duration.localizedTitle)") + } case .end: if let activeOverride = temporaryPresetsManager.activeOverride { if activeOverride.presetId == preset.id { @@ -134,7 +142,8 @@ struct PresetDetentView: View { insulinMultiplier: preset.insulinNeedsScaleFactor, correctionRange: preset.correctionRange, guardrail: settingsManager.guardrailForPreset(preset), - therapySettingsImpactDisplayState: operation == .end ? .show(settingsImpact) : .hide + therapySettingsImpactDisplayState: operation == .end ? .show(settingsImpact) : .hide, + isScheduled: false ) actionArea diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift index 0da1c1606c..440660f118 100644 --- a/Loop/Views/Presets/Components/PresetStatsView.swift +++ b/Loop/Views/Presets/Components/PresetStatsView.swift @@ -24,7 +24,8 @@ struct PresetStatsView: View { let correctionRange: ClosedRange? let guardrail: Guardrail? let therapySettingsImpactDisplayState: TherapySettingsImpactDisplayState - + let isScheduled: Bool + private var numberFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .percent @@ -139,6 +140,13 @@ struct PresetStatsView: View { overallInsulinView Spacer() correctionRangeView + if isScheduled { + Spacer() + Text(Image(systemName: "alarm")) + .font(.footnote) + .foregroundColor(.carbs) + .accessibilityLabel(Text("Scheduled reminder")) + } } VStack(alignment: .leading, spacing: 16) { diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift index a44ad41436..3e629f4b11 100644 --- a/Loop/Views/Presets/CreatePresetView.swift +++ b/Loop/Views/Presets/CreatePresetView.swift @@ -120,6 +120,9 @@ struct CreatePresetView: View { if let temporaryScheduleOverride = preset.temporaryScheduleOverride { if preset.savePreset, case .preset(let preset) = temporaryScheduleOverride.context { settingsManager.createPreset(preset) + Task { + await temporaryPresetsManager.scheduleNextPresetReminder() + } } if startPreset { temporaryPresetsManager.scheduleOverride = temporaryScheduleOverride diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index ae3d1d368b..d96bc6336e 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -181,7 +181,7 @@ struct EditPresetView: View { Spacer() Toggle("", isOn: Binding(get: { - return preset.scheduleStartDate != nil + return preset.isScheduled }, set: { newValue in withAnimation { if newValue { @@ -203,11 +203,11 @@ struct EditPresetView: View { .padding(.vertical, -4) } - if preset.scheduleStartDate != nil { + if preset.isScheduled { Divider() HStack { if preset.repeatOptions != .none { - Text("Date") + Text("Next Date") } else { Text("Start Date") } @@ -215,11 +215,11 @@ struct EditPresetView: View { DatePicker( "", selection: Binding(get: { - preset.scheduleStartDate ?? Date() + preset.nextScheduledStartAfter(Date()) ?? Date() }, set: { newValue in preset.scheduleStartDate = newValue }), - in: Date()..., + in: Date().addingTimeInterval(.minutes(1))..., displayedComponents: [.date, .hourAndMinute] ) } diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index e2112d2879..1a34eb12e9 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -136,6 +136,7 @@ struct PresetsView: View { PresetCard( preset, guardrail: settingsManager.guardrailForPreset(preset) + ) .cornerRadius(12) .onTapGesture { @@ -220,9 +221,15 @@ struct PresetsView: View { scheduledRange: scheduledRange, onSave: { updatedPreset in settingsManager.savePreset(updatedPreset) + Task { + await temporaryPresetsManager.scheduleNextPresetReminder() + } }, onDelete: { preset in settingsManager.deletePreset(preset) + Task { + await temporaryPresetsManager.scheduleNextPresetReminder() + } } ) } @@ -303,7 +310,8 @@ extension PresetCard { insulinMultiplier: preset.insulinNeedsScaleFactor, correctionRange: preset.correctionRange, guardrail: guardrail, - expectedEndTime: expectedEndTime + expectedEndTime: expectedEndTime, + isScheduled: preset.isScheduled ) } } diff --git a/LoopTests/Managers/Alerts/AlertManagerTests.swift b/LoopTests/Managers/Alerts/AlertManagerTests.swift index 06aec6dd68..6797845483 100644 --- a/LoopTests/Managers/Alerts/AlertManagerTests.swift +++ b/LoopTests/Managers/Alerts/AlertManagerTests.swift @@ -50,53 +50,53 @@ class AlertManagerTests: XCTestCase { mockAlertStore = nil } - func testIssueAlertOnHandlerCalled() { - alertManager.issueAlert(mockAlert) + func testIssueAlertOnHandlerCalled() async { + await alertManager.issueAlert(mockAlert) XCTAssertEqual(mockAlert.identifier, mockModalScheduler.scheduledAlert?.identifier) XCTAssertEqual(mockAlert.identifier, mockUserNotificationScheduler.scheduledAlert?.identifier) XCTAssertNil(mockModalScheduler.unscheduledAlertIdentifier) XCTAssertNil(mockUserNotificationScheduler.unscheduledAlertIdentifier) } - func testRetractAlertOnHandlerCalled() { - alertManager.retractAlert(identifier: mockAlert.identifier) + func testRetractAlertOnHandlerCalled() async { + await alertManager.retractAlert(identifier: mockAlert.identifier) XCTAssertNil(mockModalScheduler.scheduledAlert) XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) XCTAssertEqual(mockAlert.identifier, mockModalScheduler.unscheduledAlertIdentifier) XCTAssertEqual(mockAlert.identifier, mockUserNotificationScheduler.unscheduledAlertIdentifier) } - func testAlertResponderAcknowledged() { + func testAlertResponderAcknowledged() async throws { let responder = MockResponder() alertManager.addAlertResponder(managerIdentifier: Self.mockManagerIdentifier, alertResponder: responder) XCTAssertTrue(responder.acknowledged.isEmpty) - alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) + try await alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) XCTAssert(responder.acknowledged[Self.mockTypeIdentifier] == true) } - func testAlertResponderNotAcknowledgedIfWrongManagerIdentifier() { + func testAlertResponderNotAcknowledgedIfWrongManagerIdentifier() async throws { let responder = MockResponder() alertManager.addAlertResponder(managerIdentifier: Self.mockManagerIdentifier, alertResponder: responder) XCTAssertTrue(responder.acknowledged.isEmpty) - alertManager.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: "foo", alertIdentifier: Self.mockTypeIdentifier)) + try await alertManager.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: "foo", alertIdentifier: Self.mockTypeIdentifier)) XCTAssertTrue(responder.acknowledged.isEmpty) } - func testRemovedAlertResponderDoesntAcknowledge() { + func testRemovedAlertResponderDoesntAcknowledge() async throws { let responder = MockResponder() alertManager.addAlertResponder(managerIdentifier: Self.mockManagerIdentifier, alertResponder: responder) XCTAssertTrue(responder.acknowledged.isEmpty) - alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) + try await alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) XCTAssert(responder.acknowledged[Self.mockTypeIdentifier] == true) responder.acknowledged[AlertManagerTests.mockTypeIdentifier] = false alertManager.removeAlertResponder(managerIdentifier: AlertManagerTests.mockManagerIdentifier) - alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) + try await alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) XCTAssert(responder.acknowledged[Self.mockTypeIdentifier] == false) } - func testAcknowledgedAlertsRemovedFromUserNotificationCenter() { - alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) + func testAcknowledgedAlertsRemovedFromUserNotificationCenter() async throws { + try await alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) } func testSoundVendorInitialization() { @@ -108,235 +108,219 @@ class AlertManagerTests: XCTestCase { XCTAssertEqual(["\(Self.mockManagerIdentifier)-doesntExist", "\(Self.mockManagerIdentifier)-existsOlder"], mockFileManager.copiedDstURLs.map { $0.lastPathComponent }) } - func testPlaybackPendingImmediateAlert() { - mockAlertStore.managedObjectContext.performAndWait { - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .immediate) - mockAlertStore.storedAlerts = [StoredAlert(from: alert, context: mockAlertStore.managedObjectContext)] + func testPlaybackPendingImmediateAlert() async { + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .immediate) + mockAlertStore.storedAlerts = [StoredAlert(from: alert, context: mockAlertStore.managedObjectContext)] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - alertManager.playbackAlertsFromPersistence() - XCTAssertEqual(alert, mockModalScheduler.scheduledAlert) - XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) - } + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + mockModalScheduler.alertScheduledExpectation = expectation(description: "alert scheduled") + await alertManager.playbackAlertsFromPersistence() + await fulfillment(of: [mockModalScheduler.alertScheduledExpectation!]) + XCTAssertEqual(alert, mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) } - func testPlaybackPendingExpiredDelayedNotification() { - mockAlertStore.managedObjectContext.performAndWait { - let date = Date.distantPast - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .delayed(interval: 30.0)) - let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) - storedAlert.issuedDate = date - mockAlertStore.storedAlerts = [storedAlert] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - alertManager.playbackAlertsFromPersistence() - let expected = Alert(identifier: Self.mockIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate) - XCTAssertEqual(expected, mockModalScheduler.scheduledAlert) - XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) - } + func testPlaybackPendingExpiredDelayedNotification() async { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .delayed(interval: 30.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + await alertManager.playbackAlertsFromPersistence() + let expected = Alert(identifier: Self.mockIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate) + XCTAssertEqual(expected, mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) } - func testPlaybackPendingDelayedNotification() { - mockAlertStore.managedObjectContext.performAndWait { - let date = Date().addingTimeInterval(-15.0) // Pretend the 30-second-delayed alert was issued 15 seconds ago - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .delayed(interval: 30.0)) - let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) - storedAlert.issuedDate = date - mockAlertStore.storedAlerts = [storedAlert] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - alertManager.playbackAlertsFromPersistence() + func testPlaybackPendingDelayedNotification() async { + let date = Date().addingTimeInterval(-15.0) // Pretend the 30-second-delayed alert was issued 15 seconds ago + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .delayed(interval: 30.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + await alertManager.playbackAlertsFromPersistence() - // The trigger for this should be `.delayed` by "something less than 15 seconds", - // but the exact value depends on the speed of executing this test. - // As long as it is <= 15 seconds, we call it good. - XCTAssertNotNil(mockModalScheduler.scheduledAlert) - switch mockModalScheduler.scheduledAlert?.trigger { - case .some(.delayed(let interval)): - XCTAssertLessThanOrEqual(interval, 15.0) - default: - XCTFail("Wrong trigger \(String(describing: mockModalScheduler.scheduledAlert?.trigger))") - } + // The trigger for this should be `.delayed` by "something less than 15 seconds", + // but the exact value depends on the speed of executing this test. + // As long as it is <= 15 seconds, we call it good. + XCTAssertNotNil(mockModalScheduler.scheduledAlert) + switch mockModalScheduler.scheduledAlert?.trigger { + case .some(.delayed(let interval)): + XCTAssertLessThanOrEqual(interval, 15.0) + default: + XCTFail("Wrong trigger \(String(describing: mockModalScheduler.scheduledAlert?.trigger))") } } - func testPlaybackPendingRepeatingNotification() { - mockAlertStore.managedObjectContext.performAndWait { - let date = Date.distantPast - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) - let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) - storedAlert.issuedDate = date - mockAlertStore.storedAlerts = [storedAlert] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - alertManager.playbackAlertsFromPersistence() + func testPlaybackPendingRepeatingNotification() async { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + await alertManager.playbackAlertsFromPersistence() - XCTAssertEqual(alert, mockModalScheduler.scheduledAlert) - XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) - } + XCTAssertEqual(alert, mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) } - func testPersistedAlertStoreLookupAllUnretracted() throws { - mockAlertStore.managedObjectContext.performAndWait { - let date = Date.distantPast - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) - let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) - storedAlert.issuedDate = date - mockAlertStore.storedAlerts = [storedAlert] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - alertManager.lookupAllUnretracted(managerIdentifier: Self.mockManagerIdentifier) { result in - try? XCTAssertEqual([PersistedAlert(alert: alert, issuedDate: date, retractedDate: nil, acknowledgedDate: nil)], - XCTUnwrap(result.successValue)) - } - } + func testPersistedAlertStoreLookupAllUnretracted() async throws { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + let alerts = try await alertManager.lookupAllUnretracted(managerIdentifier: Self.mockManagerIdentifier) + XCTAssertEqual([PersistedAlert(alert: alert, issuedDate: date, retractedDate: nil, acknowledgedDate: nil)], alerts) } - func testPersistedAlertStoreLookupAllUnacknowledgedUnretracted() throws { - mockAlertStore.managedObjectContext.performAndWait { - let date = Date.distantPast - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) - let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) - storedAlert.issuedDate = date - mockAlertStore.storedAlerts = [storedAlert] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - alertManager.lookupAllUnacknowledgedUnretracted(managerIdentifier: Self.mockManagerIdentifier) { result in - try? XCTAssertEqual([PersistedAlert(alert: alert, issuedDate: date, retractedDate: nil, acknowledgedDate: nil)], - XCTUnwrap(result.successValue)) - } - } + func testPersistedAlertStoreLookupAllUnacknowledgedUnretracted() async throws { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + let alerts = try await alertManager.lookupAllUnacknowledgedUnretracted(managerIdentifier: Self.mockManagerIdentifier) + XCTAssertEqual([PersistedAlert(alert: alert, issuedDate: date, retractedDate: nil, acknowledgedDate: nil)], alerts) } - func testPersistedAlertStoreDoesIssuedAlertExist() throws { - mockAlertStore.managedObjectContext.performAndWait { - let date = Date.distantPast - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) - let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) - storedAlert.issuedDate = date - mockAlertStore.storedAlerts = [storedAlert] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - let identifierExists = Self.mockIdentifier - let identifierDoesNotExist = Alert.Identifier(managerIdentifier: "TestManagerIdentifier", alertIdentifier: "TestAlertIdentifier") - alertManager.doesIssuedAlertExist(identifier: identifierExists) { result in - try? XCTAssertEqual(true, XCTUnwrap(result.successValue)) - } - alertManager.doesIssuedAlertExist(identifier: identifierDoesNotExist) { result in - try? XCTAssertEqual(false, XCTUnwrap(result.successValue)) - } - } + func testPersistedAlertStoreDoesIssuedAlertExist() async throws { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + let identifierExists = Self.mockIdentifier + let identifierDoesNotExist = Alert.Identifier(managerIdentifier: "TestManagerIdentifier", alertIdentifier: "TestAlertIdentifier") + let result = try await alertManager.doesIssuedAlertExist(identifier: identifierExists) + XCTAssertEqual(true, result) + let result2 = try await alertManager.doesIssuedAlertExist(identifier: identifierDoesNotExist) + XCTAssertEqual(false, result2) } - func testReportRetractedAlert() throws { - mockAlertStore.managedObjectContext.performAndWait { - let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") - let alert = Alert(identifier: Self.mockIdentifier, - foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) - mockAlertStore.storedAlerts = [] - alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertScheduler: mockModalScheduler, - userNotificationAlertScheduler: mockUserNotificationScheduler, - fileManager: mockFileManager, - alertStore: mockAlertStore, - bluetoothProvider: MockBluetoothProvider(), - analyticsServicesManager: AnalyticsServicesManager()) - let now = Date() - alertManager.recordRetractedAlert(alert, at: now) - XCTAssertEqual(mockAlertStore.retractedAlert, alert) - XCTAssertEqual(mockAlertStore.retractedAlertDate, now) - } + func testReportRetractedAlert() async throws { + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + mockAlertStore.storedAlerts = [] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + let now = Date() + try await alertManager.recordRetractedAlert(alert, at: now) + XCTAssertEqual(mockAlertStore.retractedAlert, alert) + XCTAssertEqual(mockAlertStore.retractedAlertDate, now) } - func testScheduleAlertForWorkoutReminder() { + func testScheduleAlertForWorkoutReminder() async { + mockModalScheduler.alertScheduledExpectation = expectation(description: "modal alert scheduled") alertManager.presetActivated(context: .legacyWorkout, duration: .indefinite) + await fulfillment(of: [mockModalScheduler.alertScheduledExpectation!], timeout: 1) XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockModalScheduler.scheduledAlert?.identifier) XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockUserNotificationScheduler.scheduledAlert?.identifier) XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockAlertStore.issuedAlert?.identifier) + mockModalScheduler.alertUnscheduledExpectation = expectation(description: "modal alert unscheduled") alertManager.presetDeactivated(context: .legacyWorkout) + await fulfillment(of: [mockModalScheduler.alertUnscheduledExpectation!], timeout: 1) XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockModalScheduler.unscheduledAlertIdentifier) XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockUserNotificationScheduler.unscheduledAlertIdentifier) XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockAlertStore.retractededAlertIdentifier) } - func testLoopDidCompleteRecordsNotifications() { - alertManager.loopDidComplete() + func testLoopDidCompleteRecordsNotifications() async { + await alertManager.loopDidComplete() XCTAssertEqual(4, UserDefaults.appGroup?.loopNotRunningNotifications.count) } - func testLoopFailureFor10MinutesDoesNotRecordAlert() { - alertManager.loopDidComplete() + func testLoopFailureFor10MinutesDoesNotRecordAlert() async { + await alertManager.loopDidComplete() XCTAssertNil(mockAlertStore.issuedAlert) alertManager.getCurrentDate = { return Date().addingTimeInterval(.minutes(10))} - alertManager.inferDeliveredLoopNotRunningNotifications() + await alertManager.inferDeliveredLoopNotRunningNotifications() XCTAssertNil(mockAlertStore.issuedAlert) } - func testLoopFailureFor30MinutesRecordsTimeSensitiveAlert() { - alertManager.loopDidComplete() + func testLoopFailureFor30MinutesRecordsTimeSensitiveAlert() async { + await alertManager.loopDidComplete() XCTAssertNil(mockAlertStore.issuedAlert) alertManager.getCurrentDate = { return Date().addingTimeInterval(.minutes(30))} - alertManager.inferDeliveredLoopNotRunningNotifications() + await alertManager.inferDeliveredLoopNotRunningNotifications() XCTAssertEqual(3, UserDefaults.appGroup?.loopNotRunningNotifications.count) XCTAssertNotNil(mockAlertStore.issuedAlert) XCTAssertEqual(.timeSensitive, mockAlertStore.issuedAlert!.interruptionLevel) } - func testLoopFailureFor65MinutesRecordsCriticalAlert() { - alertManager.loopDidComplete() + func testLoopFailureFor65MinutesRecordsCriticalAlert() async { + await alertManager.loopDidComplete() alertManager.getCurrentDate = { return Date().addingTimeInterval(.minutes(65))} - alertManager.inferDeliveredLoopNotRunningNotifications() + await alertManager.inferDeliveredLoopNotRunningNotifications() XCTAssertEqual(1, UserDefaults.appGroup?.loopNotRunningNotifications.count) XCTAssertNotNil(mockAlertStore.issuedAlert) XCTAssertEqual(.critical, mockAlertStore.issuedAlert!.interruptionLevel) @@ -346,7 +330,7 @@ class AlertManagerTests: XCTestCase { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() let lastLoopDate = Date() - alertManager.loopDidComplete(lastLoopDate) + await alertManager.loopDidComplete(lastLoopDate) alertManager.alertMuter.configuration.startTime = Date() alertManager.alertMuter.configuration.duration = .hours(4) diff --git a/LoopTests/Managers/Alerts/AlertStoreTests.swift b/LoopTests/Managers/Alerts/AlertStoreTests.swift index 81ba581e0c..5f56baf124 100644 --- a/LoopTests/Managers/Alerts/AlertStoreTests.swift +++ b/LoopTests/Managers/Alerts/AlertStoreTests.swift @@ -12,13 +12,13 @@ import XCTest @testable import Loop class AlertStoreTests: XCTestCase { - + var alertStore: AlertStore! static let defaultTimeout: TimeInterval = 1.5 static let expiryInterval: TimeInterval = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */ static let historicDate = Date(timeIntervalSinceNow: -expiryInterval + TimeInterval.hours(4)) // Within default 24 hour expiration - + static let identifier1 = Alert.Identifier(managerIdentifier: "managerIdentifier1", alertIdentifier: "alertIdentifier1") static let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "OK") let alert1 = Alert(identifier: identifier1, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .immediate, sound: nil) @@ -35,11 +35,11 @@ class AlertStoreTests: XCTestCase { override func setUp() { alertStore = AlertStore(expireAfter: Self.expiryInterval) } - + override func tearDown() { alertStore = nil } - + func testTriggerTypeIntervalConversion() { let immediate = Alert.Trigger.immediate let delayed = Alert.Trigger.delayed(interval: 1.0) @@ -49,7 +49,7 @@ class AlertStoreTests: XCTestCase { XCTAssertEqual(repeating, try? Alert.Trigger(storedType: repeating.storedType, storedInterval: repeating.storedInterval)) XCTAssertNil(immediate.storedInterval) } - + func testTriggerTypeIntervalConversionAdjustedForStorageTime() { let immediate = Alert.Trigger.immediate let delayed = Alert.Trigger.delayed(interval: 10.0) @@ -66,14 +66,14 @@ class AlertStoreTests: XCTestCase { XCTAssertEqual(repeating, try? Alert.Trigger(storedType: repeating.storedType, storedInterval: repeating.storedInterval, storageDate: Self.historicDate)) XCTAssertNil(immediate.storedInterval) } - + func testStoredAlertSerialization() { alertStore.managedObjectContext.performAndWait { let object = StoredAlert(from: alert2, context: alertStore.managedObjectContext, issuedDate: Self.historicDate) XCTAssertNil(object.acknowledgedDate) XCTAssertNil(object.retractedDate) - XCTAssertEqual("{\"acknowledgeActionButtonLabel\":\"label\",\"body\":\"body\",\"title\":\"title\"}", object.backgroundContent) - XCTAssertEqual("{\"acknowledgeActionButtonLabel\":\"label\",\"body\":\"body\",\"title\":\"title\"}", object.foregroundContent) + XCTAssertEqual("{\"actions\":[{\"identifier\":\"acknowledge\",\"label\":\"label\",\"style\":0}],\"body\":\"body\",\"title\":\"title\"}", object.backgroundContent) + XCTAssertEqual("{\"actions\":[{\"identifier\":\"acknowledge\",\"label\":\"label\",\"style\":0}],\"body\":\"body\",\"title\":\"title\"}", object.foregroundContent) XCTAssertEqual("managerIdentifier2.alertIdentifier2", object.identifier.value) XCTAssertEqual(Self.historicDate, object.issuedDate) XCTAssertEqual(1, object.modificationCounter) @@ -82,7 +82,7 @@ class AlertStoreTests: XCTestCase { XCTAssertEqual(Alert.InterruptionLevel.critical, object.interruptionLevel) } } - + func testQueryAnchorSerialization() { var anchor = AlertStore.QueryAnchor() anchor.modificationCounter = 999 @@ -90,694 +90,456 @@ class AlertStoreTests: XCTestCase { XCTAssertEqual(anchor, newAnchor) XCTAssertEqual(999, newAnchor?.modificationCounter) } - - func testRecordIssued() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(Self.historicDate, storedAlerts.first?.issuedDate) - XCTAssertNil(storedAlerts.first?.acknowledgedDate) - XCTAssertNil(storedAlerts.first?.retractedDate) - expect.fulfill() - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordIssuedTwo() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.recordIssued(alert: self.alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - self.assertEqual([self.alert1, self.alert1], storedAlerts) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordAcknowledged() { - let expect = self.expectation(description: #function) + + func testRecordIssued() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(Self.historicDate, storedAlerts.first?.issuedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + XCTAssertNil(storedAlerts.first?.retractedDate) + } + + func testRecordIssuedTwo() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + await alertStore.recordIssued(alert: self.alert1, at: Self.historicDate) + let storedAlerts = try await alertStore.fetch(identifier: Self.identifier1) + self.assertEqual([self.alert1, self.alert1], storedAlerts) + } + + func testRecordAcknowledged() async throws { let issuedDate = Self.historicDate let acknowledgedDate = issuedDate.addingTimeInterval(1) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) - XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) - XCTAssertNil(storedAlerts.first?.retractedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordAcknowledgedOfInvalid() { - let expect = self.expectation(description: #function) - self.alertStore.recordAcknowledgement(of: Self.identifier1, at: Self.historicDate) { - switch $0 { - case .failure: break - case .success: XCTFail("Unexpected success") - } - expect.fulfill() + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + try await alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) + XCTAssertNil(storedAlerts.first?.retractedDate) + } + + func testRecordAcknowledgedOfInvalid() async throws { + do { + try await self.alertStore.recordAcknowledgement(of: Self.identifier1, at: Self.historicDate) + XCTFail("Unexpected success") + } catch { + return } - wait(for: [expect], timeout: Self.defaultTimeout) } - func testRecordRetracted() { - let expect = self.expectation(description: #function) + func testRecordRetracted() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate.addingTimeInterval(2) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) - XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) - XCTAssertNil(storedAlerts.first?.acknowledgedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordIssuedExpiresOld() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Date.distantPast, completion: self.expectSuccess { - self.alertStore.recordIssued(alert: self.alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(Self.historicDate, storedAlerts.first?.issuedDate) - XCTAssertNil(storedAlerts.first?.acknowledgedDate) - XCTAssertNil(storedAlerts.first?.retractedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + try await self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + } + + func testRecordIssuedExpiresOld() async throws { + await alertStore.recordIssued(alert: alert1, at: Date.distantPast) + await self.alertStore.recordIssued(alert: self.alert1, at: Self.historicDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(Self.historicDate, storedAlerts.first?.issuedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + XCTAssertNil(storedAlerts.first?.retractedDate) + } + func testRecordAcknowledgedExpiresOld() { // TODO: Not quite sure how to do this yet. } - + func testRecordRetractedExpiresOld() { // TODO: Not quite sure how to do this yet. } - func testRecordRetractedBeforeDelayShouldDelete() { - let expect = self.expectation(description: #function) + func testRecordRetractedBeforeDelayShouldDelete() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate + Self.delayedAlertDelay - 1.0 - alertStore.recordIssued(alert: delayedAlert, at: issuedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.delayedAlertIdentifier, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(0, storedAlerts.count) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordRetractedBeforeRepeatDelayShouldDelete() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: delayedAlert, at: issuedDate) + try await self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.delayedAlertIdentifier) + XCTAssertEqual(0, storedAlerts.count) + } + + func testRecordRetractedBeforeRepeatDelayShouldDelete() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate + Self.repeatingAlertDelay - 1.0 - alertStore.recordIssued(alert: repeatingAlert, at: issuedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(0, storedAlerts.count) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordRetractedExactlyAtDelayShouldDelete() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: repeatingAlert, at: issuedDate) + try await self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier) + XCTAssertEqual(0, storedAlerts.count) + } + + func testRecordRetractedExactlyAtDelayShouldDelete() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate + Self.delayedAlertDelay - alertStore.recordIssued(alert: delayedAlert, at: issuedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.delayedAlertIdentifier, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(0, storedAlerts.count) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordRetractedExactlyAtRepeatDelayShouldDelete() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: delayedAlert, at: issuedDate) + try await self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.delayedAlertIdentifier) + XCTAssertEqual(0, storedAlerts.count) + } + + func testRecordRetractedExactlyAtRepeatDelayShouldDelete() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate + Self.repeatingAlertDelay - alertStore.recordIssued(alert: repeatingAlert, at: issuedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(0, storedAlerts.count) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - - func testRecordRetractedAfterDelayShouldRetract() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: repeatingAlert, at: issuedDate) + try await self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier) + XCTAssertEqual(0, storedAlerts.count) + } + + func testRecordRetractedAfterDelayShouldRetract() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate + Self.delayedAlertDelay + 1.0 - alertStore.recordIssued(alert: delayedAlert, at: issuedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.delayedAlertIdentifier, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.delayedAlertIdentifier, storedAlerts.first?.identifier) - XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) - XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) - XCTAssertNil(storedAlerts.first?.acknowledgedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordRetractedAfterRepeatDelayShouldRetract() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: delayedAlert, at: issuedDate) + try await self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.delayedAlertIdentifier) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.delayedAlertIdentifier, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + } + + func testRecordRetractedAfterRepeatDelayShouldRetract() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate + Self.repeatingAlertDelay + 1.0 - alertStore.recordIssued(alert: repeatingAlert, at: issuedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.repeatingAlertIdentifier, storedAlerts.first?.identifier) - XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) - XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) - XCTAssertNil(storedAlerts.first?.acknowledgedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - + await alertStore.recordIssued(alert: repeatingAlert, at: issuedDate) + try await self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.repeatingAlertIdentifier, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + } + // These next two tests are admittedly weird corner cases, but theoretically they might be race conditions, // and so are allowed - func testRecordRetractedThenAcknowledged() { - let expect = self.expectation(description: #function) + func testRecordRetractedThenAcknowledged() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate.addingTimeInterval(2) let acknowledgedDate = issuedDate.addingTimeInterval(4) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate, completion: self.expectSuccess { - self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) - XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) - XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) - expect.fulfill() - }) - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordAcknowledgedThenRetracted() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + try await self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate) + try await self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + } + + func testRecordAcknowledgedThenRetracted() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate.addingTimeInterval(2) let acknowledgedDate = issuedDate.addingTimeInterval(4) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate, completion: self.expectSuccess { - self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) - XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) - XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) - expect.fulfill() - }) - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testRecordRetractedAlert() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + try await self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate) + try await self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + } + + func testRecordRetractedAlert() async throws { let alertDate = Self.historicDate - alertStore.recordRetractedAlert(alert1, at: alertDate, completion: self.expectSuccess { - self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(1, storedAlerts.count) - XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) - XCTAssertEqual(alertDate, storedAlerts.first?.issuedDate) - XCTAssertNil(storedAlerts.first?.acknowledgedDate) - XCTAssertEqual(alertDate, storedAlerts.first?.retractedDate) - expect.fulfill() - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testEmptyQuery() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.executeQuery(since: Date.distantPast, limit: 0, completion: self.expectSuccess { _, objects in - XCTAssertTrue(objects.isEmpty) - expect.fulfill() - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testSimpleQuery() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.executeQuery(since: Date.distantPast, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(1, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier1, objects.first?.identifier) - XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - expect.fulfill() - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testSimpleQueryThenRetraction() { - let expect = self.expectation(description: #function) + try await alertStore.recordRetractedAlert(alert1, at: alertDate) + let storedAlerts = try await self.alertStore.fetch(identifier: Self.identifier1) + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(alertDate, storedAlerts.first?.issuedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + XCTAssertEqual(alertDate, storedAlerts.first?.retractedDate) + } + + func testEmptyQuery() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + let (_, objects) = try await self.alertStore.executeQuery(since: Date.distantPast, limit: 0) + XCTAssertTrue(objects.isEmpty) + } + + func testSimpleQuery() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + let (anchor, objects) = try await self.alertStore.executeQuery(since: Date.distantPast, limit: 100) + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + } + + func testSimpleQueryThenRetraction() async throws { let issuedDate = Self.historicDate let retractedDate = issuedDate.addingTimeInterval(2) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.executeQuery(since: Date.distantPast, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(1, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier1, objects.first?.identifier) - XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate, completion: self.expectSuccess { - self.alertStore.executeQuery(since: Date.distantPast, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(2, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier1, objects.first?.identifier) - XCTAssertEqual(issuedDate, objects.first?.issuedDate) - XCTAssertEqual(retractedDate, objects.first?.retractedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - expect.fulfill() - }) - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testQueryByDate() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - let now = Date() - self.alertStore.recordIssued(alert: self.alert2, at: now, completion: self.expectSuccess { - self.alertStore.executeQuery(since: now, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(2, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier2, objects.first?.identifier) - XCTAssertEqual(now, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testQueryByDateExcludingFutureDelayed() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + let (anchor, objects) = try await self.alertStore.executeQuery(since: Date.distantPast, limit: 100) + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + try await self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate) + let (anchor2, objects2) = try await self.alertStore.executeQuery(since: Date.distantPast, limit: 100) + XCTAssertEqual(2, anchor2.modificationCounter) + XCTAssertEqual(1, objects2.count) + XCTAssertEqual(Self.identifier1, objects2.first?.identifier) + XCTAssertEqual(issuedDate, objects2.first?.issuedDate) + XCTAssertEqual(retractedDate, objects2.first?.retractedDate) + XCTAssertNil(objects2.first?.acknowledgedDate) + } + + func testQueryByDate() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) let now = Date() - alertStore.recordIssued(alert: alert1, at: now, completion: self.expectSuccess { - self.alertStore.recordIssued(alert: self.delayedAlert, at: now, completion: self.expectSuccess { - self.alertStore.executeQuery(since: now, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(1, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier1, objects.first?.identifier) - XCTAssertEqual(now, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testQueryByDateExcludingFutureRepeating() { - let expect = self.expectation(description: #function) + await self.alertStore.recordIssued(alert: self.alert2, at: now) + let (anchor, objects) = try await self.alertStore.executeQuery(since: now, limit: 100) + XCTAssertEqual(2, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier2, objects.first?.identifier) + XCTAssertEqual(now, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + } + + func testQueryByDateExcludingFutureDelayed() async throws { let now = Date() - alertStore.recordIssued(alert: alert1, at: now, completion: self.expectSuccess { - self.alertStore.recordIssued(alert: self.repeatingAlert, at: now, completion: self.expectSuccess { - self.alertStore.executeQuery(since: now, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(1, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier1, objects.first?.identifier) - XCTAssertEqual(now, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testQueryByDateNotExcludingFutureDelayed() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: alert1, at: now) + await self.alertStore.recordIssued(alert: self.delayedAlert, at: now) + let (anchor, objects) = try await self.alertStore.executeQuery(since: now, limit: 100) + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(now, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + } + + func testQueryByDateExcludingFutureRepeating() async throws { let now = Date() - alertStore.recordIssued(alert: alert1, at: now, completion: self.expectSuccess { - self.alertStore.recordIssued(alert: self.delayedAlert, at: now, completion: self.expectSuccess { - self.alertStore.executeQuery(since: now, excludingFutureAlerts: false, limit: 100, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(2, anchor.modificationCounter) - self.assertEqual([self.alert1, self.delayedAlert], objects) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testQueryWithLimit() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { - self.alertStore.recordIssued(alert: self.alert2, at: Date(), completion: self.expectSuccess { - self.alertStore.executeQuery(since: Date.distantPast, limit: 1, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(1, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier1, objects.first?.identifier) - XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - expect.fulfill() - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testQueryThenContinue() { - let expect = self.expectation(description: #function) - alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: expectSuccess { - let now = Date() - self.alertStore.recordIssued(alert: self.alert2, at: now, completion: self.expectSuccess { - self.alertStore.executeQuery(since: Date.distantPast, limit: 1, completion: self.expectSuccess { anchor, _ in - self.alertStore.executeQuery(fromQueryAnchor: anchor, since: Date.distantPast, limit: 1, completion: self.expectSuccess { anchor, objects in - XCTAssertEqual(2, anchor.modificationCounter) - XCTAssertEqual(1, objects.count) - XCTAssertEqual(Self.identifier2, objects.first?.identifier) - XCTAssertEqual(now, objects.first?.issuedDate) - XCTAssertNil(objects.first?.acknowledgedDate) - XCTAssertNil(objects.first?.retractedDate) - expect.fulfill() - }) - }) - }) - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testAcknowledgeFindsCorrectOne() { - let expect = self.expectation(description: #function) + await alertStore.recordIssued(alert: alert1, at: now) + await self.alertStore.recordIssued(alert: self.repeatingAlert, at: now) + let (anchor, objects) = try await self.alertStore.executeQuery(since: now, limit: 100) + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(now, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + } + + func testQueryByDateNotExcludingFutureDelayed() async throws { let now = Date() - fillWith(startDate: Self.historicDate, data: [ + await alertStore.recordIssued(alert: alert1, at: now) + await self.alertStore.recordIssued(alert: self.delayedAlert, at: now) + let (anchor, objects) = try await self.alertStore.executeQuery(since: now, excludingFutureAlerts: false, limit: 100) + XCTAssertEqual(2, anchor.modificationCounter) + self.assertEqual([self.alert1, self.delayedAlert], objects) + } + + func testQueryWithLimit() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + await self.alertStore.recordIssued(alert: self.alert2, at: Date()) + let (anchor, objects) = try await self.alertStore.executeQuery(since: Date.distantPast, limit: 1) + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + } + + func testQueryThenContinue() async throws { + await alertStore.recordIssued(alert: alert1, at: Self.historicDate) + let now = Date() + await self.alertStore.recordIssued(alert: self.alert2, at: now) + let (anchor, _) = try await self.alertStore.executeQuery(since: Date.distantPast, limit: 1) + let (anchor2, objects) = try await self.alertStore.executeQuery(fromQueryAnchor: anchor, since: Date.distantPast, limit: 1) + XCTAssertEqual(2, anchor2.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier2, objects.first?.identifier) + XCTAssertEqual(now, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + } + + func testAcknowledgeFindsCorrectOne() async throws { + let now = Date() + await fillWith(startDate: Self.historicDate, data: [ (alert1, true, false), (alert2, false, false), (alert1, false, false) - ]) { - self.alertStore.recordAcknowledgement(of: self.alert1.identifier, at: now, completion: self.expectSuccess { - self.alertStore.fetch(completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(3, storedAlerts.count) - // Last one is last-modified - XCTAssertNotNil(storedAlerts.last) - if let last = storedAlerts.last { - XCTAssertEqual(Self.identifier1, last.identifier) - XCTAssertEqual(Self.historicDate + 4, last.issuedDate) - XCTAssertEqual(now, last.acknowledgedDate) - XCTAssertNil(last.retractedDate) - } - expect.fulfill() - }) - }) + ]) + try await self.alertStore.recordAcknowledgement(of: self.alert1.identifier, at: now) + let storedAlerts = try await self.alertStore.fetch() + XCTAssertEqual(3, storedAlerts.count) + // Last one is last-modified + XCTAssertNotNil(storedAlerts.last) + if let last = storedAlerts.last { + XCTAssertEqual(Self.identifier1, last.identifier) + XCTAssertEqual(Self.historicDate + 4, last.issuedDate) + XCTAssertEqual(now, last.acknowledgedDate) + XCTAssertNil(last.retractedDate) } - wait(for: [expect], timeout: Self.defaultTimeout) } - - func testAcknowledgeMultiple() { - let expect = self.expectation(description: #function) + + func testAcknowledgeMultiple() async throws { let now = Date() - fillWith(startDate: Self.historicDate, data: [ + await fillWith(startDate: Self.historicDate, data: [ (alert1, false, false), (alert2, false, false), (alert1, false, false) - ]) { - self.alertStore.recordAcknowledgement(of: self.alert1.identifier, at: now, completion: self.expectSuccess { - self.alertStore.fetch(completion: self.expectSuccess { storedAlerts in - XCTAssertEqual(3, storedAlerts.count) - for alert in storedAlerts where alert.identifier == Self.identifier1 { - XCTAssertEqual(now, alert.acknowledgedDate) - XCTAssertNil(alert.retractedDate) - } - expect.fulfill() - }) - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testLookupAllUnacknowledgedUnretractedEmpty() { - let expect = self.expectation(description: #function) - alertStore.lookupAllUnacknowledgedUnretracted(completion: expectSuccess { alerts in - XCTAssertTrue(alerts.isEmpty) - expect.fulfill() - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testLookupAllUnacknowledgedUnretractedOne() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [(alert1, false, false)]) { - self.alertStore.lookupAllUnacknowledgedUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert1], alerts) - expect.fulfill() - }) + ]) + try await self.alertStore.recordAcknowledgement(of: self.alert1.identifier, at: now) + let storedAlerts = try await self.alertStore.fetch() + XCTAssertEqual(3, storedAlerts.count) + for alert in storedAlerts where alert.identifier == Self.identifier1 { + XCTAssertEqual(now, alert.acknowledgedDate) + XCTAssertNil(alert.retractedDate) } - wait(for: [expect], timeout: Self.defaultTimeout) - } - - - func testLookupAllUnacknowledgedUnretractedOneAcknowledged() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [(alert1, true, false)]) { - self.alertStore.lookupAllUnacknowledgedUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) } - - func testLookupAllUnacknowledgedUnretractedSomeNot() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + + func testLookupAllUnacknowledgedUnretractedEmpty() async throws { + let alerts = try await alertStore.lookupAllUnacknowledgedUnretracted() + XCTAssertTrue(alerts.isEmpty) + } + + func testLookupAllUnacknowledgedUnretractedOne() async throws { + await fillWith(startDate: Self.historicDate, data: [(alert1, false, false)]) + let alerts = try await self.alertStore.lookupAllUnacknowledgedUnretracted() + self.assertEqual([self.alert1], alerts) + } + + func testLookupAllUnacknowledgedUnretractedOneAcknowledged() async throws { + await fillWith(startDate: Self.historicDate, data: [(alert1, true, false)]) + let alerts = try await self.alertStore.lookupAllUnacknowledgedUnretracted() + self.assertEqual([], alerts) + } + + func testLookupAllUnacknowledgedUnretractedSomeNot() async throws { + await fillWith(startDate: Self.historicDate, data: [ (alert1, true, false), (alert2, false, false), (alert1, false, false), - ]) { - self.alertStore.lookupAllUnacknowledgedUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert2, self.alert1], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) + ]) + let alerts = try await self.alertStore.lookupAllUnacknowledgedUnretracted() + self.assertEqual([self.alert2, self.alert1], alerts) } - - func testLookupAllUnacknowledgedUnretractedSomeRetracted() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + + func testLookupAllUnacknowledgedUnretractedSomeRetracted() async throws { + await fillWith(startDate: Self.historicDate, data: [ (alert1, false, true), (alert2, false, false), (alert1, false, true) - ]) { - self.alertStore.lookupAllUnacknowledgedUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert2], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testLookupAllUnretractedEmpty() { - let expect = self.expectation(description: #function) - alertStore.lookupAllUnretracted(completion: expectSuccess { alerts in - XCTAssertTrue(alerts.isEmpty) - expect.fulfill() - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testLookupAllUnretractedOne() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [(alert1, false, false)]) { - self.alertStore.lookupAllUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert1], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) - } - - - func testLookupAllUnretractedOneAcknowledged() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [(alert1, true, false)]) { - self.alertStore.lookupAllUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert1], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) + ]) + let alerts = try await self.alertStore.lookupAllUnacknowledgedUnretracted() + self.assertEqual([self.alert2], alerts) + } + + func testLookupAllUnretractedEmpty() async throws { + let alerts = try await alertStore.lookupAllUnretracted() + XCTAssertTrue(alerts.isEmpty) } - - func testLookupAllUnretractedSomeAcknowledgedSomeNot() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + + func testLookupAllUnretractedOne() async throws { + await fillWith(startDate: Self.historicDate, data: [(alert1, false, false)]) + let alerts = try await self.alertStore.lookupAllUnretracted() + self.assertEqual([self.alert1], alerts) + } + + func testLookupAllUnretractedOneAcknowledged() async throws { + await fillWith(startDate: Self.historicDate, data: [(alert1, true, false)]) + let alerts = try await self.alertStore.lookupAllUnretracted() + self.assertEqual([self.alert1], alerts) + } + + func testLookupAllUnretractedSomeAcknowledgedSomeNot() async throws { + await fillWith(startDate: Self.historicDate, data: [ (alert1, true, false), (alert2, false, false), (alert1, false, false), - ]) { - self.alertStore.lookupAllUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert1, self.alert2, self.alert1], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) + ]) + let alerts = try await self.alertStore.lookupAllUnretracted() + self.assertEqual([self.alert1, self.alert2, self.alert1], alerts) } - - func testLookupAllUnretractedSomeRetracted() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + + func testLookupAllUnretractedSomeRetracted() async throws { + await fillWith(startDate: Self.historicDate, data: [ (alert1, false, true), (alert2, false, false), (alert1, false, true) - ]) { - self.alertStore.lookupAllUnretracted(completion: self.expectSuccess { alerts in - self.assertEqual([self.alert2], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) + ]) + let alerts = try await self.alertStore.lookupAllUnretracted() + self.assertEqual([self.alert2], alerts) } - func testLookupAllAcknowledgedUnretractedRepeatingAlertsAll() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + func testLookupAllAcknowledgedUnretractedRepeatingAlertsAll() async throws { + await fillWith(startDate: Self.historicDate, data: [ (repeatingAlert, true, false), (repeatingAlert, true, false) - ]) { - self.alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: self.expectSuccess { alerts in - XCTAssertEqual(alerts.count, 2) - self.assertEqual([self.repeatingAlert, self.repeatingAlert], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testLookupAllAcknowledgedUnretractedRepeatingAlertsEmpty() { - let expect = self.expectation(description: #function) - alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: expectSuccess { alerts in - XCTAssertTrue(alerts.isEmpty) - expect.fulfill() - }) - wait(for: [expect], timeout: Self.defaultTimeout) - } - - func testLookupAllAcknowledgedUnretractedRepeatingAlertsSome() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + ]) + let alerts = try await self.alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts() + XCTAssertEqual(alerts.count, 2) + self.assertEqual([self.repeatingAlert, self.repeatingAlert], alerts) + } + + func testLookupAllAcknowledgedUnretractedRepeatingAlertsEmpty() async throws { + let alerts = try await alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts() + XCTAssertTrue(alerts.isEmpty) + } + + func testLookupAllAcknowledgedUnretractedRepeatingAlertsSome() async throws { + await fillWith(startDate: Self.historicDate, data: [ (repeatingAlert, true, true), (repeatingAlert, true, false), (alert1, true, false) - ]) { - self.alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: self.expectSuccess { alerts in - XCTAssertEqual(alerts.count, 1) - self.assertEqual([self.repeatingAlert], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) + ]) + let alerts = try await self.alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts() + XCTAssertEqual(alerts.count, 1) + self.assertEqual([self.repeatingAlert], alerts) } - func testLookUpAllMatching() { - let expect = self.expectation(description: #function) - fillWith(startDate: Self.historicDate, data: [ + func testLookUpAllMatching() async throws { + await fillWith(startDate: Self.historicDate, data: [ (alert1, true, false), (repeatingAlert, true, false) - ]) { - self.alertStore.lookupAllMatching(identifier: AlertStoreTests.repeatingAlertIdentifier, completion: self.expectSuccess { alerts in - XCTAssertEqual(alerts.count, 1) - self.assertEqual([self.repeatingAlert], alerts) - expect.fulfill() - }) - } - wait(for: [expect], timeout: Self.defaultTimeout) + ]) + let alerts = try await self.alertStore.lookupAllMatching(identifier: AlertStoreTests.repeatingAlertIdentifier) + XCTAssertEqual(alerts.count, 1) + self.assertEqual([self.repeatingAlert], alerts) } - private func fillWith(startDate: Date, data: [(alert: Alert, acknowledged: Bool, retracted: Bool)], _ completion: @escaping () -> Void) { + private func fillWith(startDate: Date, data: [(alert: Alert, acknowledged: Bool, retracted: Bool)]) async { let increment = 1.0 - if let value = data.first { - alertStore.recordIssued(alert: value.alert, at: startDate, completion: self.expectSuccess { - var next = startDate.addingTimeInterval(increment) - self.maybeRecordAcknowledge(acknowledged: value.acknowledged, identifier: value.alert.identifier, at: next) { - next = next.addingTimeInterval(increment) - self.maybeRecordRetracted(retracted: value.retracted, identifier: value.alert.identifier, at: next) { - self.fillWith(startDate: startDate.addingTimeInterval(increment).addingTimeInterval(increment), data: data.suffix(data.count - 1), completion) - } - } - }) - } else { - completion() - } - } - - private func maybeRecordAcknowledge(acknowledged: Bool, identifier: Alert.Identifier, at date: Date, _ completion: @escaping () -> Void) { - if acknowledged { - self.alertStore.recordAcknowledgement(of: identifier, at: date, completion: self.expectSuccess(completion)) - } else { - completion() - } - } - - private func maybeRecordRetracted(retracted: Bool, identifier: Alert.Identifier, at date: Date, _ completion: @escaping () -> Void) { - if retracted { - self.alertStore.recordRetraction(of: identifier, at: date, completion: self.expectSuccess(completion)) - } else { - completion() + for (index, value) in data.enumerated() { + let issuedDate = startDate.addingTimeInterval(Double(index) * increment * 2) + await alertStore.recordIssued(alert: value.alert, at: issuedDate) + + if value.acknowledged { + let acknowledgedDate = issuedDate.addingTimeInterval(increment) + try? await self.alertStore.recordAcknowledgement(of: value.alert.identifier, at: acknowledgedDate) + } + + if value.retracted { + let retractedDate = issuedDate.addingTimeInterval(increment) + try? await self.alertStore.recordRetraction(of: value.alert.identifier, at: retractedDate) + } } } @@ -789,7 +551,7 @@ class AlertStoreTests: XCTestCase { } } } - + private func assertEqual(_ alerts: [Alert], _ syncAlertObjects: [SyncAlertObject], file: StaticString = #file, line: UInt = #line) { XCTAssertEqual(alerts.count, syncAlertObjects.count, file: file, line: line) if alerts.count == syncAlertObjects.count { @@ -798,104 +560,4 @@ class AlertStoreTests: XCTestCase { } } } - - private func expectSuccess(file: StaticString = #file, line: UInt = #line, _ completion: @escaping (T) -> Void) -> ((Result) -> Void) { - return { - switch $0 { - case .failure(let error): XCTFail("Unexpected \(error)", file: file, line: line) - case .success(let value): completion(value) - } - } - } - - private func expectSuccess(file: StaticString = #file, line: UInt = #line, _ completion: @escaping (AlertStore.QueryAnchor, [SyncAlertObject]) -> Void) -> ((AlertStore.AlertQueryResult) -> Void) { - return { - switch $0 { - case .failure(let error): XCTFail("Unexpected \(error)", file: file, line: line) - case .success(let queryAnchor, let objects): completion(queryAnchor, objects) - } - } - } -} - -class AlertStoreLogCriticalEventLogTests: XCTestCase { - var alertStore: AlertStore! - var outputStream: MockOutputStream! - var progress: Progress! - - override func setUp() { - super.setUp() - - let alerts = [AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:08:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m1", alertIdentifier: "a1"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "52A046F7-F449-49B2-B003-7A378D0002DE")!), - AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:10:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m2", alertIdentifier: "a2"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "0929E349-972F-4B06-9808-68914A541515")!), - AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:04:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m3", alertIdentifier: "a3"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "285AEA4B-0DEE-41F4-8669-800E9582A6E7")!), - AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:06:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m4", alertIdentifier: "a4"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "4B3109BD-DE11-42BD-A777-D4783459C483")!), - AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:02:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m5", alertIdentifier: "a5"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "48C8ACC7-9DB7-411D-B5A3-CD907D464B78")!)] - - alertStore = AlertStore() - XCTAssertNil(alertStore.addAlerts(alerts: alerts)) - - outputStream = MockOutputStream() - progress = Progress() - } - - override func tearDown() { - alertStore = nil - - super.tearDown() - } - - func testExportProgressTotalUnitCount() { - switch alertStore.exportProgressTotalUnitCount(startDate: dateFormatter.date(from: "2100-01-02T03:03:00Z")!, - endDate: dateFormatter.date(from: "2100-01-02T03:09:00Z")!) { - case .failure(let error): - XCTFail("Unexpected failure: \(error)") - case .success(let progressTotalUnitCount): - XCTAssertEqual(progressTotalUnitCount, 3 * 1) - } - } - - func testExportProgressTotalUnitCountEmpty() { - switch alertStore.exportProgressTotalUnitCount(startDate: dateFormatter.date(from: "2100-01-02T03:00:00Z")!, - endDate: dateFormatter.date(from: "2100-01-02T03:01:00Z")!) { - case .failure(let error): - XCTFail("Unexpected failure: \(error)") - case .success(let progressTotalUnitCount): - XCTAssertEqual(progressTotalUnitCount, 0) - } - } - - func testExport() { - XCTAssertNil(alertStore.export(startDate: dateFormatter.date(from: "2100-01-02T03:03:00Z")!, - endDate: dateFormatter.date(from: "2100-01-02T03:09:00Z")!, - to: outputStream, - progress: progress)) - XCTAssertEqual(outputStream.string, #""" - [ - {"acknowledgedDate":"2100-01-02T03:08:00.000Z","alertIdentifier":"a1","backgroundContent":"{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:08:00.000Z","managerIdentifier":"m1","modificationCounter":1,"syncIdentifier":"52A046F7-F449-49B2-B003-7A378D0002DE","triggerType":0}, - {"acknowledgedDate":"2100-01-02T03:04:00.000Z","alertIdentifier":"a3","backgroundContent":"{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:04:00.000Z","managerIdentifier":"m3","modificationCounter":3,"syncIdentifier":"285AEA4B-0DEE-41F4-8669-800E9582A6E7","triggerType":0}, - {"acknowledgedDate":"2100-01-02T03:06:00.000Z","alertIdentifier":"a4","backgroundContent":"{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:06:00.000Z","managerIdentifier":"m4","modificationCounter":4,"syncIdentifier":"4B3109BD-DE11-42BD-A777-D4783459C483","triggerType":0} - ] - """#) - XCTAssertEqual(progress.completedUnitCount, 3 * 1) - } - - func testExportEmpty() { - XCTAssertNil(alertStore.export(startDate: dateFormatter.date(from: "2100-01-02T03:00:00Z")!, - endDate: dateFormatter.date(from: "2100-01-02T03:01:00Z")!, - to: outputStream, - progress: progress)) - XCTAssertEqual(outputStream.string, "[]") - XCTAssertEqual(progress.completedUnitCount, 0) - } - - func testExportCancelled() { - progress.cancel() - XCTAssertEqual(alertStore.export(startDate: dateFormatter.date(from: "2100-01-02T03:03:00Z")!, - endDate: dateFormatter.date(from: "2100-01-02T03:09:00Z")!, - to: outputStream, - progress: progress) as? CriticalEventLogError, CriticalEventLogError.cancelled) - } - - private let dateFormatter = ISO8601DateFormatter() } diff --git a/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift b/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift index 7b799b591b..f0d8965291 100644 --- a/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift +++ b/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift @@ -10,6 +10,7 @@ import LoopKit import XCTest @testable import Loop +@MainActor class InAppModalAlertSchedulerTests: XCTestCase { class MockAlertAction: UIAlertAction { @@ -34,42 +35,33 @@ class InAppModalAlertSchedulerTests: XCTestCase { } class MockAlertManagerResponder: AlertManagerResponder { + var alertAcknowledgedExectation: XCTestExpectation? var identifierAcknowledged: Alert.Identifier? func acknowledgeAlert(identifier: Alert.Identifier) { identifierAcknowledged = identifier + alertAcknowledgedExectation?.fulfill() } + func userDidSelectAction(alertIdentifier: LoopKit.Alert.Identifier, actionIdentifier: String) async throws { } } class MockViewController: UIViewController, AlertPresenter { + + var alertPresentedExpectation: XCTestExpectation? + var alertDismissedExpectation: XCTestExpectation? + + var viewControllerPresented: UIViewController? var alertDismissed: UIAlertController? - var autoComplete = true - var completion: (() -> Void)? - override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + + func present(_ viewControllerToPresent: UIViewController, animated flag: Bool) async { viewControllerPresented = viewControllerToPresent - if autoComplete { - completion?() - } else { - self.completion = completion - } - } - func dismissTopMost(animated: Bool, completion: (() -> Void)?) { - if autoComplete { - completion?() - } else { - self.completion = completion - } + alertPresentedExpectation?.fulfill() } - func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { + + func dismissTopMost(animated: Bool) async { } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool) async { alertDismissed = alertToDismiss - if autoComplete { - completion?() - } else { - self.completion = completion - } - } - func callCompletion() { - completion?() + alertDismissedExpectation?.fulfill() } } @@ -77,7 +69,9 @@ class InAppModalAlertSchedulerTests: XCTestCase { let alertIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: "bar") let foregroundContent = Alert.Content(title: "FOREGROUND", body: "foreground", acknowledgeActionButtonLabel: "") let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "") - + + var timerCreatedExepctation: XCTestExpectation? + var mockTimer: Timer? var mockTimerTimeInterval: TimeInterval? var mockTimerRepeats: Bool? @@ -85,7 +79,7 @@ class InAppModalAlertSchedulerTests: XCTestCase { var mockViewController: MockViewController! var inAppModalAlertScheduler: InAppModalAlertScheduler! - override func setUp() { + override func setUp() async throws { mockAlertManagerResponder = MockAlertManagerResponder() mockViewController = MockViewController() @@ -94,6 +88,7 @@ class InAppModalAlertSchedulerTests: XCTestCase { self.mockTimer = timer self.mockTimerTimeInterval = timeInterval self.mockTimerRepeats = repeats + self.timerCreatedExepctation?.fulfill() return timer } inAppModalAlertScheduler = InAppModalAlertScheduler(alertPresenter: mockViewController, @@ -141,29 +136,28 @@ class InAppModalAlertSchedulerTests: XCTestCase { XCTAssertEqual("FOREGROUND", alertController?.title) } - @MainActor func testRemoveImmediateAlert() { + @MainActor + func testRemoveImmediateAlert() async { + mockViewController.alertPresentedExpectation = expectation(description: "alert presented") let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) inAppModalAlertScheduler.scheduleAlert(alert) - - waitOnMain() + + await fulfillment(of: [mockViewController.alertPresentedExpectation!]) let alertControllerPresented = mockViewController.viewControllerPresented as? UIAlertController XCTAssertNotNil(alertControllerPresented) - var dismissed = false - inAppModalAlertScheduler.removePresentedAlert(identifier: alert.identifier) { - dismissed = true - } + mockViewController.alertDismissedExpectation = expectation(description: "alert dismissed") - waitOnMain() + await inAppModalAlertScheduler.removePresentedAlert(identifier: alert.identifier) + + await fulfillment(of: [mockViewController.alertDismissedExpectation!]) let alertDimissed = mockViewController.alertDismissed XCTAssertNotNil(alertDimissed) - XCTAssertTrue(dismissed) } func testIssueImmediateAlertTwiceOnlyOneShows() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) - mockViewController.autoComplete = false inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() @@ -186,14 +180,15 @@ class InAppModalAlertSchedulerTests: XCTestCase { waitOnMain() let action = (mockViewController.viewControllerPresented as? UIAlertController)?.actions[0] as? MockAlertAction XCTAssertNotNil(action) + mockAlertManagerResponder.alertAcknowledgedExectation = expectation(description: "alert acknowledged") XCTAssertNil(mockAlertManagerResponder.identifierAcknowledged) action?.callHandler() + wait(for: [mockAlertManagerResponder.alertAcknowledgedExectation!]) XCTAssertEqual(alertIdentifier, mockAlertManagerResponder.identifierAcknowledged) } func testIssueDelayedAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) - mockViewController.autoComplete = false inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() @@ -212,7 +207,6 @@ class InAppModalAlertSchedulerTests: XCTestCase { func testIssueDelayedAlertTwiceOnlyOneWorks() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) - mockViewController.autoComplete = false inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() @@ -239,21 +233,22 @@ class InAppModalAlertSchedulerTests: XCTestCase { XCTAssertNil(mockViewController.viewControllerPresented) } - func testRetractAlert() { + func testRetractAlert() async { + + timerCreatedExepctation = expectation(description: "Timer created") + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) inAppModalAlertScheduler.scheduleAlert(alert) - - waitOnMain() + + await fulfillment(of: [timerCreatedExepctation!]) XCTAssert(mockTimer?.isValid == true) - inAppModalAlertScheduler.unscheduleAlert(identifier: alert.identifier) - - waitOnMain() + + await inAppModalAlertScheduler.unscheduleAlert(identifier: alert.identifier) XCTAssert(mockTimer?.isValid == false) } func testIssueRepeatingAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .repeating(repeatInterval: 0.1)) - mockViewController.autoComplete = false inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() diff --git a/LoopTests/Managers/Alerts/StoredAlertTests.swift b/LoopTests/Managers/Alerts/StoredAlertTests.swift index 63bb93b3f3..e7b74f45fd 100644 --- a/LoopTests/Managers/Alerts/StoredAlertTests.swift +++ b/LoopTests/Managers/Alerts/StoredAlertTests.swift @@ -48,7 +48,7 @@ class StoredAlertEncodableTests: XCTestCase { try! assertStoredAlertEncodable(storedAlert, encodesJSON: #""" { "alertIdentifier" : "bar", - "backgroundContent" : "{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}", + "backgroundContent" : "{\"actions\":[{\"identifier\":\"acknowledge\",\"label\":\"OK\",\"style\":0}],\"body\":\"background\",\"title\":\"BACKGROUND\"}", "interruptionLevel" : "active", "issuedDate" : "2020-05-14T21:00:12Z", "managerIdentifier" : "foo", @@ -64,7 +64,7 @@ class StoredAlertEncodableTests: XCTestCase { try! assertStoredAlertEncodable(storedAlert, encodesJSON: #""" { "alertIdentifier" : "bar", - "backgroundContent" : "{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}", + "backgroundContent" : "{\"actions\":[{\"identifier\":\"acknowledge\",\"label\":\"OK\",\"style\":0}],\"body\":\"background\",\"title\":\"BACKGROUND\"}", "interruptionLevel" : "critical", "issuedDate" : "2020-05-14T21:00:12Z", "managerIdentifier" : "foo", diff --git a/LoopTests/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift index 179040a509..81a6c31e08 100644 --- a/LoopTests/Managers/DeviceDataManagerTests.swift +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -189,7 +189,7 @@ final class DeviceDataManagerTests: XCTestCase { } extension DeviceDataManagerTests: ActiveServicesProvider { - var activeServices: [LoopKit.Service] { + nonisolated var activeServices: [LoopKit.Service] { return [] } @@ -197,7 +197,7 @@ extension DeviceDataManagerTests: ActiveServicesProvider { } extension DeviceDataManagerTests: ActiveStatefulPluginsProvider { - var activeStatefulPlugins: [LoopKit.StatefulPluggable] { + nonisolated var activeStatefulPlugins: [LoopKit.StatefulPluggable] { return [] } } diff --git a/LoopTests/Mock Stores/HKHealthStoreMock.swift b/LoopTests/Mock Stores/HKHealthStoreMock.swift index 6f8127d3e4..40d5fb5899 100644 --- a/LoopTests/Mock Stores/HKHealthStoreMock.swift +++ b/LoopTests/Mock Stores/HKHealthStoreMock.swift @@ -11,7 +11,7 @@ import Foundation import LoopKit -class HKHealthStoreMock: HKHealthStore { +class HKHealthStoreMock: HKHealthStore, @unchecked Sendable { var saveError: Error? var deleteError: Error? var queryResults: (samples: [HKSample]?, error: Error?)? diff --git a/LoopTests/Mocks/AlertMocks.swift b/LoopTests/Mocks/AlertMocks.swift index d13c0663db..aed3174b1a 100644 --- a/LoopTests/Mocks/AlertMocks.swift +++ b/LoopTests/Mocks/AlertMocks.swift @@ -9,6 +9,7 @@ import UIKit import LoopKit @testable import Loop +import XCTest class MockBluetoothProvider: BluetoothProvider { var bluetoothAuthorization: BluetoothAuthorization = .authorized @@ -28,12 +29,19 @@ class MockBluetoothProvider: BluetoothProvider { class MockModalAlertScheduler: InAppModalAlertScheduler { var scheduledAlert: Alert? + + var alertScheduledExpectation: XCTestExpectation? + var alertUnscheduledExpectation: XCTestExpectation? + override func scheduleAlert(_ alert: Alert) { scheduledAlert = alert + alertScheduledExpectation?.fulfill() } var unscheduledAlertIdentifier: Alert.Identifier? - override func unscheduleAlert(identifier: Alert.Identifier) { + + override func unscheduleAlert(identifier: Alert.Identifier) async { unscheduledAlertIdentifier = identifier + alertUnscheduledExpectation?.fulfill() } } @@ -52,9 +60,9 @@ class MockUserNotificationAlertScheduler: UserNotificationAlertScheduler { } class MockResponder: AlertResponder { + var acknowledged: [Alert.AlertIdentifier: Bool] = [:] - func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - completion(nil) + func acknowledgeAlert(alertIdentifier: LoopKit.Alert.AlertIdentifier) async throws { acknowledged[alertIdentifier] = true } } @@ -92,13 +100,22 @@ class MockFileManager: FileManager { } 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?() } + var presentedViewController: UIViewController? + + func present(_ viewControllerToPresent: UIViewController, animated flag: Bool) async { + presentedViewController = viewControllerToPresent + } + func dismissTopMost(animated: Bool) async { + presentedViewController = nil + } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool) async { + presentedViewController = nil + } } class MockAlertManagerResponder: AlertManagerResponder { - func acknowledgeAlert(identifier: LoopKit.Alert.Identifier) { } + func userDidSelectAction(alertIdentifier: LoopKit.Alert.Identifier, actionIdentifier: String) async throws { } + func acknowledgeAlert(identifier: LoopKit.Alert.Identifier) async { } } class MockSoundVendor: AlertSoundVendor { @@ -115,43 +132,38 @@ class MockSoundVendor: AlertSoundVendor { class MockAlertStore: AlertStore { var issuedAlert: Alert? - override public func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { + override public func recordIssued(alert: Alert, at date: Date = Date()) async { issuedAlert = alert - completion?(.success) } var retractedAlert: Alert? var retractedAlertDate: Date? - override public func recordRetractedAlert(_ alert: Alert, at date: Date, completion: ((Result) -> Void)? = nil) { + override public func recordRetractedAlert(_ alert: Alert, at date: Date) async throws { 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) { + override public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date()) async throws { 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) { + override public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date()) async throws { 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 lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil) async throws -> [StoredAlert] + { + return storedAlerts } - override public func lookupAllUnretracted(managerIdentifier: String?, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { - completion(.success(storedAlerts)) + override public func lookupAllUnretracted(managerIdentifier: String?) async -> [StoredAlert] { + return storedAlerts } } diff --git a/LoopTests/Mocks/MockCGMManager.swift b/LoopTests/Mocks/MockCGMManager.swift index 38e6d6a140..736f509ca1 100644 --- a/LoopTests/Mocks/MockCGMManager.swift +++ b/LoopTests/Mocks/MockCGMManager.swift @@ -46,9 +46,7 @@ class MockCGMManager: CGMManager { var debugDescription: String = "MockCGMManager" - func acknowledgeAlert(alertIdentifier: LoopKit.Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - completion(nil) - } + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier) async throws { } func getSoundBaseURL() -> URL? { return nil diff --git a/LoopTests/Mocks/MockPumpManager.swift b/LoopTests/Mocks/MockPumpManager.swift index 52b898447c..fed34a11b3 100644 --- a/LoopTests/Mocks/MockPumpManager.swift +++ b/LoopTests/Mocks/MockPumpManager.swift @@ -127,8 +127,7 @@ class MockPumpManager: PumpManager { var debugDescription: String = "MockPumpManager" - func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { - } + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier) async throws { } func getSoundBaseURL() -> URL? { return nil diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 19772d9a58..c595286976 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -980,7 +980,7 @@ fileprivate extension TimeInterval { } } -extension BolusDosingDecision: Equatable { +extension BolusDosingDecision: @retroactive Equatable { init(for reason: Reason, originalCarbEntry: StoredCarbEntry? = nil, carbEntry: StoredCarbEntry? = nil, manualGlucoseSample: StoredGlucoseSample? = nil, manualBolusRequested: Double? = nil) { self.init(for: reason) self.originalCarbEntry = originalCarbEntry @@ -1002,7 +1002,7 @@ extension BolusDosingDecision: Equatable { } } -extension ManualBolusRecommendationWithDate: Equatable { +extension ManualBolusRecommendationWithDate: @retroactive Equatable { public static func == (lhs: ManualBolusRecommendationWithDate, rhs: ManualBolusRecommendationWithDate) -> Bool { return lhs.recommendation == rhs.recommendation && lhs.date == rhs.date } diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 3a150f1e9e..0479890319 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -254,7 +254,7 @@ extension ExtensionDelegate: WCSessionDelegate { extension ExtensionDelegate: UNUserNotificationCenterDelegate { - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier: guard @@ -281,10 +281,14 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { statusController.addCarbs() } default: + let userInfo = response.notification.request.content.userInfo + if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? LoopKit.Alert.AlertIdentifier, + let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String + { + await loopManager.sendUserSelectedNotificationActionMessage(alertIdentifier: alertIdentifier, managerIdentifier: managerIdentifier, actionIdentifier: response.actionIdentifier) + } break } - - completionHandler() } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { diff --git a/WatchApp Extension/Extensions/WCSession.swift b/WatchApp Extension/Extensions/WCSession.swift index 6eb309309f..95318339e0 100644 --- a/WatchApp Extension/Extensions/WCSession.swift +++ b/WatchApp Extension/Extensions/WCSession.swift @@ -93,6 +93,20 @@ extension WCSession { }) } + func sendUserSelectedNotificationActionMessage(alertIdentifier: String, managerIdentifier: String, actionIdentifier: String) async { + let msg = NotificationActionSelection( + alertIdentifier: alertIdentifier, + managerIdentifier: managerIdentifier, + actionIdentifier: actionIdentifier + ) + + sendMessage(msg.rawValue, replyHandler: { (reply) in + log.error("Sent notication action selection: ${public}@", actionIdentifier) + }, errorHandler: { (error) in + log.error("sendUserSelectedNotificationActionMessage failed: ${public}@", String(describing: error)) + }) + } + func sendCarbBackfillRequestMessage(_ userInfo: CarbBackfillRequestUserInfo, completionHandler: @escaping (WCSessionMessageResult) -> Void) { log.default("sendCarbBackfillRequestMessage: since %{public}@", String(describing: userInfo.startDate)) diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 37ccd2a4d4..ea94e5eca3 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -116,6 +116,14 @@ extension LoopDataManager { } } + func sendUserSelectedNotificationActionMessage(alertIdentifier: String, managerIdentifier: String, actionIdentifier: String) async { + await WCSession.default.sendUserSelectedNotificationActionMessage( + alertIdentifier: alertIdentifier, + managerIdentifier: managerIdentifier, + actionIdentifier: actionIdentifier + ) + } + func requestCarbBackfill() { dispatchPrecondition(condition: .onQueue(.main)) From 1cf40a9b79c0579b65e8ad16ec39525c6aab3dda Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 31 Jul 2025 09:37:20 -0500 Subject: [PATCH 269/421] LOOP-5170 Fix inappropriate overlay of override target history (#809) * Fix inappropriate overlay of override target history * Update LoopDataManager.swift Remove commented debug print. --- Loop/Managers/LoopDataManager.swift | 28 +++++++++++++++---- Loop/Managers/WatchDataManager.swift | 2 +- Loop/View Models/BolusEntryViewModel.swift | 3 +- .../ViewModels/BolusEntryViewModelTests.swift | 2 +- .../SimpleBolusViewModelTests.swift | 2 +- 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 9aebe81dd3..8cca65107a 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -383,7 +383,7 @@ final class LoopDataManager: ObservableObject { endDate: neededSensitivityTimeline.end ) - let target = try await settingsProvider.getTargetRangeHistory(startDate: baseTime, endDate: forecastEndTime) + var target = try await settingsProvider.getTargetRangeHistory(startDate: baseTime, endDate: forecastEndTime) let dosingLimits = try await settingsProvider.getDosingLimits(at: baseTime) @@ -396,7 +396,8 @@ final class LoopDataManager: ObservableObject { } var overrides = temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: neededSensitivityTimeline.start, endDate: forecastEndTime) - + + // For recommendation, we should consider preMeal override to be ending at time of dose if disablingPreMeal, let activeOverride = temporaryPresetsManager.activeOverride, activeOverride.context == .preMeal, @@ -423,7 +424,22 @@ final class LoopDataManager: ObservableObject { guard !target.isEmpty else { throw LoopError.configurationError(.glucoseTargetRangeSchedule) } - let targetWithOverrides = overrides.applyTarget(over: target, at: baseTime) + + // If we have an active override, and it's not a preMeal override that should be disabled, + // then override the target for the entire forecast. + if let activeOverride = temporaryPresetsManager.activeOverride, + let overriddenTargetRange = activeOverride.settings.targetRange + { + if !(disablingPreMeal && activeOverride.context == .preMeal) { + target = [ + AbsoluteScheduleValue( + startDate: baseTime, + endDate: forecastEndTime, + value: overriddenTargetRange + ) + ] + } + } // Create dosing strategy based on user setting let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled @@ -451,7 +467,7 @@ final class LoopDataManager: ObservableObject { basal: basalWithOverrides, sensitivity: sensitivityWithOverrides, carbRatio: carbRatioWithOverrides, - target: targetWithOverrides, + target: target, suspendThreshold: dosingLimits.suspendThreshold, maxBolus: maxBolus, maxBasalRate: maxBasalRate, @@ -648,7 +664,7 @@ final class LoopDataManager: ObservableObject { ) async throws -> ManualBolusRecommendation? { var input = try await self.fetchData(for: now(), disablingPreMeal: potentialCarbEntry != nil) - .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseStample) + .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseSample) .removingCarbEntry(carbEntry: originalCarbEntry) .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) @@ -971,7 +987,7 @@ extension NewCarbEntry { } extension NewGlucoseSample { - var asStoredGlucoseStample: StoredGlucoseSample { + var asStoredGlucoseSample: StoredGlucoseSample { StoredGlucoseSample( syncIdentifier: syncIdentifier, syncVersion: syncVersion, diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 029e5d0d1b..9338e44660 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -175,7 +175,7 @@ final class WatchDataManager: NSObject { } let rawUserInfo = userInfo.rawValue - log.default("Transferring LoopSettingsUserInfo: %{public}@", rawUserInfo) + //log.default("Transferring LoopSettingsUserInfo: %{public}@", rawUserInfo) session.transferUserInfo(rawUserInfo) } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 7f7860035d..6bea80022f 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -533,7 +533,7 @@ final class BolusEntryViewModel: ObservableObject { // Add potential bolus, carbs, manual glucose input = input .addingDose(dose: enteredBolusDose) - .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseStample) + .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseSample) .removingCarbEntry(carbEntry: originalCarbEntry) .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) @@ -757,3 +757,4 @@ extension BolusEntryViewModel { } } } + diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index c595286976..d3f688ea5d 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -902,7 +902,7 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { if let saveGlucoseError { throw saveGlucoseError } else { - return sample.asStoredGlucoseStample + return sample.asStoredGlucoseSample } } diff --git a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift index 4f4401bcec..0701b66117 100644 --- a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift +++ b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift @@ -264,7 +264,7 @@ class SimpleBolusViewModelTests: XCTestCase { extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { func saveGlucose(sample: LoopKit.NewGlucoseSample) async throws -> StoredGlucoseSample { addedGlucose.append(sample) - return sample.asStoredGlucoseStample + return sample.asStoredGlucoseSample } func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry { From d34e491d1beb3d3de23f0e67eb534249f1ddafd0 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 31 Jul 2025 15:23:58 -0500 Subject: [PATCH 270/421] Collapse picker when indefinite duration selected (#810) --- .../CreatePresetNameAndScheduledEdit.swift | 5 +- Loop/Views/Presets/DurationPickerView.swift | 81 ++++++++++--------- Loop/Views/Presets/EditPresetView.swift | 3 +- 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift index 5240dc7894..34bac6d92d 100644 --- a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift +++ b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift @@ -89,7 +89,7 @@ struct CreatePresetNameAndScheduledEdit: View { // Duration Section CardSection { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading) { HStack { Text("Duration") .foregroundColor(.primary) @@ -117,7 +117,7 @@ struct CreatePresetNameAndScheduledEdit: View { DurationPickerView( durationType: Binding( get: { - return preset.duration ?? .duration(0) + return preset.duration ?? .duration(.hours(1)) }, set: { duration in preset.duration = duration @@ -243,6 +243,7 @@ struct CreatePresetNameAndScheduledEdit: View { assignRepeatDays() } }) + .animation(.easeInOut, value: preset.duration) .navigationBarTitleDisplayMode(.inline) .navigationTitle("Create a Preset") diff --git a/Loop/Views/Presets/DurationPickerView.swift b/Loop/Views/Presets/DurationPickerView.swift index fc3af3250f..dd702a2a90 100644 --- a/Loop/Views/Presets/DurationPickerView.swift +++ b/Loop/Views/Presets/DurationPickerView.swift @@ -80,51 +80,56 @@ struct DurationPickerView: View { ) } - var body: some View { - VStack { - HStack(spacing: 16) { - HStack(spacing: 8) { - Picker("Hours", selection: hours) { - ForEach(availableHours, id: \.self) { hour in - Text("\(hour)") - .tag(hour) - } + var picker: some View { + HStack(spacing: 16) { + HStack(spacing: 8) { + Picker("Hours", selection: hours) { + ForEach(availableHours, id: \.self) { hour in + Text("\(hour)") + .tag(hour) } - .pickerStyle(.wheel) - .frame(width: 60) - .clipped() - .disabled(isIndefinite.wrappedValue) - .opacity(isIndefinite.wrappedValue ? 0.5 : 1) - - Text("hour") - .foregroundColor(isIndefinite.wrappedValue ? .gray.opacity(0.5) : .gray) } + .pickerStyle(.wheel) + .frame(width: 60) + .clipped() + .disabled(isIndefinite.wrappedValue) + .opacity(isIndefinite.wrappedValue ? 0.5 : 1) - HStack(spacing: 8) { - Picker("Minutes", selection: minutes) { - ForEach(availableMinutes, id: \.self) { minute in - Text("\(minute)") - .tag(minute) - } - } - .pickerStyle(.wheel) - .frame(width: 60) - .clipped() - .disabled(isIndefinite.wrappedValue) - .opacity(isIndefinite.wrappedValue ? 0.5 : 1) + Text("hour") + .foregroundColor(isIndefinite.wrappedValue ? .gray.opacity(0.5) : .gray) + } - Text("min") - .foregroundColor(isIndefinite.wrappedValue ? .gray.opacity(0.5) : .gray) + HStack(spacing: 8) { + Picker("Minutes", selection: minutes) { + ForEach(availableMinutes, id: \.self) { minute in + Text("\(minute)") + .tag(minute) + } } + .pickerStyle(.wheel) + .frame(width: 60) + .clipped() + .disabled(isIndefinite.wrappedValue) + .opacity(isIndefinite.wrappedValue ? 0.5 : 1) + + Text("min") + .foregroundColor(isIndefinite.wrappedValue ? .gray.opacity(0.5) : .gray) } - .padding(.horizontal) - .onChange(of: hours.wrappedValue) { _, _ in - enforceConstraints() - } - .onChange(of: minutes.wrappedValue) { _, _ in - enforceConstraints() - } + } + .padding(.horizontal) + .onChange(of: hours.wrappedValue) { _, _ in + enforceConstraints() + } + .onChange(of: minutes.wrappedValue) { _, _ in + enforceConstraints() + } + } + var body: some View { + VStack { + if !isIndefinite.wrappedValue { + picker + } HStack { Text("Until I turn off") Spacer() diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index d96bc6336e..83914367b0 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -133,7 +133,7 @@ struct EditPresetView: View { // Duration Section if preset.canAdjustDuration { CardSection { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading) { HStack { Text("Duration") .foregroundColor(.primary) @@ -298,6 +298,7 @@ struct EditPresetView: View { .padding(.top) } } + .animation(.easeInOut, value: preset.duration) } .navigationBarItems(trailing: dismissButton) .navigationDestination(for: Destination.self) { dest in From c86f5f93866c229503472b957696aa28abfe9a3f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 31 Jul 2025 20:00:38 -0500 Subject: [PATCH 271/421] Cleanup (#811) --- Loop.xcodeproj/project.pbxproj | 4 -- Loop/Base.lproj/Main.storyboard | 15 ++---- .../OverrideSelectionViewController.swift | 13 ----- Loop/Managers/TemporaryPresetsManager.swift | 8 ++- .../StatusTableViewController.swift | 53 ------------------- Loop/View Models/StatusTableViewModel.swift | 4 +- Loop/Views/StatusTableView.swift | 6 +-- 7 files changed, 12 insertions(+), 91 deletions(-) delete mode 100644 Loop/Extensions/OverrideSelectionViewController.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 698ae49860..da5f617bc5 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -294,7 +294,6 @@ 895788B1242E69A2002CB114 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788A9242E69A1002CB114 /* Color.swift */; }; 895788B2242E69A2002CB114 /* CircularAccessoryButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788AA242E69A1002CB114 /* CircularAccessoryButtonStyle.swift */; }; 895788B3242E69A2002CB114 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788AB242E69A2002CB114 /* ActionButton.swift */; }; - 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */; }; 8968B1122408B3520074BB48 /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8968B1112408B3520074BB48 /* UIFont.swift */; }; 897A5A9624C2175B00C4E71D /* BolusEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */; }; 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */; }; @@ -1208,7 +1207,6 @@ 895788A9242E69A1002CB114 /* Color.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; 895788AA242E69A1002CB114 /* CircularAccessoryButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularAccessoryButtonStyle.swift; sourceTree = ""; }; 895788AB242E69A2002CB114 /* ActionButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; - 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideSelectionViewController.swift; sourceTree = ""; }; 8968B1112408B3520074BB48 /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryView.swift; sourceTree = ""; }; 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryViewModel.swift; sourceTree = ""; }; @@ -2212,7 +2210,6 @@ A9DCF2D525B0F3C500C89088 /* LoopUIColorPalette+Default.swift */, 89E267FE229267DF00A3F2AF /* Optional.swift */, A967D94B24F99B9300CDDF8A /* OutputStream.swift */, - 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */, A9F703762489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift */, A999D40524663D18004C89D4 /* PumpManagerError.swift */, 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */, @@ -3818,7 +3815,6 @@ 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */, DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */, A9C62D842331700E00535612 /* DiagnosticLog+Subsystem.swift in Sources */, - 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */, C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */, A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */, 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */, diff --git a/Loop/Base.lproj/Main.storyboard b/Loop/Base.lproj/Main.storyboard index ae893d031f..5f1ecdf2bf 100644 --- a/Loop/Base.lproj/Main.storyboard +++ b/Loop/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -671,7 +671,6 @@ - @@ -695,19 +694,11 @@ - - - - - - - - - + diff --git a/Loop/Extensions/OverrideSelectionViewController.swift b/Loop/Extensions/OverrideSelectionViewController.swift deleted file mode 100644 index bbe072b813..0000000000 --- a/Loop/Extensions/OverrideSelectionViewController.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// OverrideSelectionViewController.swift -// Loop -// -// Created by Michael Pangburn on 1/27/19. -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import LoopKitUI -import LoopCore - - -extension OverrideSelectionViewController: IdentifiableClass { } diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 46f6e9f3c5..6b7a99b15e 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -210,8 +210,14 @@ class TemporaryPresetsManager { var clearOverrideTimer: Timer? public func scheduleClearOverride(override: TemporaryScheduleOverride) { clearOverrideTimer?.invalidate() + if override.duration.isInfinite { return } + log.default("Scheduling override end timer %{public}@", String(describing: override)) + clearOverrideTimer = Timer.scheduledTimer(withTimeInterval: override.scheduledEndDate.timeIntervalSince(Date()), repeats: false, block: { [weak self] _ in - Task { await self?.endOverride(override) } + Task { + self?.log.default("override end timer fired for %{public}@", String(describing: override)) + await self?.endOverride(override) + } }) } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index ca035f1f46..d41ff90a90 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -221,10 +221,6 @@ final class StatusTableViewController: LoopChartsTableViewController { private var appearedOnce = false - func presentLegacyPresets() { - performSegue(withIdentifier: OverrideSelectionViewController.className, sender: view) - } - override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -1484,14 +1480,6 @@ final class StatusTableViewController: LoopChartsTableViewController { vc.hidesBottomBarWhenPushed = true vc.enableEntryDeletion = FeatureFlags.entryDeletionEnabled vc.headerValueLabelColor = .insulinTintColor - case let vc as OverrideSelectionViewController: - if temporaryPresetsManager.futureOverrideEnabled() { - vc.scheduledOverride = temporaryPresetsManager.scheduleOverride - } - vc.presets = loopManager.settings.overridePresets - vc.glucoseUnit = statusCharts.glucose.glucoseUnit - vc.overrideHistory = temporaryPresetsManager.presetHistory.getEvents() - vc.delegate = self case let vc as PredictionTableViewController: vc.deviceManager = deviceManager vc.settingsManager = settingsManager @@ -2115,47 +2103,6 @@ extension StatusTableViewController: DoseProgressObserver { } } -extension StatusTableViewController: OverrideSelectionViewControllerDelegate { - func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didUpdatePresets presets: [TemporaryPreset]) { - settingsManager.mutateLoopSettings { settings in - settings.overridePresets = presets - } - } - - func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmOverride override: TemporaryScheduleOverride) { - temporaryPresetsManager.scheduleOverride = override - } - - func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmPreset preset: TemporaryPreset) { - let intent = EnableOverridePresetIntent() - intent.overrideName = preset.name - - let interaction = INInteraction(intent: intent, response: nil) - interaction.identifier = preset.id.uuidString - interaction.groupIdentifier = preset.name - interaction.donate { (error) in - if let error = error { - os_log(.error, "Failed to donate intent: %{public}@", String(describing: error)) - } - } - temporaryPresetsManager.scheduleOverride = preset.createOverride(enactTrigger: .local) - } - - func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didCancelOverride override: TemporaryScheduleOverride) { - temporaryPresetsManager.scheduleOverride = nil - } -} - -extension StatusTableViewController: AddEditOverrideTableViewControllerDelegate { - func addEditOverrideTableViewController(_ vc: AddEditOverrideTableViewController, didSaveOverride override: TemporaryScheduleOverride) { - temporaryPresetsManager.scheduleOverride = override - } - - func addEditOverrideTableViewController(_ vc: AddEditOverrideTableViewController, didCancelOverride override: TemporaryScheduleOverride) { - temporaryPresetsManager.scheduleOverride = nil - } -} - extension StatusTableViewController { fileprivate func addCGMManager(withIdentifier identifier: String) { switch deviceManager.setupCGMManager(withIdentifier: identifier) { diff --git a/Loop/View Models/StatusTableViewModel.swift b/Loop/View Models/StatusTableViewModel.swift index 24d6f9102d..530089620d 100644 --- a/Loop/View Models/StatusTableViewModel.swift +++ b/Loop/View Models/StatusTableViewModel.swift @@ -30,11 +30,10 @@ class StatusTableViewModel { let onboardingManager: OnboardingManager let temporaryPresetsManager: TemporaryPresetsManager let settingsViewModel: SettingsViewModel - let legacyPresetsEnabled: Bool var pendingPreset: SelectablePreset? - init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, settingsViewModel: SettingsViewModel, legacyPresetsEnabled: Bool = false) { + init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, settingsViewModel: SettingsViewModel) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter self.automaticDosingStatus = automaticDosingStatus @@ -54,6 +53,5 @@ class StatusTableViewModel { self.criticalEventLogExportManager = criticalEventLogExportManager self.bluetoothStateManager = bluetoothStateManager self.settingsViewModel = settingsViewModel - self.legacyPresetsEnabled = legacyPresetsEnabled } } diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index d797330bc4..33e6dc0a91 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -172,11 +172,7 @@ struct StatusTableView: View { case .bolus: viewController.presentBolusScreen() case .presets: - if viewModel.legacyPresetsEnabled { - viewController.presentLegacyPresets() - } else { - viewController.presentPresets() - } + viewController.presentPresets() case .settings: viewController.presentSettings() } From 5c8365782e5c095f2d65b65d62c394100083b0ed Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 1 Aug 2025 15:40:38 -0700 Subject: [PATCH 272/421] [LOOP-5389] Fetching efficiencies for delivery log (#812) --- .../DosingDecisionStoreProtocol.swift | 14 ++++++- .../InsulinDeliveryLogViewModel.swift | 38 ++++++++----------- .../Mock Stores/MockDosingDecisionStore.swift | 6 ++- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift index 3bbeee37b5..401dfb7e31 100644 --- a/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift @@ -6,8 +6,19 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // +import LoopAlgorithm import LoopKit +struct LightDosingDecision: DosingDecision { + let automaticDoseRecommendation: AutomaticDoseRecommendation? + let carbEntry: StoredCarbEntry? + let id: UUID + let manualBolusRecommendation: ManualBolusRecommendationWithDate? + let manualBolusRequested: Double? + let scheduleOverride: TemporaryScheduleOverride? + let syncIdentifier: UUID +} + protocol DosingDecisionStoreProtocol: CriticalEventLog { var delegate: DosingDecisionStoreDelegate? { get set } @@ -15,7 +26,8 @@ protocol DosingDecisionStoreProtocol: CriticalEventLog { func executeDosingDecisionQuery(fromQueryAnchor queryAnchor: DosingDecisionStore.QueryAnchor?, limit: Int, completion: @escaping (DosingDecisionStore.DosingDecisionQueryResult) -> Void) - func findDosingDecisionsById(_ id: UUID) async throws -> StoredDosingDecision? + func findDosingDecisionsById(_ id: UUID) async throws -> D? + func findDosingDecisionsByIds(_ ids: [UUID]) async throws -> [D] } extension DosingDecisionStore: DosingDecisionStoreProtocol { } diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index f989c83d4f..1a064b0eb4 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -171,13 +171,14 @@ class InsulinDeliveryLogViewModel { } let statusState = fetchStatusState() + let totalInsulinDelivered = await fetchTotalInsulinDeliveredToday() let doses = await fetchDoses(since: startDate) let lastAutoBolus = fetchLastAutoBolus(doses: doses) - let totalInsulinDelivered = await fetchTotalInsulinDeliveredToday() + let decisions = await fetchDosingDecisions(doses.compactMap(\.decisionId)) // map raw event data into delivery log events for display var events = [InsulinDeliveryLogEvent]() - await handleDoseEvents(doses: doses, fetchedDate: fetchedDate, events: &events) + handleDoseEvents(doses: doses, decisions: decisions, fetchedDate: fetchedDate, events: &events) handleAutomationEvents(&events) handlePresetEvents(startDate: startDate, &events) @@ -243,11 +244,15 @@ class InsulinDeliveryLogViewModel { (try? await loopDataManager.doseStore.getNormalizedDoseEntries(start: startDate, end: nil)) ?? [] } + private func fetchDosingDecisions(_ ids: [UUID]) async -> [LightDosingDecision] { + (try? await loopDataManager.dosingDecisionStore.findDosingDecisionsByIds(ids)) ?? [] + } + private func fetchTotalInsulinDeliveredToday() async -> LoopQuantity { await LoopQuantity(unit: .internationalUnit, doubleValue: loopDataManager.totalDeliveredToday()?.value ?? 0) } - private func handleBasalEvent(dose: DoseEntry, events: inout [InsulinDeliveryLogEvent]) async { + private func handleBasalEvent(dose: DoseEntry, decision: LightDosingDecision?, events: inout [InsulinDeliveryLogEvent]) { let automationEnabledDuringDose = loopDataManager.automationHistory.automationEnabled(at: dose.startDate) ?? loopDataManager.automaticDosingStatus.automaticDosingEnabled if dose.type == .tempBasal && dose.automatic == false { @@ -268,7 +273,7 @@ class InsulinDeliveryLogViewModel { ) ) } else if automationEnabledDuringDose { - if let decision = await dose.dosingDecision(from: loopDataManager.dosingDecisionStore) { + if let decision { if decision.scheduleOverride != nil { events.append( InsulinDeliveryLogEvent( @@ -342,7 +347,7 @@ class InsulinDeliveryLogViewModel { ) } } else { - fatalError("No `decision.automaticDoseRecommendation`") + assertionFailure("No `decision.automaticDoseRecommendation`") } } } else if let scheduledBasalRate = dose.scheduledBasalRate, scheduledBasalRate.doubleValue(for: .internationalUnitsPerHour) == dose.value { @@ -363,7 +368,7 @@ class InsulinDeliveryLogViewModel { ) ) } else { - fatalError("No `decision` or `scheduledBasalRate`") + assertionFailure("No `decision` or `scheduledBasalRate`") } } else { events.append( @@ -385,9 +390,7 @@ class InsulinDeliveryLogViewModel { } } - private func handleBolusEvents(dose: DoseEntry, events: inout [InsulinDeliveryLogEvent]) async { - let decision = await dose.dosingDecision(from: loopDataManager.dosingDecisionStore) - + private func handleBolusEvents(dose: DoseEntry, decision: LightDosingDecision?, events: inout [InsulinDeliveryLogEvent]) { if dose.automatic == true { events.append( InsulinDeliveryLogEvent( @@ -491,13 +494,14 @@ class InsulinDeliveryLogViewModel { } } - private func handleDoseEvents(doses: [DoseEntry], fetchedDate: Date, events: inout [InsulinDeliveryLogEvent]) async { + private func handleDoseEvents(doses: [DoseEntry], decisions: [LightDosingDecision], fetchedDate: Date, events: inout [InsulinDeliveryLogEvent]) { for dose in doses { + let decision = decisions.first(where: { $0.id == dose.decisionId }) switch dose.type { case .basal, .tempBasal: - await handleBasalEvent(dose: dose, events: &events) + handleBasalEvent(dose: dose, decision: decision, events: &events) case .bolus: - await handleBolusEvents(dose: dose, events: &events) + handleBolusEvents(dose: dose, decision: decision, events: &events) case .resume, .suspend: handleSuspendResumeEvents(dose: dose, fetchedDate: fetchedDate, events: &events) } @@ -536,13 +540,3 @@ class InsulinDeliveryLogViewModel { } } } - -private extension DoseEntry { - func dosingDecision(from store: DosingDecisionStoreProtocol) async -> StoredDosingDecision? { - if let decisionId = decisionId { - return try? await store.findDosingDecisionsById(decisionId) - } else { - return nil - } - } -} diff --git a/LoopTests/Mock Stores/MockDosingDecisionStore.swift b/LoopTests/Mock Stores/MockDosingDecisionStore.swift index affb4ceca0..a53d30afd4 100644 --- a/LoopTests/Mock Stores/MockDosingDecisionStore.swift +++ b/LoopTests/Mock Stores/MockDosingDecisionStore.swift @@ -38,7 +38,11 @@ class MockDosingDecisionStore: DosingDecisionStoreProtocol { } } - func findDosingDecisionsById(_ id: UUID) async throws -> StoredDosingDecision? { + func findDosingDecisionsById(_ id: UUID) async throws -> D? { nil } + + func findDosingDecisionsByIds(_ ids: [UUID]) async throws -> [D] { + [] + } } From e7a36d84fb2c07bd698797a16839c73df6e85c18 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 8 Aug 2025 03:25:26 -0700 Subject: [PATCH 273/421] [LOOP-5236] Preset Card States (#813) --- Loop/Views/Presets/Components/PresetCard.swift | 7 +++++-- Loop/Views/Presets/Components/PresetDetentView.swift | 3 ++- Loop/Views/Presets/Components/PresetStatsView.swift | 11 ++++++++--- Loop/Views/Presets/PresetsView.swift | 1 + 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index b35035ad93..3fd13157fe 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -12,10 +12,12 @@ import SwiftUI import LoopKit struct PresetCard: View { + @Environment(\.guidanceColors) private var guidanceColors - + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + let presetId: String let icon: PresetIcon let presetName: String let duration: PresetDuration @@ -105,7 +107,8 @@ struct PresetCard: View { correctionRange: correctionRange, guardrail: guardrail, therapySettingsImpactDisplayState: .hide, - isScheduled: isScheduled && expectedEndTime != nil + isScheduled: isScheduled && expectedEndTime != nil, + isActive: temporaryPresetsManager.activePreset?.id == presetId ) } .padding(10) diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 0e13f3c54a..12213e8465 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -143,7 +143,8 @@ struct PresetDetentView: View { correctionRange: preset.correctionRange, guardrail: settingsManager.guardrailForPreset(preset), therapySettingsImpactDisplayState: operation == .end ? .show(settingsImpact) : .hide, - isScheduled: false + isScheduled: false, + isActive: temporaryPresetsManager.activePreset?.id == preset.id ) actionArea diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift index 440660f118..20dcb287b7 100644 --- a/Loop/Views/Presets/Components/PresetStatsView.swift +++ b/Loop/Views/Presets/Components/PresetStatsView.swift @@ -18,6 +18,8 @@ struct PresetStatsView: View { } @Environment(\.guidanceColors) private var guidanceColors + @Environment(\.settingsManager) private var settingsManager + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference let insulinMultiplier: Double? @@ -25,6 +27,7 @@ struct PresetStatsView: View { let guardrail: Guardrail? let therapySettingsImpactDisplayState: TherapySettingsImpactDisplayState let isScheduled: Bool + let isActive: Bool private var numberFormatter: NumberFormatter { let formatter = NumberFormatter() @@ -122,13 +125,15 @@ struct PresetStatsView: View { Group { if let target = correctionRange { annotatedRangeText(target: target) + } else if isActive, let therapySettingsCorrectionRange = settingsManager.therapySettings.glucoseTargetRangeSchedule?.quantityRange(at: Date()) { + annotatedRangeText(target: therapySettingsCorrectionRange) } else { Text("Scheduled Range") .bold() } } - .font(.subheadline) - .accessibilitySortPriority(1) + .font(.subheadline) + .accessibilitySortPriority(1) } .accessibilityElement(children: .contain) } @@ -140,7 +145,7 @@ struct PresetStatsView: View { overallInsulinView Spacer() correctionRangeView - if isScheduled { + if isScheduled, !isActive { Spacer() Text(Image(systemName: "alarm")) .font(.footnote) diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 1a34eb12e9..d60af0952d 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -304,6 +304,7 @@ struct PresetsView: View { extension PresetCard { init (_ preset: SelectablePreset, guardrail: Guardrail, expectedEndTime: PresetExpectedEndTime? = nil) { self.init( + presetId: preset.id, icon: preset.icon, presetName: preset.name, duration: preset.duration, From fc7c0db800d7838807f3d88e072523726c67c88d Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 11 Aug 2025 14:02:59 -0700 Subject: [PATCH 274/421] [LOOP-5237] Bolusing with a preset (#814) --- Loop.xcodeproj/project.pbxproj | 4 + Loop/Managers/LoopDataManager.swift | 29 +++-- .../StatusTableViewController.swift | 70 ++--------- Loop/View Models/BolusEntryViewModel.swift | 46 +++++-- Loop/Views/BolusEntryView.swift | 112 +++++++++++------- Loop/Views/Presets/ActivePresetBanner.swift | 75 ++++++++++++ .../ViewModels/BolusEntryViewModelTests.swift | 9 +- 7 files changed, 219 insertions(+), 126 deletions(-) create mode 100644 Loop/Views/Presets/ActivePresetBanner.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index da5f617bc5..88f635887e 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -248,6 +248,7 @@ 84213C8B2D94948900642E78 /* InsulinDeliveryOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */; }; 84213C8D2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */; }; 843F32EA2E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */; }; + 847F23432E4543140035C864 /* ActivePresetBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847F23422E4543140035C864 /* ActivePresetBanner.swift */; }; 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; 84A7B5502D2D972C00B6D202 /* Image+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */; }; 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; @@ -1158,6 +1159,7 @@ 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryOverview.swift; sourceTree = ""; }; 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogEventRow.swift; sourceTree = ""; }; 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogViewModel.swift; sourceTree = ""; }; + 847F23422E4543140035C864 /* ActivePresetBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivePresetBanner.swift; sourceTree = ""; }; 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Optional.swift"; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; @@ -2586,6 +2588,7 @@ C105095E2D7A310B00118A37 /* ReviewNewPresetView.swift */, 84E8BBC22CC9B9780078E6CF /* Components */, 84E8BBB62CC990480078E6CF /* Training Content */, + 847F23422E4543140035C864 /* ActivePresetBanner.swift */, ); path = Presets; sourceTree = ""; @@ -3715,6 +3718,7 @@ 89CAB36324C8FE96009EE3CE /* PredictedGlucoseChartView.swift in Sources */, C105096D2D80E23A00118A37 /* DayPickerPopup.swift in Sources */, DDC389F82A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift in Sources */, + 847F23432E4543140035C864 /* ActivePresetBanner.swift in Sources */, 84BC5BDF2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift in Sources */, 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */, A9F703732489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift in Sources */, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 8cca65107a..ebfc3e90ea 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -310,7 +310,7 @@ final class LoopDataManager: ObservableObject { func fetchData( for baseTime: Date = Date(), - disablingPreMeal: Bool = false, + presumePresetEndingNow: Bool = false, ensureDosingCoverageStart: Date? = nil ) async throws -> StoredDataAlgorithmInput { // Need to fetch doses back as far as t - (DIA + DCA) for Dynamic carbs @@ -398,9 +398,8 @@ final class LoopDataManager: ObservableObject { var overrides = temporaryPresetsManager.presetHistory.getOverrideHistory(startDate: neededSensitivityTimeline.start, endDate: forecastEndTime) // For recommendation, we should consider preMeal override to be ending at time of dose - if disablingPreMeal, + if presumePresetEndingNow, let activeOverride = temporaryPresetsManager.activeOverride, - activeOverride.context == .preMeal, let index = overrides.lastIndex(of: activeOverride) { overrides[index].scheduledEndDate = baseTime } @@ -428,17 +427,16 @@ final class LoopDataManager: ObservableObject { // If we have an active override, and it's not a preMeal override that should be disabled, // then override the target for the entire forecast. if let activeOverride = temporaryPresetsManager.activeOverride, - let overriddenTargetRange = activeOverride.settings.targetRange + let overriddenTargetRange = activeOverride.settings.targetRange, + !presumePresetEndingNow { - if !(disablingPreMeal && activeOverride.context == .preMeal) { - target = [ - AbsoluteScheduleValue( - startDate: baseTime, - endDate: forecastEndTime, - value: overriddenTargetRange - ) - ] - } + target = [ + AbsoluteScheduleValue( + startDate: baseTime, + endDate: forecastEndTime, + value: overriddenTargetRange + ) + ] } // Create dosing strategy based on user setting @@ -660,10 +658,11 @@ final class LoopDataManager: ObservableObject { func recommendManualBolus( manualGlucoseSample: NewGlucoseSample? = nil, potentialCarbEntry: NewCarbEntry? = nil, - originalCarbEntry: StoredCarbEntry? = nil + originalCarbEntry: StoredCarbEntry? = nil, + ignoringOverride: Bool = false ) async throws -> ManualBolusRecommendation? { - var input = try await self.fetchData(for: now(), disablingPreMeal: potentialCarbEntry != nil) + var input = try await self.fetchData(for: now(), presumePresetEndingNow: ignoringOverride || potentialCarbEntry != nil) .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseSample) .removingCarbEntry(carbEntry: originalCarbEntry) .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index d41ff90a90..6ec3849cf6 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -773,7 +773,7 @@ final class StatusTableViewController: LoopChartsTableViewController { private func updateBannerRow(animated: Bool) { let warningWasVisible = tableView.numberOfRows(inSection: Section.alertWarning.rawValue) != 0 if !shouldShowBannerWarning && warningWasVisible { - tableView.deleteRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: animated ? .top : .none) + tableView.deleteRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: animated ? .fade : .none) } else if shouldShowBannerWarning && !warningWasVisible { tableView.insertRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: animated ? .top : .none) } else { @@ -815,7 +815,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case (false, true): tableView.insertRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .top : .none) case (true, false): - tableView.deleteRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .top : .none) + tableView.deleteRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .fade : .none) default: tableView.reloadRows(at: [IndexPath(row: 0, section: Section.presets.rawValue)], with: animated ? .automatic : .none) } @@ -824,7 +824,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case (false, true): tableView.insertRows(at: [IndexPath(row: 0, section: Section.hud.rawValue)], with: animated ? .top : .none) case (true, false): - tableView.deleteRows(at: [IndexPath(row: 0, section: Section.hud.rawValue)], with: animated ? .top : .none) + tableView.deleteRows(at: [IndexPath(row: 0, section: Section.hud.rawValue)], with: animated ? .fade : .none) default: break } @@ -916,69 +916,19 @@ final class StatusTableViewController: LoopChartsTableViewController { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch Section(rawValue: indexPath.section)! { case .presets: - func getTitleSubtitleCell() -> TitleSubtitleTableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: TitleSubtitleTableViewCell.className, for: indexPath) as! TitleSubtitleTableViewCell - cell.selectionStyle = .none - cell.backgroundColor = .clear - cell.titleLabel.text = nil - cell.titleLabel.textColor = .white - cell.titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) - cell.subtitleLabel.text = nil - cell.subtitleLabel.textColor = .white - cell.subtitleLabel.font = .systemFont(ofSize: 15) - cell.accessoryView = nil - cell.gradient.isHidden = true - return cell - } - - let cell = getTitleSubtitleCell() + let cell = UITableViewCell() switch presetsRowMode { case .hidden: break case .scheduleOverrideEnabled(let override): - switch override.context { - case .preMeal: - let symbolAttachment = NSTextAttachment() - symbolAttachment.image = UIImage(named: "Pre-Meal-symbol")?.withTintColor(.white) - - let attributedString = NSMutableAttributedString(attachment: symbolAttachment) - attributedString.append(NSAttributedString(string: NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)"))) - cell.titleLabel.attributedText = attributedString - cell.titleLabel.accessibilityIdentifier = "text_PreMealPresetCellTitle" - case .legacyWorkout: - let symbolAttachment = NSTextAttachment() - symbolAttachment.image = UIImage(named: "workout-symbol")?.withTintColor(.white) - - let attributedString = NSMutableAttributedString(attachment: symbolAttachment) - attributedString.append(NSAttributedString(string: NSLocalizedString(" Workout Preset", comment: "Status row title for workout override enabled (leading space is to separate from symbol)"))) - cell.titleLabel.attributedText = attributedString - cell.titleLabel.accessibilityIdentifier = "text_WorkoutPresetCellTitle" - case .preset(let preset): - cell.titleLabel.text = String(format: NSLocalizedString("%@ %@", comment: "The format for an active custom preset. (1: preset symbol)(2: preset name)"), preset.symbol, preset.name) - case .custom: - cell.titleLabel.text = NSLocalizedString("Single Use Preset", comment: "The title of the cell indicating a generic custom preset is enabled") - } - - if override.isActive() { - if let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == override.presetId }), case .preMeal(_) = preset { - cell.subtitleLabel.text = NSLocalizedString("on until carbs added", comment: "The format for the description of a premeal preset end date") - cell.subtitleLabel.accessibilityIdentifier = "text_PresetActiveOn" - } else { - switch override.duration { - case .finite: - let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) - cell.subtitleLabel.text = String(format: NSLocalizedString("on until %@", comment: "The format for the description of a finite custom preset end date"), endTimeText) - cell.subtitleLabel.accessibilityIdentifier = "text_PresetActiveOn" - case .indefinite: - cell.subtitleLabel.text = NSLocalizedString("on indefinitely", comment: "The format for the description of an indefinite custom preset end date") - cell.subtitleLabel.accessibilityIdentifier = "text_PresetActiveOn" - } - } - } else { - let startTimeText = DateFormatter.localizedString(from: override.startDate, dateStyle: .none, timeStyle: .short) - cell.subtitleLabel.text = String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText) + cell.contentConfiguration = UIHostingConfiguration { + ActivePresetBanner(override: override) } + .margins(.all, 0) + + cell.backgroundColor = .presets + cell.selectionStyle = .none } return cell diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 6bea80022f..87e55e82ff 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, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput + func fetchData(for baseTime: Date, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry @@ -40,10 +40,10 @@ protocol BolusEntryViewModelDelegate: AnyObject { func recommendManualBolus( manualGlucoseSample: NewGlucoseSample?, potentialCarbEntry: NewCarbEntry?, - originalCarbEntry: StoredCarbEntry? + originalCarbEntry: StoredCarbEntry?, + ignoringOverride: Bool ) async throws -> ManualBolusRecommendation? - func generatePrediction(input: StoredDataAlgorithmInput) throws -> [PredictedGlucoseValue] var activeInsulin: InsulinValue? { get } @@ -515,7 +515,7 @@ final class BolusEntryViewModel: ObservableObject { do { let startDate = now() - var input = try await delegate.fetchData(for: startDate, disablingPreMeal: potentialCarbEntry != nil, ensureDosingCoverageStart: nil) + var input = try await delegate.fetchData(for: startDate, presumePresetEndingNow: potentialCarbEntry != nil, ensureDosingCoverageStart: nil) let insulinModel = delegate.insulinModel(for: deliveryDelegate?.pumpInsulinType) @@ -546,7 +546,36 @@ final class BolusEntryViewModel: ObservableObject { } } + + struct PresetEffectedRecommendation { + let originalAmount: Double + let recommendedAmount: Double + + let formatter = QuantityFormatter(for: .internationalUnit) + + var originalAmountString: String? { + formatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: originalAmount)) + } + + var recommendedAmountString: String? { + formatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: recommendedAmount)) + } + + var differenceString: String? { + formatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: abs(recommendedAmount - originalAmount))) + } + + var showPredictionDifference: Bool { + originalAmount != recommendedAmount + } + + var direction: String { + recommendedAmount > originalAmount ? NSLocalizedString("increase", comment: "") : NSLocalizedString("decrease", comment: "") + } + } + @Published var presetEffectedRecommendation: PresetEffectedRecommendation? + private func updateRecommendedBolusAndNotice(isUpdatingFromUserInput: Bool) async { guard let delegate else { @@ -561,8 +590,10 @@ final class BolusEntryViewModel: ObservableObject { recommendation = try await computeBolusRecommendation() if let recommendation, deliveryDelegate != nil { + if let originalAmount = try await computeBolusRecommendation(ignoringOverride: true)?.amount { + presetEffectedRecommendation = PresetEffectedRecommendation(originalAmount: originalAmount, recommendedAmount: recommendation.amount) + } recommendedBolus = LoopQuantity(unit: .internationalUnit, doubleValue: recommendation.amount) - switch recommendation.notice { case .glucoseBelowSuspendThreshold: if let suspendThreshold = delegate.settings.suspendThreshold { @@ -610,7 +641,7 @@ final class BolusEntryViewModel: ObservableObject { } } - private func computeBolusRecommendation() async throws -> ManualBolusRecommendation? { + private func computeBolusRecommendation(ignoringOverride: Bool = false) async throws -> ManualBolusRecommendation? { guard let delegate else { return nil } @@ -618,7 +649,8 @@ final class BolusEntryViewModel: ObservableObject { return try await delegate.recommendManualBolus( manualGlucoseSample: manualGlucoseSample, potentialCarbEntry: potentialCarbEntry, - originalCarbEntry: originalCarbEntry + originalCarbEntry: originalCarbEntry, + ignoringOverride: ignoringOverride ) } diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index faac687d36..de7eda1cf4 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -30,51 +30,49 @@ struct BolusEntryView: View { @State private var editedBolusAmount = false var body: some View { - GeometryReader { geometry in - VStack(spacing: 0) { - List { - self.chartSection - self.summarySection - } - .padding(.top, -28) - .insetGroupedListStyle() - - self.actionArea - .frame(height: self.isKeyboardVisible || shouldBolusEntryBecomeFirstResponder ? 0 : nil) - .opacity(self.isKeyboardVisible || shouldBolusEntryBecomeFirstResponder ? 0 : 1) + VStack(spacing: 0) { + List { + self.chartSection + 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 - } + .padding(.top, -28) + .insetGroupedListStyle() + + self.actionArea + .frame(height: self.isKeyboardVisible || shouldBolusEntryBecomeFirstResponder ? 0 : nil) + .opacity(self.isKeyboardVisible || shouldBolusEntryBecomeFirstResponder ? 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.shouldBolusEntryBecomeFirstResponder = false } - .keyboardAware() - .edgesIgnoringSafeArea(self.isKeyboardVisible ? [] : .bottom) - .navigationBarTitle(self.title) - .supportedInterfaceOrientations(.portrait) - .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) - .onReceive(self.viewModel.$recommendedBolus) { recommendation in - // If the recommendation changes, and the user has not edited the bolus amount, update the bolus amount - let amount = recommendation?.doubleValue(for: .internationalUnit) ?? 0 - if !editedBolusAmount { - var newEnteredBolusString: String - if amount == 0 { - newEnteredBolusString = "" - } else { - newEnteredBolusString = viewModel.formatBolusAmount(amount) - } - enteredBolusStringBinding.wrappedValue = newEnteredBolusString + } + .keyboardAware() + .edgesIgnoringSafeArea(self.isKeyboardVisible ? [] : .bottom) + .navigationBarTitle(self.title) + .supportedInterfaceOrientations(.portrait) + .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) + .onReceive(self.viewModel.$recommendedBolus) { recommendation in + // If the recommendation changes, and the user has not edited the bolus amount, update the bolus amount + let amount = recommendation?.doubleValue(for: .internationalUnit) ?? 0 + if !editedBolusAmount { + var newEnteredBolusString: String + if amount == 0 { + newEnteredBolusString = "" } else { - // If the recommendation changes, and the user has edited the bolus amount, set the bolus amount to 0 - enteredBolusStringBinding.wrappedValue = "0" + newEnteredBolusString = viewModel.formatBolusAmount(amount) } + enteredBolusStringBinding.wrappedValue = newEnteredBolusString + } else { + // If the recommendation changes, and the user has edited the bolus amount, set the bolus amount to 0 + enteredBolusStringBinding.wrappedValue = "0" } - .task { - await self.viewModel.generateRecommendationAndStartObserving() - } + } + .task { + await self.viewModel.generateRecommendationAndStartObserving() } } @@ -131,6 +129,13 @@ struct BolusEntryView: View { } .padding(.top, 12) .padding(.bottom, 8) + } header: { + if let scheduleOverride = viewModel.scheduleOverride ?? viewModel.preMealOverride { + ActivePresetBanner(override: scheduleOverride) + .padding(.horizontal, -32) + .padding(.bottom, 8) + .textCase(nil) + } } } @@ -167,6 +172,8 @@ struct BolusEntryView: View { ) } + @State private var expandedPresetSummary: Bool = false + private var summarySection: some View { Section { VStack(spacing: 16) { @@ -174,6 +181,31 @@ struct BolusEntryView: View { .bold() .frame(maxWidth: .infinity, alignment: .leading) + if (viewModel.scheduleOverride ?? viewModel.preMealOverride) != nil, let presetEffectedRecommendation = viewModel.presetEffectedRecommendation, presetEffectedRecommendation.showPredictionDifference { + HStack(alignment: .top, spacing: 12) { + Text(Image(systemName: "info.circle")) + .foregroundStyle(Color.accentColor) + + VStack(alignment: .leading, spacing: 8) { + Text("Recommended bolus adjusted due to preset") + .frame(maxWidth: .infinity, alignment: .leading) + + if expandedPresetSummary, let differenceString = presetEffectedRecommendation.differenceString, let originalAmountString = presetEffectedRecommendation.originalAmountString { + Text("This reflects a \(differenceString) \(presetEffectedRecommendation.direction) from the original \(originalAmountString) due to preset adjustments.") + .foregroundStyle(.secondary) + } + } + .font(.subheadline) + + Text(Image(systemName: "chevron.up")) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(expandedPresetSummary ? 180 : 0)) + } + .onTapGesture { + expandedPresetSummary.toggle() + } + } + if viewModel.isManualGlucoseEntryEnabled { ManualGlucoseEntryRow(quantity: $viewModel.manualGlucoseQuantity) } else if viewModel.potentialCarbEntry != nil { diff --git a/Loop/Views/Presets/ActivePresetBanner.swift b/Loop/Views/Presets/ActivePresetBanner.swift new file mode 100644 index 0000000000..cdb05cedf3 --- /dev/null +++ b/Loop/Views/Presets/ActivePresetBanner.swift @@ -0,0 +1,75 @@ +// +// ActivePresetBanner.swift +// Loop +// +// Created by Cameron Ingham on 8/7/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKit +import SwiftUI + +struct ActivePresetBanner: View { + + @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager + + let override: TemporaryScheduleOverride + + @ViewBuilder + var title: some View { + switch override.context { + case .preMeal: + Group { + Text(Image("Pre-Meal-symbol")) + Text(" ") + Text(NSLocalizedString("Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)")) + } + .accessibilityIdentifier("text_PreMealPresetCellTitle") + case .legacyWorkout: + Group { + Text(Image("workout-symbol")) + Text(" ") + Text( NSLocalizedString("Workout Preset", comment: "Status row title for workout override enabled (leading space is to separate from symbol)")) + } + .accessibilityIdentifier("text_WorkoutPresetCellTitle") + case .preset(let preset): + Text(String(format: NSLocalizedString("%@ %@", comment: "The format for an active custom preset. (1: preset symbol)(2: preset name)"), preset.symbol, preset.name)) + case .custom: + Text(NSLocalizedString("Single Use Preset", comment: "The title of the cell indicating a generic custom preset is enabled")) + } + } + + @ViewBuilder + var subtitle: some View { + if override.isActive() { + if let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == override.presetId }), case .preMeal(_) = preset { + Text(NSLocalizedString("on until carbs added", comment: "The format for the description of a premeal preset end date")) + .accessibilityIdentifier("text_PresetActiveOn") + } else { + switch override.duration { + case .finite: + let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) + Text(String(format: NSLocalizedString("on until %@", comment: "The format for the description of a finite custom preset end date"), endTimeText)) + .accessibilityIdentifier("text_PresetActiveOn") + case .indefinite: + Text(NSLocalizedString("on indefinitely", comment: "The format for the description of an indefinite custom preset end date")) + .accessibilityIdentifier("text_PresetActiveOn") + } + } + } else { + let startTimeText = DateFormatter.localizedString(from: override.startDate, dateStyle: .none, timeStyle: .short) + Text(String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText)) + } + } + + var body: some View { + HStack { + title + .font(.body.weight(.semibold)) + + Spacer() + + subtitle + .font(.subheadline) + } + .padding() + .foregroundStyle(Color(UIColor.systemBackground)) + .background(Color.presets) + } +} diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index d3f688ea5d..33b9bc5f3d 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, ensureDosingCoverageStart: nil) + let input = try await delegate.fetchData(for: Self.exampleStartDate, presumePresetEndingNow: false, ensureDosingCoverageStart: nil) let prediction = try input.predictGlucose() await bolusEntryViewModel.update() XCTAssertEqual(prediction, bolusEntryViewModel.predictedGlucoseValues.map { PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) }) @@ -200,7 +200,7 @@ class BolusEntryViewModelTests: XCTestCase { func testUpdatePredictedGlucoseValuesWithManual() async throws { do { - let input = try await delegate.fetchData(for: Self.exampleStartDate, disablingPreMeal: false, ensureDosingCoverageStart: nil) + let input = try await delegate.fetchData(for: Self.exampleStartDate, presumePresetEndingNow: false, ensureDosingCoverageStart: nil) let prediction = try input.predictGlucose() await bolusEntryViewModel.update() bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity @@ -870,7 +870,7 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { automaticBolusApplicationFactor: 0.4 ) - func fetchData(for baseTime: Date, disablingPreMeal: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput { + func fetchData(for baseTime: Date, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput { loopStateInput.predictionStart = baseTime return loopStateInput } @@ -946,7 +946,8 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { func recommendManualBolus( manualGlucoseSample: NewGlucoseSample?, potentialCarbEntry: NewCarbEntry?, - originalCarbEntry: StoredCarbEntry? + originalCarbEntry: StoredCarbEntry?, + ignoringOverride: Bool ) async throws -> ManualBolusRecommendation? { manualGlucoseSampleForBolusRecommendation = manualGlucoseSample From d63ea3435c4f67bbd5a9964f802b94b28213db3f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 15 Aug 2025 11:24:23 -0500 Subject: [PATCH 275/421] LOOP-5314 FeatureFlag to limit strategy to automatic bolus (#815) * add feature flag to only allow auto-bolus * Fix test * Cancel ongoing automatic bolus for manual bolus * Add partial delivery text for automatic boluses in log * set dosingStrategySelectionEnabled environment --- Common/FeatureFlags.swift | 10 +-- .../AlertStore+SimulatedCoreData.swift | 9 +- .../CarbStore+SimulatedCoreData.swift | 26 +++--- ...osingDecisionStore+SimulatedCoreData.swift | 26 ++---- .../GlucoseStore+SimulatedCoreData.swift | 20 +---- ...ersistentDeviceLog+SimulatedCoreData.swift | 9 +- .../SettingsStore+SimulatedCoreData.swift | 20 +---- Loop/Managers/Alerts/AlertStore.swift | 26 +++--- Loop/Managers/DeviceDataManager.swift | 89 +++++++++---------- Loop/Managers/LoopAppManager.swift | 73 ++++----------- Loop/Managers/LoopDataManager.swift | 25 ++++-- Loop/Managers/TemporaryPresetsManager.swift | 6 +- Loop/Managers/WatchDataManager.swift | 7 ++ .../StatusTableViewController.swift | 9 +- .../ManualEntryDoseViewModel.swift | 1 + .../InsulinDeliveryLogEventRow.swift | 25 +++--- Loop/Views/SettingsView.swift | 2 +- LoopTests/Managers/LoopDataManagerTests.swift | 2 +- 18 files changed, 166 insertions(+), 219 deletions(-) diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 68a1fc46dd..4664b6b0b5 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -11,7 +11,7 @@ import Foundation let FeatureFlags = FeatureFlagConfiguration() struct FeatureFlagConfiguration: Decodable { - let automaticBolusEnabled: Bool + let dosingStrategySelectionEnabled: Bool let cgmManagerCategorizeManualGlucoseRangeEnabled: Bool let criticalAlertsEnabled: Bool let entryDeletionEnabled: Bool @@ -42,10 +42,10 @@ struct FeatureFlagConfiguration: Decodable { fileprivate init() { // Swift compiler config is inverse, since the default state is enabled. - #if AUTOMATIC_BOLUS_DISABLED - self.automaticBolusEnabled = false + #if DOSING_STRATEGY_SELECTION_DISABLED + self.dosingStrategySelectionEnabled = false #else - self.automaticBolusEnabled = true + self.dosingStrategySelectionEnabled = true #endif #if CGM_MANAGER_CATEGORIZE_GLUCOSE_RANGE_ENABLED @@ -257,7 +257,7 @@ extension FeatureFlagConfiguration : CustomDebugStringConvertible { "* showEventualBloodGlucoseOnWatchEnabled: \(showEventualBloodGlucoseOnWatchEnabled)", "* simulatedCoreDataEnabled: \(simulatedCoreDataEnabled)", "* siriEnabled: \(siriEnabled)", - "* automaticBolusEnabled: \(automaticBolusEnabled)", + "* dosingStrategySelectionEnabled: \(dosingStrategySelectionEnabled)", "* manualDoseEntryEnabled: \(manualDoseEntryEnabled)", "* allowDebugFeatures: \(allowDebugFeatures)", "* simpleBolusCalculatorEnabled: \(simpleBolusCalculatorEnabled)", diff --git a/Loop/Extensions/AlertStore+SimulatedCoreData.swift b/Loop/Extensions/AlertStore+SimulatedCoreData.swift index 2634bc3274..ecab37321f 100644 --- a/Loop/Extensions/AlertStore+SimulatedCoreData.swift +++ b/Loop/Extensions/AlertStore+SimulatedCoreData.swift @@ -17,7 +17,7 @@ extension AlertStore { private var simulatedPerDay: Int { 12 } private var simulatedLimit: Int { 10000 } - func generateSimulatedHistoricalStoredAlerts(completion: @escaping (Error?) -> Void) { + func generateSimulatedHistoricalStoredAlerts() async throws { var startDate = Calendar.current.startOfDay(for: expireDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var simulated = [DatedAlert]() @@ -29,8 +29,7 @@ extension AlertStore { if simulated.count >= simulatedLimit { if let error = addAlerts(alerts: simulated) { - completion(error) - return + throw error } simulated = [] } @@ -38,7 +37,9 @@ extension AlertStore { startDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! } - completion(addAlerts(alerts: simulated)) + if let error = addAlerts(alerts: simulated) { + throw error + } } func purgeHistoricalStoredAlerts(completion: @escaping (Error?) -> Void) { diff --git a/Loop/Extensions/CarbStore+SimulatedCoreData.swift b/Loop/Extensions/CarbStore+SimulatedCoreData.swift index 81530e1233..f29f645b05 100644 --- a/Loop/Extensions/CarbStore+SimulatedCoreData.swift +++ b/Loop/Extensions/CarbStore+SimulatedCoreData.swift @@ -18,7 +18,7 @@ extension CarbStore { private var simulatedPerDay: Int { 10 } private var simulatedLimit: Int { 10000 } - func generateSimulatedHistoricalCarbObjects(completion: @escaping (Error?) -> Void) { + func generateSimulatedHistoricalCarbObjects() async throws { var startDate = Calendar.current.startOfDay(for: earliestCacheDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var simulated = [NewCarbEntry]() @@ -31,28 +31,26 @@ extension CarbStore { } if simulated.count >= simulatedLimit { - if let error = addSimulatedHistoricalCarbObjects(entries: simulated) { - completion(error) - return - } + try await addSimulatedHistoricalCarbObjects(entries: simulated) simulated = [] } startDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! } - completion(addSimulatedHistoricalCarbObjects(entries: simulated)) + try await addSimulatedHistoricalCarbObjects(entries: simulated) } - private func addSimulatedHistoricalCarbObjects(entries: [NewCarbEntry]) -> Error? { - var addError: Error? - let semaphore = DispatchSemaphore(value: 0) - addNewCarbEntries(entries: entries) { error in - addError = error - semaphore.signal() + private func addSimulatedHistoricalCarbObjects(entries: [NewCarbEntry]) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + addNewCarbEntries(entries: entries) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } } - semaphore.wait() - return addError } func purgeHistoricalCarbObjects(completion: @escaping (Error?) -> Void) { diff --git a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift index f83e02446a..83072b4701 100644 --- a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift +++ b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift @@ -19,7 +19,7 @@ extension DosingDecisionStore { private var simulatedStartDateInterval: TimeInterval { .minutes(5) } private var simulatedLimit: Int { 10000 } - func generateSimulatedHistoricalDosingDecisionObjects(completion: @escaping (Error?) -> Void) { + func generateSimulatedHistoricalDosingDecisionObjects() async throws { var startDate = Calendar.current.startOfDay(for: expireDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var simulated = [StoredDosingDecision]() @@ -28,32 +28,18 @@ extension DosingDecisionStore { simulated.append(StoredDosingDecision.simulated(date: startDate)) if simulated.count >= simulatedLimit { - if let error = addSimulatedHistoricalDosingDecisionObjects(dosingDecisions: simulated) { - completion(error) - return - } + try await addStoredDosingDecisions(dosingDecisions: simulated) simulated = [] } startDate = startDate.addingTimeInterval(simulatedStartDateInterval) } - - completion(addSimulatedHistoricalDosingDecisionObjects(dosingDecisions: simulated)) - } - - private func addSimulatedHistoricalDosingDecisionObjects(dosingDecisions: [StoredDosingDecision]) -> Error? { - var addError: Error? - let semaphore = DispatchSemaphore(value: 0) - addStoredDosingDecisions(dosingDecisions: dosingDecisions) { error in - addError = error - semaphore.signal() - } - semaphore.wait() - return addError + + try await addStoredDosingDecisions(dosingDecisions: simulated) } - func purgeHistoricalDosingDecisionObjects(completion: @escaping (Error?) -> Void) { - purgeDosingDecisions(before: historicalEndDate, completion: completion) + func purgeHistoricalDosingDecisionObjects() async throws { + try await purgeDosingDecisionObjects(before: historicalEndDate) } } diff --git a/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift b/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift index 4e27c520b0..99cc96c19e 100644 --- a/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift +++ b/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift @@ -21,7 +21,7 @@ extension GlucoseStore { private var simulatedValueIncrement: Double { 2.0 * .pi / 72.0 } // 6 hour period private var simulatedLimit: Int { 10000 } - func generateSimulatedHistoricalGlucoseObjects(completion: @escaping (Error?) -> Void) { + func generateSimulatedHistoricalGlucoseObjects() async throws { var startDate = Calendar.current.startOfDay(for: earliestCacheDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var value = 0.0 @@ -57,10 +57,7 @@ extension GlucoseStore { trendRate: LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: trendRateValue))) if simulated.count >= simulatedLimit { - if let error = addSimulatedHistoricalGlucoseObjects(samples: simulated) { - completion(error) - return - } + try await addNewGlucoseSamples(samples: simulated) simulated = [] } @@ -68,18 +65,7 @@ extension GlucoseStore { startDate = startDate.addingTimeInterval(simulatedStartDateInterval) } - completion(addSimulatedHistoricalGlucoseObjects(samples: simulated)) - } - - private func addSimulatedHistoricalGlucoseObjects(samples: [NewGlucoseSample]) -> Error? { - var addError: Error? - let semaphore = DispatchSemaphore(value: 0) - addNewGlucoseSamples(samples: samples) { error in - addError = error - semaphore.signal() - } - semaphore.wait() - return addError + try await addNewGlucoseSamples(samples: simulated) } func purgeHistoricalGlucoseObjects() async throws { diff --git a/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift b/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift index 1651404078..a6444d3859 100644 --- a/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift +++ b/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift @@ -17,7 +17,7 @@ extension PersistentDeviceLog { private var simulatedPerHour: Int { 60 } private var simulatedLimit: Int { 10000 } - func generateSimulatedHistoricalDeviceLogEntries(completion: @escaping (Error?) -> Void) { + func generateSimulatedHistoricalDeviceLogEntries() async throws { var startDate = Calendar.current.startOfDay(for: earliestLogEntryDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var simulated = [StoredDeviceLogEntry]() @@ -28,17 +28,14 @@ extension PersistentDeviceLog { } if simulated.count >= simulatedLimit { - if let error = addStoredDeviceLogEntries(entries: simulated) { - completion(error) - return - } + try await addStoredDeviceLogEntries(entries: simulated) simulated = [] } startDate = Calendar.current.date(byAdding: .hour, value: 1, to: startDate)! } - completion(addStoredDeviceLogEntries(entries: simulated)) + try await addStoredDeviceLogEntries(entries: simulated) } func purgeHistoricalDeviceLogEntries(completion: @escaping (Error?) -> Void) { diff --git a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift index e3403e8e88..403c5cf5cc 100644 --- a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift +++ b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift @@ -19,7 +19,7 @@ extension SettingsStore { private var simulatedPerDay: Int { 2 } private var simulatedLimit: Int { 10000 } - func generateSimulatedHistoricalSettingsObjects(completion: @escaping (Error?) -> Void) { + func generateSimulatedHistoricalSettingsObjects() async throws { var startDate = Calendar.current.startOfDay(for: expireDate) let endDate = Calendar.current.startOfDay(for: historicalEndDate) var simulated = [StoredSettings]() @@ -30,28 +30,14 @@ extension SettingsStore { } if simulated.count >= simulatedLimit { - if let error = addSimulatedHistoricalSettingsObjects(settings: simulated) { - completion(error) - return - } + try await addStoredSettings(settings: simulated) simulated = [] } startDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! } - completion(addSimulatedHistoricalSettingsObjects(settings: simulated)) - } - - private func addSimulatedHistoricalSettingsObjects(settings: [StoredSettings]) -> Error? { - var addError: Error? - let semaphore = DispatchSemaphore(value: 0) - addStoredSettings(settings: settings) { error in - addError = error - semaphore.signal() - } - semaphore.wait() - return addError + try await addStoredSettings(settings: simulated) } func purgeHistoricalSettingsObjects(completion: @escaping (Error?) -> Void) { diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift index c0438c5721..abfda250df 100644 --- a/Loop/Managers/Alerts/AlertStore.swift +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -9,6 +9,7 @@ import CoreData import LoopKit +@MainActor public protocol AlertStoreDelegate: AnyObject { /** Informs the delegate that the alert store has updated alert data. @@ -83,16 +84,16 @@ public class AlertStore { } public func recordIssued(alert: Alert, at date: Date = Date()) async { - await self.managedObjectContext.perform { - _ = StoredAlert(from: alert, context: self.managedObjectContext, issuedDate: date) - do { + do { + try await self.managedObjectContext.perform { + _ = StoredAlert(from: alert, context: self.managedObjectContext, issuedDate: date) try self.managedObjectContext.save() self.log.default("Recorded alert: %{public}@", alert.identifier.value) self.purgeExpired() - self.delegate?.alertStoreHasUpdatedAlertData(self) - } catch { - self.log.error("Could not store alert: %{public}@, %{public}@", alert.identifier.value, String(describing: error)) } + await delegate?.alertStoreHasUpdatedAlertData(self) + } catch { + self.log.error("Could not store alert: %{public}@, %{public}@", alert.identifier.value, String(describing: error)) } } @@ -103,8 +104,8 @@ public class AlertStore { try self.managedObjectContext.save() self.log.default("Recorded retracted alert: %{public}@", alert.identifier.value) self.purgeExpired() - self.delegate?.alertStoreHasUpdatedAlertData(self) } + await delegate?.alertStoreHasUpdatedAlertData(self) } public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date()) async throws { @@ -223,6 +224,8 @@ extension AlertStore { throw AlertStoreError.notFound } } + purgeExpired() + await delegate?.alertStoreHasUpdatedAlertData(self) } private func recordUpdateOfLatest(identifier: Alert.Identifier, @@ -238,6 +241,8 @@ extension AlertStore { throw AlertStoreError.notFound } } + purgeExpired() + await delegate?.alertStoreHasUpdatedAlertData(self) } private func update(objects: [StoredAlert], with updateBlock: @escaping ManagedObjectUpdateBlock) throws { @@ -249,8 +254,6 @@ extension AlertStore { self.log.default("%{public}@ alert: %{public}@", shouldDelete ? "Deleted" : "Recorded", alert.identifier.value) } try self.managedObjectContext.save() - self.purgeExpired() - self.delegate?.alertStoreHasUpdatedAlertData(self) } @@ -585,9 +588,12 @@ extension AlertStore { return error } - self.delegate?.alertStoreHasUpdatedAlertData(self) + Task { @MainActor in + self.delegate?.alertStoreHasUpdatedAlertData(self) + } self.log.info("Added %d StoredAlerts", alerts.count) return nil } + } diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 239904e59e..ead6c9af61 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -643,7 +643,6 @@ private extension DeviceDataManager { dispatchPrecondition(condition: .onQueue(.main)) pumpManager?.pumpManagerDelegate = self - pumpManager?.delegateQueue = DispatchQueue.main reportPluginInitializationComplete() doseStore.device = pumpManager?.status.device @@ -731,24 +730,22 @@ extension DeviceDataManager { throw LoopError.configurationError(.pumpManager) } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - pumpManager.enactBolus(decisionId: decisionId, units: units, activationType: activationType) { (error) in - if let error = error { - self.log.error("%{public}@", String(describing: error)) - switch error { - case .uncertainDelivery: - // Do not generate notification on uncertain delivery error - break - default: - // Do not generate notifications for automatic boluses that fail. - if !activationType.isAutomatic { - NotificationManager.sendBolusFailureNotification(for: error, units: units, at: Date(), decisionId: decisionId, activationType: activationType) - } - } - continuation.resume(throwing: error) - } else { - continuation.resume() - } + var automaticBolusOngoing = false + if case .inProgress(let dose) = pumpManager.status.bolusState, dose.automatic == true { + automaticBolusOngoing = true + } + + if automaticBolusOngoing && activationType != .automatic { + let _ = try? await pumpManager.cancelBolus() + } + + do { + try await pumpManager.enactBolus(decisionId: decisionId, units: units, activationType: activationType) + } catch PumpManagerError.uncertainDelivery { + // Do not generate notification on uncertain delivery error + } catch { + if !activationType.isAutomatic, let error = error as? PumpManagerError { + NotificationManager.sendBolusFailureNotification(for: error, units: units, at: Date(), decisionId: decisionId, activationType: activationType) } } } @@ -894,7 +891,7 @@ extension DeviceDataManager: CGMManagerDelegate { nonisolated func cgmManager(_ manager: LoopKit.CGMManager, hasNew events: [PersistedCgmEvent]) { - Task { + Task { @MainActor in do { try await cgmEventStore.add(events: events) } catch { @@ -1029,43 +1026,44 @@ extension DeviceDataManager: PumpManagerDelegate { } } - func pumpManagerPumpWasReplaced(_ pumpManager: PumpManager) { + nonisolated func pumpManagerPumpWasReplaced(_ pumpManager: PumpManager) { } - func pumpManagerWillDeactivate(_ pumpManager: PumpManager) { - dispatchPrecondition(condition: .onQueue(.main)) - log.default("Pump manager with identifier '%{public}@' will deactivate", pumpManager.pluginIdentifier) + nonisolated func pumpManagerWillDeactivate(_ pumpManager: PumpManager) { + Task { @MainActor in + log.default("Pump manager with identifier '%{public}@' will deactivate", pumpManager.pluginIdentifier) - self.pumpManager = nil - deliveryUncertaintyAlertManager = nil - settingsManager.storeSettings() + self.pumpManager = nil + deliveryUncertaintyAlertManager = nil + settingsManager.storeSettings() + } } - func pumpManager(_ pumpManager: PumpManager, didUpdatePumpRecordsBasalProfileStartEvents pumpRecordsBasalProfileStartEvents: Bool) { - dispatchPrecondition(condition: .onQueue(.main)) - log.default("PumpManager:%{public}@ did update pumpRecordsBasalProfileStartEvents to %{public}@", String(describing: type(of: pumpManager)), String(describing: pumpRecordsBasalProfileStartEvents)) - - doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents + nonisolated func pumpManager(_ pumpManager: PumpManager, didUpdatePumpRecordsBasalProfileStartEvents pumpRecordsBasalProfileStartEvents: Bool) { + Task { @MainActor in + log.default("PumpManager:%{public}@ did update pumpRecordsBasalProfileStartEvents to %{public}@", String(describing: type(of: pumpManager)), String(describing: pumpRecordsBasalProfileStartEvents)) + doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents + } } - func pumpManager(_ pumpManager: PumpManager, didError error: PumpManagerError) { - dispatchPrecondition(condition: .onQueue(.main)) - log.error("PumpManager:%{public}@ did error: %{public}@", String(describing: type(of: pumpManager)), String(describing: error)) + nonisolated func pumpManager(_ pumpManager: PumpManager, didError error: PumpManagerError) { + Task { @MainActor in + dispatchPrecondition(condition: .onQueue(.main)) + log.error("PumpManager:%{public}@ did error: %{public}@", String(describing: type(of: pumpManager)), String(describing: error)) - setLastError(error: error) + setLastError(error: error) + } } - func pumpManager( + nonisolated func pumpManager( _ pumpManager: PumpManager, hasNewPumpEvents events: [NewPumpEvent], lastReconciliation: Date?, replacePendingEvents: Bool, completion: @escaping (_ error: Error?) -> Void) { - dispatchPrecondition(condition: .onQueue(.main)) - log.default("PumpManager:%{public}@ hasNewPumpEvents (lastReconciliation = %{public}@)", String(describing: type(of: pumpManager)), String(describing: lastReconciliation)) - - Task { + Task { @MainActor in + log.default("PumpManager:%{public}@ hasNewPumpEvents (lastReconciliation = %{public}@)", String(describing: type(of: pumpManager)), String(describing: lastReconciliation)) do { try await doseStore.addPumpEvents(events, lastReconciliation: lastReconciliation, replacePendingEvents: replacePendingEvents) } catch { @@ -1078,7 +1076,7 @@ extension DeviceDataManager: PumpManagerDelegate { } } - func pumpManager( + nonisolated func pumpManager( _ pumpManager: PumpManager, didReadReservoirValue units: Double, at date: Date, @@ -1099,7 +1097,6 @@ extension DeviceDataManager: PumpManagerDelegate { } func startDateToFilterNewPumpEvents(for manager: PumpManager) -> Date { - dispatchPrecondition(condition: .onQueue(.main)) return doseStore.pumpEventQueryAfterDate } @@ -1117,10 +1114,10 @@ extension DeviceDataManager: PumpManagerOnboardingDelegate { } func pumpManagerOnboarding(didOnboardPumpManager pumpManager: PumpManagerUI) { - Task { @MainActor in - precondition(pumpManager.isOnboarded) - log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.pluginIdentifier) + precondition(pumpManager.isOnboarded) + log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.pluginIdentifier) + Task { await refreshDeviceData() settingsManager.storeSettings() } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 916e0a223b..92c93c829a 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -105,7 +105,7 @@ class LoopAppManager: NSObject { // 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 displayGlucoseUnitObservers = WeakSet() private var state: State = .initialize @@ -323,7 +323,8 @@ class LoopAppManager: NSObject { automaticDosingStatus: automaticDosingStatus, trustedTimeOffset: { self.trustedTimeChecker.detectedSystemTimeOffset }, analyticsServicesManager: analyticsServicesManager, - carbAbsorptionModel: carbModel + carbAbsorptionModel: carbModel, + dosingStrategySelectionEnabled: FeatureFlags.dosingStrategySelectionEnabled ) cacheStore.delegate = loopDataManager @@ -843,15 +844,14 @@ protocol DisplayGlucoseUnitBroadcaster: AnyObject { extension LoopAppManager: DisplayGlucoseUnitBroadcaster { func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { - let queue = DispatchQueue.main - displayGlucoseUnitObservers.insert(observer, queue: queue) - queue.async { + displayGlucoseUnitObservers.insert(observer) + Task { @MainActor in observer.unitDidChange(to: self.displayGlucosePreference.unit) } } func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { - displayGlucoseUnitObservers.removeElement(observer) + displayGlucoseUnitObservers.remove(observer) displayGlucoseUnitObservers.cleanupDeallocatedElements() } @@ -1094,50 +1094,20 @@ extension LoopAppManager: SimulatedData { fatalError("\(#function) invoke with no settings store") } - settingsManager.settingsStore?.generateSimulatedHistoricalSettingsObjects() { error in - guard error == nil else { + Task { @MainActor in + do { + try await settingsManager.settingsStore?.generateSimulatedHistoricalSettingsObjects() + try await self.doseStore.generateSimulatedHistoricalPumpEvents() + try await self.glucoseStore.generateSimulatedHistoricalGlucoseObjects() + try await self.carbStore.generateSimulatedHistoricalCarbObjects() + try await self.dosingDecisionStore.generateSimulatedHistoricalDosingDecisionObjects() + try await self.deviceDataManager.deviceLog.generateSimulatedHistoricalDeviceLogEntries() + try await self.alertManager.alertStore.generateSimulatedHistoricalStoredAlerts() + } catch { completion(error) return } - Task { @MainActor in - guard FeatureFlags.simulatedCoreDataEnabled else { - fatalError("\(#function) should be invoked only when simulated core data is enabled") - } - self.glucoseStore.generateSimulatedHistoricalGlucoseObjects() { error in - guard error == nil else { - completion(error) - return - } - self.carbStore.generateSimulatedHistoricalCarbObjects() { error in - guard error == nil else { - completion(error) - return - } - self.dosingDecisionStore.generateSimulatedHistoricalDosingDecisionObjects() { error in - Task { - guard error == nil else { - completion(error) - return - } - do { - try await self.doseStore.generateSimulatedHistoricalPumpEvents() - } catch { - completion(error) - return - } - self.deviceDataManager.deviceLog.generateSimulatedHistoricalDeviceLogEntries() { error in - guard error == nil else { - completion(error) - return - } - self.alertManager.alertStore.generateSimulatedHistoricalStoredAlerts(completion: completion) - } - } - } - } - } - } } } @@ -1160,22 +1130,17 @@ extension LoopAppManager: SimulatedData { do { try await self.doseStore.purgeHistoricalPumpEvents() try await self.glucoseStore.purgeHistoricalGlucoseObjects() + try await self.dosingDecisionStore.purgeHistoricalDosingDecisionObjects() } catch { 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 - 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 ebfc3e90ea..1ccc6b8039 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -144,6 +144,8 @@ final class LoopDataManager: ObservableObject { private var lastManualBolusRecommendation: ManualBolusRecommendation? + private var dosingStrategySelectionEnabled: Bool + var usePositiveMomentumAndRCForManualBoluses: Bool var automationHistory: [AutomationHistoryEntry] { @@ -168,7 +170,8 @@ final class LoopDataManager: ObservableObject { trustedTimeOffset: @escaping () async -> TimeInterval, analyticsServicesManager: AnalyticsServicesManager?, carbAbsorptionModel: CarbAbsorptionModel, - usePositiveMomentumAndRCForManualBoluses: Bool = true + usePositiveMomentumAndRCForManualBoluses: Bool = true, + dosingStrategySelectionEnabled: Bool = true, ) { self.lastLoopCompleted = lastLoopCompleted @@ -187,6 +190,7 @@ final class LoopDataManager: ObservableObject { self.usePositiveMomentumAndRCForManualBoluses = usePositiveMomentumAndRCForManualBoluses self.automationHistory = UserDefaults.standard.automationHistory self.publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate + self.dosingStrategySelectionEnabled = dosingStrategySelectionEnabled self.publishedMostRecentPumpDataDate = mostRecentPumpDataDate // Required for device settings in stored dosing decisions @@ -491,6 +495,7 @@ final class LoopDataManager: ObservableObject { 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)) @@ -551,7 +556,11 @@ final class LoopDataManager: ObservableObject { // Trim future basal input.doses = input.doses.trimmed(to: loopBaseTime) - let dosingStrategy = settingsProvider.settings.automaticDosingStrategy + var dosingStrategy: AutomaticDosingStrategy = .automaticBolus + + if dosingStrategySelectionEnabled { + dosingStrategy = settingsProvider.settings.automaticDosingStrategy + } input.recommendationType = dosingStrategy.recommendationType guard let latestGlucose = input.glucoseHistory.last else { @@ -782,12 +791,16 @@ final class LoopDataManager: ObservableObject { // MARK: - Background task management extension LoopDataManager: PersistenceControllerDelegate { - func persistenceControllerWillSave(_ controller: PersistenceController) { - startBackgroundTask() + nonisolated func persistenceControllerWillSave(_ controller: PersistenceController) { + Task { + await startBackgroundTask() + } } - func persistenceControllerDidSave(_ controller: PersistenceController, error: PersistenceController.PersistenceControllerError?) { - endBackgroundTask() + nonisolated func persistenceControllerDidSave(_ controller: PersistenceController, error: PersistenceController.PersistenceControllerError?) { + Task { + await endBackgroundTask() + } } } diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 6b7a99b15e..e61cc1390b 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -35,14 +35,14 @@ class TemporaryPresetsManager { @ObservationIgnored private var overrideIntentObserver: NSKeyValueObservation? = nil - init(settingsProvider: SettingsProvider, alertIssuer: AlertIssuer? = nil) { + init(settingsProvider: SettingsProvider, alertIssuer: AlertIssuer? = nil, presetHistory: TemporaryScheduleOverrideHistory? = nil) { self.settingsProvider = settingsProvider self.alertIssuer = alertIssuer - self.presetHistory = TemporaryScheduleOverrideHistoryContainer.shared.fetch() + self.presetHistory = presetHistory ?? TemporaryScheduleOverrideHistoryContainer.shared.fetch() TemporaryScheduleOverrideHistory.relevantTimeWindow = Bundle.main.localCacheDuration - _scheduleOverride = presetHistory.activeOverride(at: Date()) + _scheduleOverride = self.presetHistory.activeOverride(at: Date()) if scheduleOverride?.context == .preMeal { preMealOverride = scheduleOverride diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 9338e44660..4be60fa3d4 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -198,6 +198,13 @@ final class WatchDataManager: NSObject { return } + for xfer in session.outstandingUserInfoTransfers { + if (xfer.userInfo["name"] as? String) == SupportedBolusVolumesUserInfo.name { + // We have an outstanding SupportedBolusVolumesUserInfo xfer in progress + return + } + } + lastSentBolusVolumes = volumes log.default("Transferring supported bolus volumes") diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 6ec3849cf6..c61405e25b 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -304,11 +304,13 @@ final class StatusTableViewController: LoopChartsTableViewController { didSet { if oldValue != bolusState { switch bolusState { - case .inProgress(_): + case .inProgress(let dose): guard case .inProgress = oldValue else { guard case .canceling = oldValue else { // Bolus starting - bolusProgressReporter = deviceManager.pumpManager?.createBolusProgressReporter(reportingOn: DispatchQueue.main) + if dose.automatic != true { + bolusProgressReporter = deviceManager.pumpManager?.createBolusProgressReporter(reportingOn: DispatchQueue.main) + } break } break @@ -1554,7 +1556,8 @@ final class StatusTableViewController: LoopChartsTableViewController { .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) .environment(\.loopStatusColorPalette, .loopStatus) .environment(\.settingsManager, settingsManager) - .environment(\.temporaryPresetsManager, temporaryPresetsManager), + .environment(\.temporaryPresetsManager, temporaryPresetsManager) + .environment(\.dosingStrategySelectionEnabled, FeatureFlags.dosingStrategySelectionEnabled), isModalInPresentation: false) present(hostingController, animated: true) diff --git a/Loop/View Models/ManualEntryDoseViewModel.swift b/Loop/View Models/ManualEntryDoseViewModel.swift index 9ff4010db6..626120bff0 100644 --- a/Loop/View Models/ManualEntryDoseViewModel.swift +++ b/Loop/View Models/ManualEntryDoseViewModel.swift @@ -21,6 +21,7 @@ enum ManualEntryDoseViewModelError: Error { case notAuthenticated } +@MainActor protocol ManualDoseViewModelDelegate: AnyObject { var algorithmDisplayState: AlgorithmDisplayState { get async } var pumpInsulinType: InsulinType? { get } diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift index 3f62412b11..a5719dd38f 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift @@ -95,7 +95,16 @@ struct InsulinDeliveryLogEventRow: View { .frame(width: 24, height: 24) } } - + + func bolusTitle(deliveryAmount: LoopQuantity, programmedAmount: LoopQuantity) -> some View { + if deliveryAmount != programmedAmount { + Text("Bolus: ") + Text(bolusFormatter.string(from: deliveryAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(deliveryAmount.unit.localizedUnitString(in: .short) ?? "U") + Text(" of ") + Text(bolusFormatter.string(from: programmedAmount, includeUnit: false) ?? "Unknown") + Text(" ") + Text(programmedAmount.unit.localizedUnitString(in: .short) ?? "U") + } else { + Text("Bolus: ") + Text(bolusFormatter.string(from: deliveryAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(deliveryAmount.unit.localizedUnitString(in: .short) ?? "U") + } + } + + @ViewBuilder var title: some View { switch event.type { @@ -213,11 +222,7 @@ struct InsulinDeliveryLogEventRow: View { case .automated: HStack(spacing: 0) { VStack(alignment: .leading, spacing: 0) { - Text("Bolus: ") + Text(bolusFormatter.string(from: deliveryAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(deliveryAmount.unit.localizedUnitString(in: .short) ?? "U") - - Text("Automated") - .font(.footnote) - .foregroundStyle(.secondary) + bolusTitle(deliveryAmount: deliveryAmount, programmedAmount: programmedAmount) } Spacer() @@ -229,12 +234,8 @@ struct InsulinDeliveryLogEventRow: View { case .meal(let recommendedAmount as LoopQuantity?, _, _), .correction(let recommendedAmount): HStack(spacing: 0) { VStack(alignment: .leading, spacing: 0) { - if deliveryAmount != programmedAmount { - Text("Bolus: ") + Text(bolusFormatter.string(from: deliveryAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(deliveryAmount.unit.localizedUnitString(in: .short) ?? "U") + Text(" of ") + Text(bolusFormatter.string(from: programmedAmount, includeUnit: false) ?? "Unknown") + Text(" ") + Text(programmedAmount.unit.localizedUnitString(in: .short) ?? "U") - } else { - Text("Bolus: ") + Text(bolusFormatter.string(from: deliveryAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(deliveryAmount.unit.localizedUnitString(in: .short) ?? "U") - } - + bolusTitle(deliveryAmount: deliveryAmount, programmedAmount: programmedAmount) + if let recommendedAmount { Group { Text("Recommended: ") + Text(bolusFormatter.string(from: recommendedAmount, includeUnit: false) ?? "Unknown").fontWeight(.medium) + Text(" ") + Text(recommendedAmount.unit.localizedUnitString(in: .short) ?? "U") diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 7d4932017b..499de318a1 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -76,7 +76,7 @@ struct SettingsView: View { if versionUpdateViewModel.softwareUpdateAvailable { softwareUpdateSection } - if FeatureFlags.automaticBolusEnabled { + if FeatureFlags.dosingStrategySelectionEnabled { dosingStrategySection } alertManagementSection diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index f9ba22695d..6d2945914f 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -125,7 +125,7 @@ class LoopDataManagerTests: XCTestCase { dosingDecisionStore = MockDosingDecisionStore() automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) - let temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsProvider) + let temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsProvider, presetHistory: TemporaryScheduleOverrideHistory()) loopDataManager = LoopDataManager( lastLoopCompleted: now, From e997a0c99cc844bf5ac1c3e2e84f9aff6e318373 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 20 Aug 2025 12:00:57 -0500 Subject: [PATCH 276/421] LOOP-5235 Fixes/updates for scheduled presets (#816) * Fix bugs in creating new preset with schedule * Update to new wording on preset reminder --- Loop/Managers/TemporaryPresetsManager.swift | 8 ++----- .../CreatePresetNameAndScheduledEdit.swift | 6 ++++- Loop/Views/Presets/CreatePresetView.swift | 7 +++--- Loop/Views/Presets/NewCustomPreset.swift | 24 ++++--------------- 4 files changed, 16 insertions(+), 29 deletions(-) diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index e61cc1390b..3dec2facd5 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -454,7 +454,6 @@ class TemporaryPresetsManager { let nextScheduledPresetReminderIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: preset.id.uuidString) await alertIssuer?.retractAlert(identifier: nextScheduledPresetReminderIdentifier) - let nextScheduledTime = preset.nextScheduledStartAfter(now)! let formatter = DateFormatter() @@ -463,11 +462,8 @@ class TemporaryPresetsManager { let title = NSLocalizedString("Start Scheduled Preset?", comment: "Scheduled preset reminder title") let body = String( - format: NSLocalizedString("Your %1$@ preset is scheduled for today at %2$@. Would you like to start it now?\n\nThis will end any active preset.", comment: "Scheduled preset reminder alert body. (1: preset name) (2: time)"), - preset.name, - formatter.string( - from: nextScheduledTime - ) + format: NSLocalizedString("Would you like to start your %1$@ preset? This will end any active preset.", comment: "Scheduled preset reminder alert body. (1: preset name)"), + preset.name ) let actions = [ diff --git a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift index 34bac6d92d..bc2eac8cee 100644 --- a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift +++ b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift @@ -219,11 +219,12 @@ struct CreatePresetNameAndScheduledEdit: View { } } } - if preset.repeatOptions != nil { + if let options = preset.repeatOptions, options != .none { Text(preset.scheduleDescription()) .font(.footnote) .foregroundColor(.secondary) .padding(.horizontal, 10) + .padding(.top, 4) } } } actionArea: { @@ -237,6 +238,9 @@ struct CreatePresetNameAndScheduledEdit: View { if newValue == .weekly { assignRepeatDays() } + if newValue == .never { + preset.repeatOptions = nil + } }) .onChange(of: preset.startDate, { oldValue, newValue in if newValue != nil { diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift index 3e629f4b11..356819889d 100644 --- a/Loop/Views/Presets/CreatePresetView.swift +++ b/Loop/Views/Presets/CreatePresetView.swift @@ -117,14 +117,15 @@ struct CreatePresetView: View { onCancel: { dismiss() }, onComplete: { startPreset in dismiss() - if let temporaryScheduleOverride = preset.temporaryScheduleOverride { - if preset.savePreset, case .preset(let preset) = temporaryScheduleOverride.context { - settingsManager.createPreset(preset) + if let temporaryPreset = preset.temporaryPreset { + if preset.savePreset { + settingsManager.createPreset(temporaryPreset) Task { await temporaryPresetsManager.scheduleNextPresetReminder() } } if startPreset { + let temporaryScheduleOverride = temporaryPreset.createOverride(enactTrigger: .local) temporaryPresetsManager.scheduleOverride = temporaryScheduleOverride } } diff --git a/Loop/Views/Presets/NewCustomPreset.swift b/Loop/Views/Presets/NewCustomPreset.swift index f9073b400c..3f3f1fb389 100644 --- a/Loop/Views/Presets/NewCustomPreset.swift +++ b/Loop/Views/Presets/NewCustomPreset.swift @@ -100,7 +100,7 @@ extension NewCustomPreset { } extension NewCustomPreset { - var temporaryScheduleOverride: TemporaryScheduleOverride? { + var temporaryPreset: TemporaryPreset? { guard let duration else { return nil } @@ -111,26 +111,12 @@ extension NewCustomPreset { insulinNeedsScaleFactor: insulinMultiplier ) - let context: TemporaryScheduleOverride.Context - - if savePreset { - let preset = TemporaryPreset( - symbol: "", - name: name, - settings: settings, - duration: overrideDuration - ) - context = .preset(preset) - } else { - context = .custom - } - return TemporaryScheduleOverride( - context: context, + return TemporaryPreset( + symbol: "", + name: name, settings: settings, - startDate: startDate ?? Date(), duration: overrideDuration, - enactTrigger: .local, - syncIdentifier: UUID() + scheduleStartDate: startDate ) } } From 9966b65c41b3b324e3c1c21bf3a237d3fe79a544 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 20 Aug 2025 17:14:40 -0500 Subject: [PATCH 277/421] Add preset history to issue report (#817) --- Loop/Managers/LoopDataManager.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 1ccc6b8039..83587e8538 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1468,6 +1468,8 @@ extension LoopDataManager: DiagnosticReportGenerator { "## LoopDataManager", "settings: \(String(reflecting: settingsProvider.settings))", + "* presetHistory: \(temporaryPresetsManager.presetHistory.recentEvents.map(String.init(describing:)))", + "insulinCounteractionEffects: [", "* GlucoseEffectVelocity(start, end, mg/dL/min)", (algoOutput?.effects.insulinCounteraction ?? []).reduce(into: "", { (entries, entry) in From 352a049233da55627d9d93c22ce705e14819f4f3 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 22 Aug 2025 14:42:15 -0700 Subject: [PATCH 278/421] [LOOP-5405] Activity Presets Core (#818) --- Common/FeatureFlags.swift | 9 - Common/Models/LoopSettingsUserInfo.swift | 16 -- Loop.xcodeproj/project.pbxproj | 4 + .../workout-selected.imageset/Contents.json | 16 -- .../workout-selected.pdf | Bin 4299 -> 0 bytes .../workout-symbol.symbolset/Contents.json | 12 - .../workout-symbol.symbolset/heart.pulse.svg | 239 ------------------ Loop/Extensions/Character+IsEmoji.swift | 2 +- .../SettingsStore+SimulatedCoreData.swift | 1 - Loop/Extensions/UIAlertController.swift | 35 +-- Loop/Extensions/UIImage.swift | 4 - Loop/Managers/Alerts/AlertManager.swift | 59 ----- Loop/Managers/AnalyticsServicesManager.swift | 27 +- Loop/Managers/DeviceDataManager.swift | 2 - Loop/Managers/LoopAppManager.swift | 2 - Loop/Managers/SettingsManager.swift | 48 +--- Loop/Managers/TemporaryPresetsManager.swift | 65 ++--- Loop/Models/InsulinDeliveryLogEvent.swift | 2 +- Loop/Models/SelectablePreset.swift | 125 ++++----- .../StatusTableViewController.swift | 2 +- Loop/View Models/SettingsViewModel.swift | 5 - .../InsulinDeliveryLogEventRow.swift | 11 +- Loop/Views/Presets/ActivePresetBanner.swift | 77 +++++- .../Components/EditPresetDurationView.swift | 4 +- .../Views/Presets/Components/PresetCard.swift | 38 ++- .../Presets/Components/PresetDetentView.swift | 3 +- .../Presets/Components/PresetSymbolView.swift | 45 ++++ Loop/Views/Presets/EditPresetView.swift | 59 ++++- Loop/Views/Presets/NewCustomPreset.swift | 22 +- Loop/Views/Presets/PresetsHistoryView.swift | 12 +- Loop/Views/Presets/PresetsView.swift | 10 +- .../CreatingYourOwnPresetsContentView.swift | 1 - Loop/Views/SettingsView.swift | 1 - LoopCore/LoopSettings.swift | 25 +- .../Managers/Alerts/AlertManagerTests.swift | 16 -- .../Controllers/ActionHUDController.swift | 44 +--- .../OverrideSelectionController.swift | 7 +- .../Views/OnOffSelectionView.swift | 6 - 38 files changed, 358 insertions(+), 698 deletions(-) delete mode 100644 Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/Contents.json delete mode 100644 Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/workout-selected.pdf delete mode 100644 Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/Contents.json delete mode 100644 Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/heart.pulse.svg create mode 100644 Loop/Views/Presets/Components/PresetSymbolView.swift diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 4664b6b0b5..dee085d09c 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -29,7 +29,6 @@ struct FeatureFlagConfiguration: Decodable { let remoteCommandsEnabled: Bool let predictedGlucoseChartClampEnabled: Bool let scenariosEnabled: Bool - let sensitivityOverridesEnabled: Bool let showEventualBloodGlucoseOnWatchEnabled: Bool let simulatedCoreDataEnabled: Bool let siriEnabled: Bool @@ -66,13 +65,6 @@ struct FeatureFlagConfiguration: Decodable { #else self.entryDeletionEnabled = true #endif - - // Swift compiler config is inverse, since the default state is enabled. - #if FEATURE_OVERRIDES_DISABLED - self.sensitivityOverridesEnabled = false - #else - self.sensitivityOverridesEnabled = true - #endif // Swift compiler config is inverse, since the default state is enabled. #if FIASP_INSULIN_MODEL_DISABLED @@ -253,7 +245,6 @@ extension FeatureFlagConfiguration : CustomDebugStringConvertible { "* predictedGlucoseChartClampEnabled: \(predictedGlucoseChartClampEnabled)", "* remoteCommandsEnabled: \(remoteCommandsEnabled)", "* scenariosEnabled: \(scenariosEnabled)", - "* sensitivityOverridesEnabled: \(sensitivityOverridesEnabled)", "* showEventualBloodGlucoseOnWatchEnabled: \(showEventualBloodGlucoseOnWatchEnabled)", "* simulatedCoreDataEnabled: \(simulatedCoreDataEnabled)", "* siriEnabled: \(siriEnabled)", diff --git a/Common/Models/LoopSettingsUserInfo.swift b/Common/Models/LoopSettingsUserInfo.swift index f788f50993..1f6e486711 100644 --- a/Common/Models/LoopSettingsUserInfo.swift +++ b/Common/Models/LoopSettingsUserInfo.swift @@ -51,22 +51,6 @@ struct LoopSettingsUserInfo: Equatable { 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: TemporaryPresetSettings(targetRange: legacyWorkoutTargetRange), - startDate: date, - duration: duration.isInfinite ? .indefinite : .finite(duration), - enactTrigger: .local, - syncIdentifier: UUID() - ) - } - } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 88f635887e..218d61e6d8 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -263,6 +263,7 @@ 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A62D09053A00CB271F /* StatusTableView.swift */; }; 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A82D09800700CB271F /* PresetDetentView.swift */; }; 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; + 84DEF35D2E566757006126F9 /* PresetSymbolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */; }; 84E8BBAE2CC9791E0078E6CF /* PresetsTrainingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */; }; 84E8BBB12CC979820078E6CF /* PresetsTrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */; }; 84E8BBB32CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB22CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift */; }; @@ -1174,6 +1175,7 @@ 84D1F1A62D09053A00CB271F /* StatusTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableView.swift; sourceTree = ""; }; 84D1F1A82D09800700CB271F /* PresetDetentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDetentView.swift; sourceTree = ""; }; 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.swift; sourceTree = ""; }; + 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetSymbolView.swift; sourceTree = ""; }; 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingViewModel.swift; sourceTree = ""; }; 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingView.swift; sourceTree = ""; }; 84E8BBB22CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatingYourOwnPresetsContentView.swift; sourceTree = ""; }; @@ -2623,6 +2625,7 @@ 84D1F1A82D09800700CB271F /* PresetDetentView.swift */, 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */, 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */, + 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */, ); path = Components; sourceTree = ""; @@ -3781,6 +3784,7 @@ A9CBE45C248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift in Sources */, 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */, + 84DEF35D2E566757006126F9 /* PresetSymbolView.swift in Sources */, C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */, 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */, 439A7944211FE22F0041B75F /* NSUserActivity.swift in Sources */, diff --git a/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/Contents.json deleted file mode 100644 index 07a9fb7036..0000000000 --- a/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/Contents.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "workout-selected.pdf" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - }, - "properties" : { - "template-rendering-intent" : "template", - "preserves-vector-representation" : true - } -} \ No newline at end of file diff --git a/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/workout-selected.pdf b/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/workout-selected.pdf deleted file mode 100644 index a340acb01d3af85f6cbf105d87ccb549f247c8fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4299 zcmai&2{=^k`^PO)7!qZVI>}mM7Go*XShCEF8M~1^vS!JaJxfU_`&LvUWG7xj zwq$E!NC+kSy#6yS|F?ec@49~HnrqH8-*cb)e$I2g&vkw75jE1%ISrMDgGF1(U&spu zUmx_fwu5B>2tdF%f|Zm283UZFou@s3ph_12865{_Pna@g*fgt$oZOMt>99PirFtX zha*+QsvNxEU%PJ40(U?D7kVXqte!!T>tz~YW4uQ%pY{epHUP;-K0YRjb z^f~0zeZi-FQk5A>Lg~%A;u)IFcMwU^B_X&{Cc@#TZ`a#H52SFaoXYc04EJ{w;!9=C zZR3bxKEzLET|BBA>V`T#BxEvv(r&@5lPx5Alkd!t6V=)Yf^wq|l~drB^kWIimhuT7 z7JY@1y8R67Wke5?7*6;ih_iwTq`-se2M#x_p2&DWaRZVaZ|%I!TRfkx6b5U(d2}#n zC4w;miKS@0!(hwYp&U#^c|i-B-u%aTnC zcu->`u8ZYd<1YPs<-SK?oU83mYfwitN6~Eb{R~r}U8D_RznSggbvGO!V}!Q*{_T!) z^#tHQjCTR&LGW_N;yeJkAA%;q)suSN1E86e>dbRETL-iT!56TAPz5*yfGJvnsn+~i zg?863OB=ZpuqHT9z=B#zO9!w7WHbrR1a}iRG!_TYJg?~i1?0b{RZ*eY`)3Y?-*Wui z7K-1aWX@BkDWgu?8m`oR0mx|KydAK(3rLOsy<@WO_*^s@<%uNL%h&fE5(Y%q1pHWl zH)nOZLB>(2U?YZzFfnhfeL7s6l9z2SzM@A)3S+goSZ{`6Mp(XZ7_~PUYiW}wVx3r) z%Mf2DGbtTrQ=@awM@H?I14e4;jCMmo&7P+8!Lm7~T%Q-3t{ytv-MYcR$3f3-O8<(4 z$AgfYS`34`^~xC;g}Uuk>z}2AZR^IQ8jZv}hWCOJh2=Fx_S1p2hvbw3#dygKDJE5I z{IEUH>$Ox#SOgTtlf#l*Dm>j<;k;fjd#bK0Jxy#TN5HI?|E28x*gB)hOi@x^EGXw{ zy6Ahmyc9=!fRcw)XUp@A|*_88QsZ2c&-MWbRU7@Om)Jt^L zF8kHUq*B9WfbPpay6VY$UynvOTP3xAU=wGJSzL7XpwD)aKJ`(4Cp(nzx-mj=BcuEC z&gHZDys9B5P~32wHaWi_Utg=IhmAdrXiM4P*qeT(=TxIYEVp_1Z6tS6O1CRs(uUs3lA>pHxAfjO9o^^#L;U(c=R@V(_Fn~E zd3C^zl`R;l299XbhtN-kI53ByhDU|{c{4BR$O@fQxOHdd}lciNX&VinKEm z*dHEw5vQAYZH94wWDYu=kEfk~I?^nbgt?3!#^^zY(y{564R~ke@Eq`vtKi1wTp1@)batb|o{0<=EEOL=UfO(FY62{pq zYaCK&=qVh*alQ2_M)L9Px9TrZf@0^Bxlj=zyca|yGpa)_q8x=S#gxVBk0*DXu|>M` znWnUGvd}1BI@Grp4*CS>}fIS^;_i zGRS0j+Sjz{uCnBkM5ok+G=EXUl#w)_G;8T>aqHtjdVlBd)-V`1sWq8|&kq?Esm)iu5q)%AKx5$1^*1%u=c>=V!@YwD zA>|+L3?$FDtsVPVzs7tf@-%FezZP7}xE zDjcl#UP`8Jope2UBULyRpGuz^Uu+Asa7iFdwjIHr>6+P%qc5hDmdM*@^GR=xeQxaWonmqzN+`IWsf?mJs1K!!hsNZcW%!$rvMBhi}k+3I<6HEF}=9%Z6 zZCAiPyB58wGHoGN8I^@fLdA8RGkH5WdC{hpSWRSenza{`EkV?Eb#M6{qEK&R^=Gem@J+N9&4#3DU;I+Qpe0B&GwnPRDY`6Zh6<5>12rw=!kHf##Z&_ zPs~0oD>^;p-MAERHq)|lf~W3$9dVs~U3f=q2g;-z=^I(kzVX)6TcR#3V2E?!BdMl$ zHFlaw++Y0h0IFar%dA(j_dstRD;w_$@8D@Z_x@|78bS9&XSl*<%~*{ zN}S5!2JeQF0GUlRg@NL|J@Iv9Wo~<9#}K3dDrNZyehAV5U1m~Yg|Qsk=LKqMqHn4T zJtp(UTIA{=4}-?8rgyj!3xds(d)tFgT1vK5Z1H-{CEq|_AJA!xww7jVoo;;Rn@(g* z9FIPak4_h|O?wD=PTOBQOTq)r*`R#Q`h^b}G?H8wxsx>4erhgm z)p8TJn+Qq^dlJ^KmDXw*g+-O4xKYy#hU!n>dp)uK5;9Yil8}-o2`}m|`pY4#db4^! zLlhlddCC&5EM70uBAW-%tbOe;^!d`^T($m00|^f^d|mqOzYV_W#){k(y=lf;<@jXM z4vEWtZQR2-yEc0S$V=Qd|<=cNgJRhi>h zny^*>H%#}3=nsaX<4FrsW&u~WCVJMxmg*m^Kictm@6GHpJ~3bW)ieWRW;NPZ{@${T z-18;(%k#P`iw&327T0VJI>g(?SHlXQ)az9^B8Lh-J^U0R5T7|)|H=RPQr5eUv603p zm!JXqIL0<6m7`pOFM~QajGW5UUE0v&q|z4RLhh&9M!O*EAgv|c)kGpP9QmlDIo@mN z?BIxep?r#5b6isUUVZ8rIpx~D_Win$O@=milD>| zAFaE8$9gf)VgBi|OZ~iC73I!!+3kjX0dw1yJ2I2&wE^VYds#;qWA<-^P z516F%GZh`QQtk0mpx9Hei_^K{V8fI5i&e`gLz^S`qWZPP2O~Dbs#|vP5jC1Jd#PI$ zhAsPuMZ{A&)jFS$Wk_P|R_uh|F`F$zn^WS_LpP_`s~!(VY*sZocb3s zRP`nc8x*&P*InjpKh6yJI4tKEL{FA9sIG@la(1w59f|XchMR_|*>S2qH&}MR_=>sU zXQwv8zV7^+s%aGZ9i`=ghmm)uJK$_nfpgf?)gm?|)|M>yE>NApjH( zhW!5l$RQAL1b_#AYH$Szb=heL;QC90$jMUI`A-dwP@q2dpBe-LqdwMuXmT)Wck>^b zyezee`cs1=WU2klpBh8~`d{k8{uKw8llxa)xIAq+{>%rLm;0@*r#srg8R!1}9|98x zKkE8X8=wmW0@WWh4^S5p<%%az&GKWcMYW3(28Mt`5O}<;BK2)4V&(BroPsSDp@6|D i;uYmF5EbzM4*6j>4^OH!zP}K-f&vUIDynU$1O7i~qfxE^ diff --git a/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/Contents.json b/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/Contents.json deleted file mode 100644 index fea0fb11b6..0000000000 --- a/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "symbols" : [ - { - "filename" : "heart.pulse.svg", - "idiom" : "universal" - } - ] -} diff --git a/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/heart.pulse.svg b/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/heart.pulse.svg deleted file mode 100644 index 2439f1cc36..0000000000 --- a/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/heart.pulse.svg +++ /dev/null @@ -1,239 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Loop/Extensions/Character+IsEmoji.swift b/Loop/Extensions/Character+IsEmoji.swift index fe19295350..888c583ef3 100644 --- a/Loop/Extensions/Character+IsEmoji.swift +++ b/Loop/Extensions/Character+IsEmoji.swift @@ -10,6 +10,6 @@ import Foundation extension Character { public var isEmoji: Bool { - unicodeScalars.contains(where: { $0.properties.isEmoji }) + unicodeScalars.contains(where: { $0.properties.isEmojiPresentation }) } } diff --git a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift index 403c5cf5cc..0d9f1f1d66 100644 --- a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift +++ b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift @@ -140,7 +140,6 @@ fileprivate extension StoredSettings { dosingEnabled: true, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, preMealTargetRange: DoubleRange(minValue: 80.0, maxValue: 90.0).quantityRange(for: .milligramsPerDeciliter), - workoutTargetRange: DoubleRange(minValue: 150.0, maxValue: 160.0).quantityRange(for: .milligramsPerDeciliter), overridePresets: [], maximumBasalRatePerHour: 3.5, maximumBolus: 10.0, diff --git a/Loop/Extensions/UIAlertController.swift b/Loop/Extensions/UIAlertController.swift index 3b83aa11d8..e483d7d2fd 100644 --- a/Loop/Extensions/UIAlertController.swift +++ b/Loop/Extensions/UIAlertController.swift @@ -11,40 +11,7 @@ import LoopKit import LoopKitUI -extension UIAlertController { - /** - Initializes an ActionSheet-styled controller for selecting a workout duration - - - parameter handler: A closure to execute when the sheet is dismissed after selection. The closure has a single argument: - - duration: The duration for which the workout is to be enabled - */ - internal convenience init(workoutDurationSelectionHandler handler: @escaping (_ duration: TimeInterval) -> Void) { - self.init( - title: NSLocalizedString("Use Workout Preset", comment: "The title of the alert controller used to select a duration for workout targets"), - message: nil, - preferredStyle: .actionSheet - ) - - let formatter = DateComponentsFormatter() - formatter.allowsFractionalUnits = false - formatter.unitsStyle = .full - - for interval in [1, 2].map({ TimeInterval(hours: $0) }) { - let duration = NSLocalizedString("For %1$@", comment: "The format string used to describe a finite workout targets duration") - - addAction(UIAlertAction(title: String(format: duration, formatter.string(from: interval)!), style: .default) { _ in - handler(interval) - }) - } - - let distantFuture = NSLocalizedString("Until I turn off", comment: "The title of a target alert action specifying workout targets duration until it is turned off by the user") - addAction(UIAlertAction(title: distantFuture, style: .default) { _ in - handler(.infinity) - }) - - addCancelAction() - } - +extension UIAlertController { /** Initializes an ActionSheet-styled controller for selecting a pre-meal preset duration diff --git a/Loop/Extensions/UIImage.swift b/Loop/Extensions/UIImage.swift index 908f0c965a..c7a8f737f7 100644 --- a/Loop/Extensions/UIImage.swift +++ b/Loop/Extensions/UIImage.swift @@ -34,10 +34,6 @@ extension UIImage { static func preMealImage(selected: Bool) -> UIImage? { return UIImage(named: selected ? "Pre-Meal Selected" : "Pre-Meal") } - - static func workoutImage(selected: Bool) -> UIImage? { - return UIImage(named: selected ? "workout-selected" : "workout") - } } private class FrameworkBundle { diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 970bfa159b..b9a0fd7870 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -290,35 +290,6 @@ public final class AlertManager { ExtensionDataManager.lastLoopCompleted } - // MARK: - Workout reminder - private func scheduleWorkoutOverrideReminder() { - Task { - await issueAlert(workoutOverrideReminderAlert) - } - } - - private func retractWorkoutOverrideReminder() { - Task { - await retractAlert(identifier: AlertManager.workoutOverrideReminderAlertIdentifier) - } - } - - static var workoutOverrideReminderAlertIdentifier: Alert.Identifier { - return Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: "WorkoutOverrideReminder") - } - - private var workoutOverrideReminderAlert: Alert { - let title = NSLocalizedString("Workout Temp Adjust Still On", comment: "Workout override still on reminder alert title") - let body = NSLocalizedString("Workout Temp Adjust has been turned on for more than 24 hours. Make sure you still want it enabled, or turn it off in the app.", comment: "Workout override still on reminder alert body.") - let content = Alert.Content(title: title, - body: body, - acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal")) - return Alert(identifier: AlertManager.workoutOverrideReminderAlertIdentifier, - foregroundContent: content, - backgroundContent: content, - trigger: .delayed(interval: .hours(24))) - } - // MARK: - Rescheduling Muted Alerts func rescheduleMutedAlerts(_ newValue: AlertMuter.Configuration) { @@ -694,36 +665,6 @@ extension AlertManager: BluetoothObserver { } } - -// MARK: - PresetActivationObserver -extension AlertManager: PresetActivationObserver { - nonisolated - func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) { - switch context { - case .legacyWorkout: - if duration == .indefinite { - Task { - await scheduleWorkoutOverrideReminder() - } - } - default: - break - } - } - - nonisolated - func presetDeactivated(context: TemporaryScheduleOverride.Context) { - switch context { - case .legacyWorkout: - Task { - await retractWorkoutOverrideReminder() - } - default: - break - } - } -} - // MARK: - Issue/Retract Alert Permissions Warning extension AlertManager: AlertPermissionsCheckerDelegate { func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool, permissions: NotificationCenterSettingsFlags) { diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 16e4eab670..08604ebf1d 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -234,25 +234,30 @@ final class AnalyticsServicesManager { extension AnalyticsServicesManager: PresetActivationObserver { func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) { switch context { - case .legacyWorkout: - didEnactOverride(name: "workout", symbol: "", duration: duration) case .preMeal: didEnactOverride(name: "preMeal", symbol: "", duration: duration) case .custom: didEnactOverride(name: "custom", symbol: "", duration: duration) case .preset(let preset): - didEnactOverride(name: preset.name, symbol: preset.symbol, duration: duration, insulinSensitivityMultiplier: preset.settings.effectiveInsulinNeedsScaleFactor, targetRange: preset.settings.targetRange) + didEnactOverride( + name: preset.name, + symbol: preset.symbol?.textualRepresentation ?? "", + duration: duration, + insulinSensitivityMultiplier: preset.settings.effectiveInsulinNeedsScaleFactor, + targetRange: preset.settings.targetRange + ) + case .activity(let activity): + didEnactOverride( + name: activity.activityType.name, + symbol: activity.preset.symbol?.textualRepresentation ?? "", + duration: activity.preset.duration, + insulinSensitivityMultiplier: activity.preset.settings.effectiveInsulinNeedsScaleFactor, + targetRange: activity.preset.settings.targetRange + ) } } - func presetDeactivated(context: TemporaryScheduleOverride.Context) { - switch context { - case .legacyWorkout: - break - default: - break - } - } + func presetDeactivated(context: TemporaryScheduleOverride.Context) {} } extension AutomaticDosingStrategy { diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index ead6c9af61..3f1eb0b6dd 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1295,8 +1295,6 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal - settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout - settings.legacyWorkoutDuration = therapySettings.correctionRangeOverrides?.workoutDuration settings.suspendThreshold = therapySettings.suspendThreshold settings.basalRateSchedule = therapySettings.basalRateSchedule settings.maximumBasalRatePerHour = therapySettings.maximumBasalRatePerHour diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 92c93c829a..1bbd474351 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -248,7 +248,6 @@ class LoopAppManager: NSObject { temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsManager, alertIssuer: alertManager) temporaryPresetsManager.presetHistory.delegate = self - temporaryPresetsManager.addTemporaryPresetObserver(alertManager) temporaryPresetsManager.addTemporaryPresetObserver(analyticsServicesManager) await temporaryPresetsManager.scheduleNextPresetReminder() @@ -578,7 +577,6 @@ class LoopAppManager: NSObject { servicesViewModel: servicesViewModel, criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, - sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, initialDosingEnabled: self.settingsManager.settings.dosingEnabled, automaticDosingStatus: self.automaticDosingStatus, automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index ddee1142e5..55b9407454 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -110,7 +110,6 @@ class SettingsManager { basalRateSchedule: settings.basalRateSchedule, carbRatioSchedule: settings.carbRatioSchedule, preMealTargetRange: settings.preMealTargetRange, - legacyWorkoutTargetRange: settings.workoutTargetRange, overridePresets: settings.overridePresets, maximumBasalRatePerHour: settings.maximumBasalRatePerHour, maximumBolus: settings.maximumBolus, @@ -129,8 +128,6 @@ class SettingsManager { dosingEnabled: newLoopSettings.dosingEnabled, glucoseTargetRangeSchedule: newLoopSettings.glucoseTargetRangeSchedule, preMealTargetRange: newLoopSettings.preMealTargetRange, - workoutTargetRange: newLoopSettings.legacyWorkoutTargetRange, - workoutDefaultDuration: newLoopSettings.legacyWorkoutDuration, overridePresets: newLoopSettings.overridePresets, maximumBasalRatePerHour: newLoopSettings.maximumBasalRatePerHour, maximumBolus: newLoopSettings.maximumBolus, @@ -306,9 +303,7 @@ extension SettingsManager { return TherapySettings( glucoseTargetRangeSchedule: settings.glucoseTargetRangeSchedule, correctionRangeOverrides: CorrectionRangeOverrides( - preMeal: settings.preMealTargetRange, - workout: settings.workoutTargetRange, - workoutDuration: settings.workoutDefaultDuration + preMeal: settings.preMealTargetRange ), overridePresets: settings.overridePresets, maximumBasalRatePerHour: settings.maximumBasalRatePerHour, @@ -329,7 +324,6 @@ extension SettingsManager { 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 @@ -342,8 +336,6 @@ extension SettingsManager { switch preset { case .preMeal: return preMealGuardrail - case .legacyWorkout: - return legacyWorkoutPresetGuardrail default: return Guardrail.temporaryPresetCorrectionRange } @@ -361,42 +353,28 @@ extension SettingsManager { } } - public var legacyWorkoutPresetGuardrail: Guardrail { - if let scheduleRange = settings.glucoseTargetRangeSchedule?.scheduleRange() { - return Guardrail.correctionRangeOverride( - for: .workout, - correctionRangeScheduleRange: scheduleRange, - suspendThreshold: settings.suspendThreshold - ) - } else { - return Guardrail.correctionRange - } - } - func savePreset(_ preset: SelectablePreset) { switch(preset) { case .preMeal(let range): mutateLoopSettings { settings in settings.preMealTargetRange = range } - case .legacyWorkout(let range, let duration): - mutateLoopSettings { settings in - settings.legacyWorkoutTargetRange = range - switch duration { - case .indefinite: - settings.legacyWorkoutDuration = .indefinite - case .duration(let interval): - settings.legacyWorkoutDuration = .finite(interval) - case .untilCarbsEntered: - break - } - } case .custom(let preset): if let index = settings.overridePresets.firstIndex(where: { $0.id == preset.id }) { mutateLoopSettings { settings in settings.overridePresets[index] = preset } } + case .activity(let activity): + if let index = settings.overridePresets.firstIndex(where: { $0.id == activity.preset.id }) { + mutateLoopSettings { settings in + settings.overridePresets[index] = activity.preset + } + } else { + mutateLoopSettings { settings in + settings.overridePresets.append(activity.preset) + } + } } } @@ -407,8 +385,10 @@ extension SettingsManager { } func deletePreset(_ preset: SelectablePreset) { + guard preset.canBeDeleted else { return } + switch(preset) { - case .preMeal, .legacyWorkout: + case .preMeal, .activity: break // cannot delete these case .custom(let preset): mutateLoopSettings { settings in diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 3dec2facd5..26bbd36542 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -163,12 +163,12 @@ class TemporaryPresetsManager { switch override.context { case .preMeal: return .preMeal(range: range!) - case .legacyWorkout: - return .legacyWorkout(range: range!, duration: override.duration.presetDurationType) + case .activity(let activity): + return .activity(activity) case .custom: let preset = TemporaryPreset( - id: override.syncIdentifier, - symbol: "", + id: override.syncIdentifier.uuidString, + symbol: nil, name: "Single Use Preset", settings: override.settings, duration: override.duration @@ -192,16 +192,20 @@ class TemporaryPresetsManager { presets.append(.preMeal(range: preMealTargetRange)) } - if let legacyWorkoutTargetRange = settings.workoutTargetRange { - let duration = settings.workoutDefaultDuration ?? .indefinite - presets.append(.legacyWorkout( - range: legacyWorkoutTargetRange, - duration: duration.presetDurationType - )) + presets.append(contentsOf: settings.overridePresets.map { override in + if override.id.hasPrefix("activity-"), let activityPreset = ActivityPreset(preset: override) { + return .activity(activityPreset) + } else { + return .custom(override) + } + }) + + ActivityPreset.ActivityType.allCases.forEach { activityType in + if !settings.overridePresets.contains(where: { $0.id == activityType.id }) { + presets.append(.activity(ActivityPreset(activityType: activityType, preset: activityType.defaultPreset(duration: .finite(.minutes(90)))))) + } } - presets.append(contentsOf: settings.overridePresets.map { .custom($0)} ) - return presets } @@ -229,11 +233,6 @@ class TemporaryPresetsManager { } } - 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 { @@ -298,26 +297,6 @@ class TemporaryPresetsManager { ) } - public func enableLegacyWorkoutOverride(at date: Date = Date(), for duration: TemporaryScheduleOverride.Duration) { - scheduleOverride = legacyWorkoutOverride(beginningAt: date, for: duration) - preMealOverride = nil - } - - public func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TemporaryScheduleOverride.Duration) -> TemporaryScheduleOverride? { - guard let legacyWorkoutTargetRange = settingsProvider.settings.workoutTargetRange else { - return nil - } - - return TemporaryScheduleOverride( - context: .legacyWorkout, - settings: TemporaryPresetSettings(targetRange: legacyWorkoutTargetRange), - startDate: date, - duration: duration, - enactTrigger: .local, - syncIdentifier: UUID() - ) - } - func startPreset(withIdentifier identifier: String) { guard let preset = selectablePresets.first(where: { $0.id == identifier }) else { log.error("Unable to find preset with identifier ${public}@", identifier) @@ -331,10 +310,10 @@ class TemporaryPresetsManager { switch preset { case .custom(let temporaryScheduleOverridePreset): scheduleOverride = temporaryScheduleOverridePreset.createOverride(enactTrigger: .local) + case .activity(let activity): + scheduleOverride = activity.preset.createOverride(enactTrigger: .local) case .preMeal: enablePreMealOverride(for: .hours(1)) - case .legacyWorkout(_, let duration): - enableLegacyWorkoutOverride(for: duration.presetDuration) } } @@ -425,8 +404,8 @@ class TemporaryPresetsManager { var id: String switch enact.context { case .preMeal: id = "preMeal" - case .legacyWorkout: id = "legacyWorkout" - case .preset(let preset): id = preset.id.uuidString + case .activity(let activity): id = activity.id + case .preset(let preset): id = preset.id case .custom: continue } lastUsed![id] = max(lastUsed![id] ?? .distantPast, enact.startDate) @@ -451,7 +430,7 @@ class TemporaryPresetsManager { if let preset { - let nextScheduledPresetReminderIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: preset.id.uuidString) + let nextScheduledPresetReminderIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: preset.id) await alertIssuer?.retractAlert(identifier: nextScheduledPresetReminderIdentifier) let nextScheduledTime = preset.nextScheduledStartAfter(now)! @@ -483,7 +462,7 @@ class TemporaryPresetsManager { body: body, actions: actions) - let metadata: Alert.Metadata = ["presetId": Alert.MetadataValue(preset.id.uuidString)] + let metadata: Alert.Metadata = ["presetId": Alert.MetadataValue(preset.id)] let alert = Alert( identifier: nextScheduledPresetReminderIdentifier, diff --git a/Loop/Models/InsulinDeliveryLogEvent.swift b/Loop/Models/InsulinDeliveryLogEvent.swift index e5f2ce7cfd..80bb2a5213 100644 --- a/Loop/Models/InsulinDeliveryLogEvent.swift +++ b/Loop/Models/InsulinDeliveryLogEvent.swift @@ -59,7 +59,7 @@ struct InsulinDeliveryLogEvent: Hashable, Identifiable { case disabled } - case preset(PresetEventType, icon: PresetIcon, name: String) + case preset(PresetEventType, icon: PresetSymbol?, name: String) } let id: String diff --git a/Loop/Models/SelectablePreset.swift b/Loop/Models/SelectablePreset.swift index b489e4c551..aeee930a80 100644 --- a/Loop/Models/SelectablePreset.swift +++ b/Loop/Models/SelectablePreset.swift @@ -9,6 +9,7 @@ import LoopKit import SwiftUI import LoopAlgorithm +import LoopKitUI enum PresetDuration: Equatable { case untilCarbsEntered @@ -45,7 +46,7 @@ extension TemporaryScheduleOverride { var expectedEndTime: PresetExpectedEndTime? { switch context { case .preMeal: return .untilCarbsEntered - case .legacyWorkout, .custom, .preset: + case .activity, .custom, .preset: switch duration { case .indefinite: return .indefinite case .finite: return .scheduled(scheduledEndDate) @@ -56,18 +57,13 @@ extension TemporaryScheduleOverride { var presetId: String { switch context { case .preMeal: return "preMeal" - case .legacyWorkout: return "legacyWorkout" + case .activity: return preset.id case .custom: return self.syncIdentifier.uuidString - case .preset(let preset): return preset.id.uuidString + case .preset(let preset): return preset.id } } } -enum PresetIcon: Hashable { - case emoji(String) - case image(String, Color) -} - typealias RangeSafetyClassification = (lower: SafetyClassification, upper: SafetyClassification) extension PresetDuration: Hashable { @@ -88,16 +84,14 @@ enum SelectablePreset: Hashable, Identifiable { case custom(TemporaryPreset) case preMeal(range: ClosedRange) - case legacyWorkout(range: ClosedRange, duration: PresetDuration) + case activity(ActivityPreset) func hash(into hasher: inout Hasher) { switch self { case .custom(let preset): hasher.combine(preset) - case .legacyWorkout(let range, let duration): - hasher.combine("legacyWorkout") - hasher.combine(range) - hasher.combine(duration) + case .activity(let activity): + hasher.combine(activity) case .preMeal(let range): hasher.combine("preMeal") hasher.combine(range) @@ -108,8 +102,8 @@ enum SelectablePreset: Hashable, Identifiable { switch (lhs, rhs) { case (.custom(let lhsPreset), .custom(let rhsPreset)): return lhsPreset == rhsPreset - case (.legacyWorkout(let lhsRange, let lhsDuration), .legacyWorkout(let rhsRange, let rhsDuration)): - return lhsRange == rhsRange && lhsDuration == rhsDuration + case (.activity(let lhsActivity), .activity(let rhsActivity)): + return lhsActivity == rhsActivity case (.preMeal(let lhsRange), .preMeal(let rhsRange)): return lhsRange == rhsRange default: @@ -119,17 +113,17 @@ enum SelectablePreset: Hashable, Identifiable { var id: String { switch self { - case .custom(let preset): return preset.id.uuidString - case .legacyWorkout: return "legacyWorkout" + case .custom(let preset): return preset.id + case .activity(let activity): return "activity-\(activity.id)" case .preMeal: return "preMeal" } } - var icon: PresetIcon { + var icon: PresetSymbol? { switch self { - case .custom(let preset): return .emoji(preset.symbol) - case .preMeal: return .image("Pre-Meal", .carbTintColor) - case .legacyWorkout: return .image("workout", .glucoseTintColor) + case .custom(let preset): return preset.symbol + case .preMeal: return .image("Pre-Meal-symbol", tint: .preMeal) + case .activity(let activity): return activity.preset.symbol } } @@ -143,17 +137,31 @@ enum SelectablePreset: Hashable, Identifiable { case .finite(let duration): return .duration(duration) } + case .activity(let activity): + switch activity.preset.duration { + case .indefinite: + return .indefinite + case .finite(let duration): + return .duration(duration) + } case .preMeal: return .untilCarbsEntered - case .legacyWorkout(_, let duration): - return duration } } set { switch self { case .preMeal(let range): self = .preMeal(range: range) - case .legacyWorkout(let range, _): - self = .legacyWorkout(range: range, duration: newValue) + case .activity(var activity): + activity.preset.settings = TemporaryPresetSettings(targetRange: activity.preset.settings.targetRange, insulinNeedsScaleFactor: activity.preset.settings.insulinNeedsScaleFactor) + switch newValue { + case .indefinite: + activity.preset.duration = .indefinite + case .duration(let duration): + activity.preset.duration = .finite(duration) + default: + break + } + self = .activity(activity) case .custom(var preset): preset.settings = TemporaryPresetSettings(targetRange: preset.settings.targetRange, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) switch newValue { @@ -177,7 +185,7 @@ enum SelectablePreset: Hashable, Identifiable { switch self { case .custom(let preset): return preset.nextScheduledStartAfter(date) - case .preMeal, .legacyWorkout: + case .preMeal, .activity: return nil } } @@ -187,7 +195,7 @@ enum SelectablePreset: Hashable, Identifiable { switch self { case .custom(let preset): return preset.scheduleStartDate - case .preMeal, .legacyWorkout: + case .preMeal, .activity: return nil } } @@ -207,7 +215,7 @@ enum SelectablePreset: Hashable, Identifiable { switch self { case .custom(let preset): return preset.repeatOptions ?? .none - case .preMeal, .legacyWorkout: + case .preMeal, .activity: return .none } } @@ -228,7 +236,7 @@ enum SelectablePreset: Hashable, Identifiable { switch self { case .custom(let preset): return preset.name case .preMeal: return "Pre-Meal" - case .legacyWorkout: return "Workout" + case .activity(let activity): return activity.activityType.name } } set { @@ -244,7 +252,7 @@ enum SelectablePreset: Hashable, Identifiable { switch self { case .custom(let preset): return preset.settings.targetRange case .preMeal(let range): return range - case .legacyWorkout(let range, _): return range + case .activity(let activity): return activity.preset.settings.targetRange } } @@ -252,8 +260,9 @@ enum SelectablePreset: Hashable, Identifiable { switch self { case .preMeal: self = .preMeal(range: newValue!) - case .legacyWorkout(_, let duration): - self = .legacyWorkout(range: newValue!, duration: duration) + case .activity(var activity): + activity.preset.settings = TemporaryPresetSettings(targetRange: newValue, insulinNeedsScaleFactor: activity.preset.settings.insulinNeedsScaleFactor) + self = .activity(activity) case .custom(var preset): preset.settings = TemporaryPresetSettings(targetRange: newValue, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) self = .custom(preset) @@ -264,6 +273,8 @@ enum SelectablePreset: Hashable, Identifiable { var insulinSensitivityMultiplier: Double? { if case .custom(let preset) = self { return preset.settings.insulinSensitivityMultiplier + } else if case .activity(let activity) = self { + return activity.preset.settings.insulinSensitivityMultiplier } else { return nil } @@ -273,12 +284,17 @@ enum SelectablePreset: Hashable, Identifiable { get { if case .custom(let preset) = self { return 1.0 / (preset.settings.insulinSensitivityMultiplier ?? 1) + } else if case .activity(let activity) = self { + return 1.0 / (activity.preset.settings.insulinSensitivityMultiplier ?? 1) } else { return 1.0 } } set { - if case .custom(var preset) = self { + if case .activity(var activity) = self { + activity.preset.settings = TemporaryPresetSettings(targetRange: activity.preset.settings.targetRange, insulinNeedsScaleFactor: newValue) + self = .activity(activity) + } else if case .custom(var preset) = self { preset.settings = TemporaryPresetSettings(targetRange: preset.settings.targetRange, insulinNeedsScaleFactor: newValue) self = .custom(preset) } @@ -287,46 +303,46 @@ enum SelectablePreset: Hashable, Identifiable { var canAdjustSensitivity: Bool { switch self { - case .custom: + case .custom, .activity: return true - case .preMeal, .legacyWorkout: + case .preMeal: return false } } var canAdjustDuration: Bool { switch self { - case .custom, .legacyWorkout: - return true; + case .custom, .activity: + return true case .preMeal: - return false; + return false } } var canChangeName: Bool { switch self { case .custom: - return true; - case .preMeal, .legacyWorkout: - return false; + return true + case .preMeal, .activity: + return false } } var allowsScheduling: Bool { switch self { case .custom: - return true; - case .preMeal, .legacyWorkout: - return false; + return true + case .preMeal, .activity: + return false } } var canBeDeleted: Bool { switch self { case .custom: - return true; - case .preMeal, .legacyWorkout: - return false; + return true + case .preMeal, .activity: + return false } } @@ -343,22 +359,15 @@ enum SelectablePreset: Hashable, Identifiable { return .distantPast // TODO case .preMeal: return .distantPast.addingTimeInterval(1) - case .legacyWorkout: + case .activity: return .distantPast } } - func title(font: Font, iconSize: Double) -> some View { + func title(font: Font, iconSize: Double, colorPalette: LoopUIColorPalette) -> some View { HStack(spacing: 6) { - switch icon { - case .emoji(let emoji): - Text(emoji) - case .image(let name, let iconColor): - Image(name) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(iconColor) - .frame(width: UIFontMetrics.default.scaledValue(for: iconSize), height: UIFontMetrics.default.scaledValue(for: iconSize)) + if let icon, !icon.isEmpty { + PresetSymbolView(icon) } Text(name) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index c61405e25b..7854000d94 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1537,6 +1537,7 @@ final class StatusTableViewController: LoopChartsTableViewController { .environmentObject(deviceManager.displayGlucosePreference) .environment(\.appName, Bundle.main.bundleDisplayName) .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) + .environment(\.colorPalette, .default) .environment(\.loopStatusColorPalette, .loopStatus) .environment(\.temporaryPresetsManager, temporaryPresetsManager) .environment(\.settingsManager, settingsManager), @@ -1843,7 +1844,6 @@ final class StatusTableViewController: LoopChartsTableViewController { self.settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal - settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout settings.suspendThreshold = therapySettings.suspendThreshold settings.maximumBolus = therapySettings.maximumBolus settings.maximumBasalRatePerHour = therapySettings.maximumBasalRatePerHour diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 5e310f5150..44cf6654db 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -77,7 +77,6 @@ class SettingsViewModel { let servicesViewModel: ServicesViewModel let criticalEventLogExportViewModel: CriticalEventLogExportViewModel let therapySettings: () -> TherapySettings - let sensitivityOverridesEnabled: Bool var isOnboardingComplete: Bool let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? let presetHistory: TemporaryScheduleOverrideHistory @@ -107,7 +106,6 @@ class SettingsViewModel { var preMealGuardrail: Guardrail? - var legacyWorkoutPresetGuardrail: Guardrail? @ObservationIgnored weak var favoriteFoodInsightsDelegate: FavoriteFoodInsightsViewModelDelegate? @@ -142,7 +140,6 @@ class SettingsViewModel { servicesViewModel: ServicesViewModel, criticalEventLogExportViewModel: CriticalEventLogExportViewModel, therapySettings: @escaping () -> TherapySettings, - sensitivityOverridesEnabled: Bool, initialDosingEnabled: Bool, automaticDosingStatus: AutomaticDosingStatus, automaticDosingStrategy: AutomaticDosingStrategy, @@ -163,7 +160,6 @@ class SettingsViewModel { self.servicesViewModel = servicesViewModel self.criticalEventLogExportViewModel = criticalEventLogExportViewModel self.therapySettings = therapySettings - self.sensitivityOverridesEnabled = sensitivityOverridesEnabled self.closedLoopPreference = initialDosingEnabled self.automaticDosingStatus = automaticDosingStatus self.automaticDosingStrategy = automaticDosingStrategy @@ -232,7 +228,6 @@ extension SettingsViewModel { servicesViewModel: ServicesViewModel.preview, criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: MockCriticalEventLogExporterFactory()), therapySettings: { TherapySettings() }, - sensitivityOverridesEnabled: false, initialDosingEnabled: true, automaticDosingStatus: AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true), automaticDosingStrategy: .automaticBolus, diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift index a5719dd38f..4558ff2a1f 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift @@ -395,15 +395,8 @@ struct InsulinDeliveryLogEventRow: View { .foregroundStyle(.secondary) HStack(spacing: 6) { - switch icon { - case .emoji(let emoji): - Text(emoji) - case .image(let name, let iconColor): - Image(name) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(iconColor) - .frame(width: UIFontMetrics.default.scaledValue(for: 20), height: UIFontMetrics.default.scaledValue(for: 20)) + if let icon, !icon.isEmpty { + PresetSymbolView(icon) } Text(name) diff --git a/Loop/Views/Presets/ActivePresetBanner.swift b/Loop/Views/Presets/ActivePresetBanner.swift index cdb05cedf3..8fa9271f42 100644 --- a/Loop/Views/Presets/ActivePresetBanner.swift +++ b/Loop/Views/Presets/ActivePresetBanner.swift @@ -14,27 +14,80 @@ struct ActivePresetBanner: View { @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager let override: TemporaryScheduleOverride - - @ViewBuilder - var title: some View { + + var symbol: Text? { switch override.context { case .preMeal: - Group { - Text(Image("Pre-Meal-symbol")) + Text(" ") + Text(NSLocalizedString("Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)")) + return Text(Image("Pre-Meal-symbol")) + case .preset(let preset): + guard let symbol = preset.symbol else { + return nil + } + + switch symbol.symbolType { + case .emoji: + return Text(symbol.value) + case .systemImage: + return Text(Image(systemName: symbol.value)) + case .image: + return Text(Image(symbol.value)) } - .accessibilityIdentifier("text_PreMealPresetCellTitle") - case .legacyWorkout: - Group { - Text(Image("workout-symbol")) + Text(" ") + Text( NSLocalizedString("Workout Preset", comment: "Status row title for workout override enabled (leading space is to separate from symbol)")) + case .activity(let activity): + guard let symbol = activity.preset.symbol else { + return nil } - .accessibilityIdentifier("text_WorkoutPresetCellTitle") + + switch symbol.symbolType { + case .emoji: + return Text(symbol.value) + case .systemImage: + return Text(Image(systemName: symbol.value)) + case .image: + return Text(Image(symbol.value)) + } + case .custom: + return nil + } + } + + var titleText: Text { + switch override.context { + case .preMeal: + Text(NSLocalizedString("Pre-Meal", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)")) case .preset(let preset): - Text(String(format: NSLocalizedString("%@ %@", comment: "The format for an active custom preset. (1: preset symbol)(2: preset name)"), preset.symbol, preset.name)) + Text(String(format: NSLocalizedString("%@", comment: "The format for an active custom preset. (1: preset name)"), preset.name)) + case .activity(let activity): + Text(String(format: NSLocalizedString("%@", comment: "The format for an active activity preset. (1: preset name)"), activity.preset.name)) case .custom: Text(NSLocalizedString("Single Use Preset", comment: "The title of the cell indicating a generic custom preset is enabled")) } } + var accessibilityIdentifier: String { + switch override.context { + case .preMeal: + "text_PreMealPresetCellTitle" + case .preset: + "text_CustomPresetCellTitle" + case .activity: + "text_ActivityPresetCellTitle" + case .custom: + "text_OneTimePresetCellTitle" + } + } + + @ViewBuilder + var title: some View { + Group { + if let symbol { + symbol + Text(" ") + titleText + } else { + titleText + } + } + .accessibilityIdentifier(accessibilityIdentifier) + } + @ViewBuilder var subtitle: some View { if override.isActive() { @@ -48,7 +101,7 @@ struct ActivePresetBanner: View { Text(String(format: NSLocalizedString("on until %@", comment: "The format for the description of a finite custom preset end date"), endTimeText)) .accessibilityIdentifier("text_PresetActiveOn") case .indefinite: - Text(NSLocalizedString("on indefinitely", comment: "The format for the description of an indefinite custom preset end date")) + Text(NSLocalizedString("on until turned off", comment: "The format for the description of an indefinite custom preset end date")) .accessibilityIdentifier("text_PresetActiveOn") } } diff --git a/Loop/Views/Presets/Components/EditPresetDurationView.swift b/Loop/Views/Presets/Components/EditPresetDurationView.swift index 076435dd32..890d1be170 100644 --- a/Loop/Views/Presets/Components/EditPresetDurationView.swift +++ b/Loop/Views/Presets/Components/EditPresetDurationView.swift @@ -13,7 +13,7 @@ import SwiftUI struct EditPresetDurationView: View { @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @Environment(\.settingsManager) private var settingsManager - + @Environment(\.colorPalette) private var colorPalette @Environment(\.dismiss) private var dismiss @State var dateSelection: Date = Date() @@ -41,7 +41,7 @@ struct EditPresetDurationView: View { VStack(spacing: 0) { VStack(spacing: 24) { - preset?.title(font: .largeTitle, iconSize: 36) + preset?.title(font: .largeTitle, iconSize: 36, colorPalette: colorPalette) .fontWeight(.bold) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index 3fd13157fe..308274304f 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -13,12 +13,12 @@ import LoopKit struct PresetCard: View { - @Environment(\.guidanceColors) private var guidanceColors + @Environment(\.colorPalette) private var colorPalette @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference let presetId: String - let icon: PresetIcon + let icon: PresetSymbol? let presetName: String let duration: PresetDuration let insulinMultiplier: Double? @@ -26,23 +26,23 @@ struct PresetCard: View { let guardrail: Guardrail? let expectedEndTime: PresetExpectedEndTime? let isScheduled: Bool + let activityPresetIsModified: Bool? var presetTitle: some View { HStack(spacing: 6) { - switch icon { - case .emoji(let emoji): - Text(emoji) - case .image(let name, let iconColor): - Image(name) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(iconColor) - .frame(width: UIFontMetrics.default.scaledValue(for: 20), height: UIFontMetrics.default.scaledValue(for: 20)) + if let icon, !icon.isEmpty { + PresetSymbolView(icon) } Text(presetName) .fontWeight(.semibold) .accessibilityIdentifier("text_Preset\(presetName)") + + if activityPresetIsModified == false { + Text(Image(systemName: "checkmark.seal.fill")) + .font(.subheadline) + .foregroundStyle(Color.accentColor) + } } } @@ -142,7 +142,7 @@ extension PresetExpectedEndTime { case .untilCarbsEntered: return NSLocalizedString("on until carbs added", comment: "Presets card pre-meal expected end time accessibility label") case .indefinite: - return NSLocalizedString("on indefinitely", comment: "Presets card indefinite duration accessibility label") + return NSLocalizedString("on until turned off", comment: "Presets card indefinite duration accessibility label") case .scheduled(let date): let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute] @@ -182,3 +182,17 @@ extension PresetDuration { } } } + +extension Color { + init(presetSymbolTint: PresetSymbol.SymbolTint?, palette: LoopUIColorPalette) { + guard let presetSymbolTint else { + self = .primary + return + } + + switch presetSymbolTint { + case .preMeal: + self = palette.carbTintColor + } + } +} diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 12213e8465..e949fddc2c 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -18,6 +18,7 @@ struct PresetDetentView: View { } @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.colorPalette) private var colorPalette @Environment(\.settingsManager) private var settingsManager @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @Environment(\.dismiss) private var dismiss @@ -117,7 +118,7 @@ struct PresetDetentView: View { VStack(spacing: 24) { VStack(spacing: 16) { VStack(spacing: 4) { - preset.title(font: .title2, iconSize: 20) + preset.title(font: .title2, iconSize: 20, colorPalette: colorPalette) subtitle } diff --git a/Loop/Views/Presets/Components/PresetSymbolView.swift b/Loop/Views/Presets/Components/PresetSymbolView.swift new file mode 100644 index 0000000000..7a8b47c48e --- /dev/null +++ b/Loop/Views/Presets/Components/PresetSymbolView.swift @@ -0,0 +1,45 @@ +// +// PresetSymbolView.swift +// Loop +// +// Created by Cameron Ingham on 8/20/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKit +import SwiftUI + +struct PresetSymbolView: View { + + @Environment(\.colorPalette) private var colorPalette + + let symbol: PresetSymbol + let iconSize: Double + + init(_ symbol: PresetSymbol, iconSize: Double = 17) { + self.symbol = symbol + self.iconSize = iconSize + } + + var body: some View { + Group { + switch symbol.symbolType { + case .emoji: + Text(symbol.value) + .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize - 2))) + case .image: + Text(Image(symbol.value)) + .foregroundStyle(Color(presetSymbolTint: symbol.tint, palette: colorPalette)) + .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize))) + case .systemImage: + Text(Image(systemName: symbol.value)) + .foregroundStyle(Color(presetSymbolTint: symbol.tint, palette: colorPalette)) + .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize))) + } + } + } +} + +#Preview { + PresetSymbolView(.emoji("🍎"), iconSize: 22) +} diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 83914367b0..121603e1f9 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -14,7 +14,7 @@ import LoopAlgorithm struct EditPresetView: View { @Environment(\.dismiss) private var dismiss - @Environment(\.guidanceColors) private var guidanceColors + @Environment(\.colorPalette) private var colorPalette @Environment(\.settingsManager) private var settingsManager @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference @@ -36,6 +36,11 @@ struct EditPresetView: View { private var onSave: (SelectablePreset) throws -> Void private var onDelete: (SelectablePreset) throws -> Void + private var activityPresetIsModified: Bool? { + guard case let .activity(activityPreset) = preset else { return nil } + + return activityPreset.isModifiedFromDefault + } init( preset: SelectablePreset, @@ -113,6 +118,35 @@ struct EditPresetView: View { ) }.accessibilityIdentifier("button_CorrectionRange") } + + if let activityPresetIsModified { + Group { + if activityPresetIsModified { + Button { + if case let .activity(activityPreset) = preset { + withAnimation { + preset = .activity(ActivityPreset(activityType: activityPreset.activityType, preset: activityPreset.activityType.defaultPreset(duration: activityPreset.preset.duration))) + } + } + } label: { + Group { + Text(Image(systemName: "arrow.uturn.backward")) + Text(" ") + Text("Revert to recommended values") + } + .font(.body.weight(.semibold)) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(ActionButtonStyle(.secondary)) + } else { + Group { + Text(Image(systemName: "checkmark.seal.fill")) + Text(" ") + Text("Recommended starting values") + } + .font(.subheadline) + .foregroundStyle(Color.accentColor) + .frame(maxWidth: .infinity) + } + } + .padding(.vertical, 4) + } CardSection("Preset Details") { HStack { @@ -124,8 +158,14 @@ struct EditPresetView: View { .focused($isTextFieldFocused) .foregroundColor(.secondary) } else { - Text(preset.name) - .foregroundColor(.secondary) + HStack(spacing: 4) { + if case let .activity(activityPreset) = preset { + Text(Image(systemName: activityPreset.activityType.symbol.value)) + } + + Text(preset.name) + } + .foregroundColor(.secondary) } } } @@ -358,17 +398,8 @@ struct EditPresetView: View { var presetTitle: some View { HStack(spacing: 6) { - switch preset.icon { - case .emoji(let emoji): - Text(emoji) - .font(.system(size: 34, weight: .semibold)) - .foregroundColor(.primary) - case .image(let name, let iconColor): - Image(name) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(iconColor) - .frame(width: UIFontMetrics.default.scaledValue(for: 34), height: UIFontMetrics.default.scaledValue(for: 34)) + if let icon = preset.icon, !icon.isEmpty { + PresetSymbolView(icon, iconSize: 34) } Text(preset.name) diff --git a/Loop/Views/Presets/NewCustomPreset.swift b/Loop/Views/Presets/NewCustomPreset.swift index 3f3f1fb389..4052d2df6c 100644 --- a/Loop/Views/Presets/NewCustomPreset.swift +++ b/Loop/Views/Presets/NewCustomPreset.swift @@ -110,13 +110,31 @@ extension NewCustomPreset { targetRange: correctionRange, insulinNeedsScaleFactor: insulinMultiplier ) + + let split = name.splitSymbolAndTitle() + var symbol: PresetSymbol? = nil + if let emoji = split.emoji { + symbol = .emoji(emoji) + } return TemporaryPreset( - symbol: "", - name: name, + symbol: symbol, + name: split.name, settings: settings, duration: overrideDuration, scheduleStartDate: startDate ) } } + +private extension String { + func splitSymbolAndTitle() -> (emoji: String?, name: String) { + let trimmed = trimmingCharacters(in: .whitespaces) + if let first = trimmed.first, first.isEmoji { + let name = String(dropFirst()).trimmingCharacters(in: .whitespaces) + return (emoji: String(first), name: name) + } else { + return (emoji: nil, name: trimmed) + } + } +} diff --git a/Loop/Views/Presets/PresetsHistoryView.swift b/Loop/Views/Presets/PresetsHistoryView.swift index 6127146951..8a1a83346a 100644 --- a/Loop/Views/Presets/PresetsHistoryView.swift +++ b/Loop/Views/Presets/PresetsHistoryView.swift @@ -10,6 +10,7 @@ import LoopKit import SwiftUI struct PresetsHistoryView: View { + @Environment(\.colorPalette) private var colorPalette @Environment(\.settingsManager) private var settingsManager @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @@ -52,15 +53,8 @@ struct PresetsHistoryView: View { if let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == override.presetId }) { HStack(spacing: 4) { - switch preset.icon { - case .emoji(let emoji): - Text(emoji) - case .image(let name, let iconColor): - Image(name) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(iconColor) - .frame(width: UIFontMetrics.default.scaledValue(for: 22), height: UIFontMetrics.default.scaledValue(for: 22)) + if let icon = preset.icon, !icon.isEmpty { + PresetSymbolView(icon) } Text(preset.name) diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index d60af0952d..b410cd831f 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -96,7 +96,7 @@ struct PresetsView: View { PresetsTrainingCard(showTraining: $showTraining) } - if let activePreset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == temporaryPresetsManager.activeOverride?.presetId }) + if let activePreset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == temporaryPresetsManager.activePreset?.id }) { PresetCard( activePreset, @@ -303,6 +303,11 @@ struct PresetsView: View { extension PresetCard { init (_ preset: SelectablePreset, guardrail: Guardrail, expectedEndTime: PresetExpectedEndTime? = nil) { + var activityPresetIsModified: Bool? = nil + if case let .activity(activityPreset) = preset { + activityPresetIsModified = activityPreset.isModifiedFromDefault + } + self.init( presetId: preset.id, icon: preset.icon, @@ -312,7 +317,8 @@ extension PresetCard { correctionRange: preset.correctionRange, guardrail: guardrail, expectedEndTime: expectedEndTime, - isScheduled: preset.isScheduled + isScheduled: preset.isScheduled, + activityPresetIsModified: activityPresetIsModified ) } } diff --git a/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift b/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift index 84c104d230..422adaca3e 100644 --- a/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift +++ b/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift @@ -20,7 +20,6 @@ struct CreatingYourOwnPresetsContentView: View { BulletedListView { Text(Image("Pre-Meal-symbol")).foregroundColor(.carbTintColor) + Text(" Pre-Meal") - Text(Image("workout-symbol")).foregroundColor(.glucoseTintColor) + Text(" Workout") } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 499de318a1..b4e486e212 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -146,7 +146,6 @@ struct SettingsView: View { mode: .settings, viewModel: TherapySettingsViewModel( therapySettings: viewModel.therapySettings(), - sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, delegate: viewModel.therapySettingsViewModelDelegate ) ) diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index acd9f23e8a..6702a60ab1 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -32,10 +32,6 @@ public struct LoopSettings: Equatable { public var preMealTargetRange: ClosedRange? - public var legacyWorkoutTargetRange: ClosedRange? - - public var legacyWorkoutDuration: TemporaryScheduleOverride.Duration? - public var overridePresets: [TemporaryPreset] = [] public var maximumBasalRatePerHour: Double? @@ -59,8 +55,6 @@ public struct LoopSettings: Equatable { basalRateSchedule: BasalRateSchedule? = nil, carbRatioSchedule: CarbRatioSchedule? = nil, preMealTargetRange: ClosedRange? = nil, - legacyWorkoutTargetRange: ClosedRange? = nil, - legacyWorkoutDuration: TemporaryScheduleOverride.Duration = .indefinite, overridePresets: [TemporaryPreset]? = nil, maximumBasalRatePerHour: Double? = nil, maximumBolus: Double? = nil, @@ -74,8 +68,6 @@ public struct LoopSettings: Equatable { self.basalRateSchedule = basalRateSchedule self.carbRatioSchedule = carbRatioSchedule self.preMealTargetRange = preMealTargetRange - self.legacyWorkoutTargetRange = legacyWorkoutTargetRange - self.legacyWorkoutDuration = legacyWorkoutDuration self.overridePresets = overridePresets ?? [] self.maximumBasalRatePerHour = maximumBasalRatePerHour self.maximumBolus = maximumBolus @@ -110,24 +102,13 @@ extension LoopSettings: RawRepresentable { if let preMealTargetRawValue = overrideRangesRawValue["preMeal"] { self.preMealTargetRange = DoubleRange(rawValue: preMealTargetRawValue)?.quantityRange(for: LoopSettings.codingGlucoseUnit) } - if let legacyWorkoutTargetRawValue = overrideRangesRawValue["workout"] { - self.legacyWorkoutTargetRange = DoubleRange(rawValue: legacyWorkoutTargetRawValue)?.quantityRange(for: LoopSettings.codingGlucoseUnit) - } } } if let rawPreMealTargetRange = rawValue["preMealTargetRange"] as? DoubleRange.RawValue { self.preMealTargetRange = DoubleRange(rawValue: rawPreMealTargetRange)?.quantityRange(for: LoopSettings.codingGlucoseUnit) } - - if let rawLegacyWorkoutTargetRange = rawValue["legacyWorkoutTargetRange"] as? DoubleRange.RawValue { - self.legacyWorkoutTargetRange = DoubleRange(rawValue: rawLegacyWorkoutTargetRange)?.quantityRange(for: LoopSettings.codingGlucoseUnit) - } - - if let rawLegacyWorkoutDuration = rawValue["legacyWorkoutDuration"] as? Double { - self.legacyWorkoutDuration = .finite(rawLegacyWorkoutDuration) - } - + if let rawPresets = rawValue["overridePresets"] as? [TemporaryPreset.RawValue] { self.overridePresets = rawPresets.compactMap(TemporaryPreset.init(rawValue:)) } @@ -156,10 +137,6 @@ extension LoopSettings: RawRepresentable { raw["glucoseTargetRangeSchedule"] = glucoseTargetRangeSchedule?.rawValue raw["preMealTargetRange"] = preMealTargetRange?.doubleRange(for: LoopSettings.codingGlucoseUnit).rawValue - raw["legacyWorkoutTargetRange"] = legacyWorkoutTargetRange?.doubleRange(for: LoopSettings.codingGlucoseUnit).rawValue - if case .finite(let duration) = legacyWorkoutDuration { - raw["legacyWorkoutDuration"] = duration - } raw["maximumBasalRatePerHour"] = maximumBasalRatePerHour raw["maximumBolus"] = maximumBolus raw["minimumBGGuard"] = suspendThreshold?.rawValue diff --git a/LoopTests/Managers/Alerts/AlertManagerTests.swift b/LoopTests/Managers/Alerts/AlertManagerTests.swift index 6797845483..30fb535919 100644 --- a/LoopTests/Managers/Alerts/AlertManagerTests.swift +++ b/LoopTests/Managers/Alerts/AlertManagerTests.swift @@ -278,22 +278,6 @@ class AlertManagerTests: XCTestCase { XCTAssertEqual(mockAlertStore.retractedAlertDate, now) } - func testScheduleAlertForWorkoutReminder() async { - mockModalScheduler.alertScheduledExpectation = expectation(description: "modal alert scheduled") - alertManager.presetActivated(context: .legacyWorkout, duration: .indefinite) - await fulfillment(of: [mockModalScheduler.alertScheduledExpectation!], timeout: 1) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockModalScheduler.scheduledAlert?.identifier) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockUserNotificationScheduler.scheduledAlert?.identifier) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockAlertStore.issuedAlert?.identifier) - - mockModalScheduler.alertUnscheduledExpectation = expectation(description: "modal alert unscheduled") - alertManager.presetDeactivated(context: .legacyWorkout) - await fulfillment(of: [mockModalScheduler.alertUnscheduledExpectation!], timeout: 1) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockModalScheduler.unscheduledAlertIdentifier) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockUserNotificationScheduler.unscheduledAlertIdentifier) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockAlertStore.retractededAlertIdentifier) - } - func testLoopDidCompleteRecordsNotifications() async { await alertManager.loopDidComplete() XCTAssertEqual(4, UserDefaults.appGroup?.loopNotRunningNotifications.count) diff --git a/WatchApp Extension/Controllers/ActionHUDController.swift b/WatchApp Extension/Controllers/ActionHUDController.swift index cfdc981633..bb7c13a140 100644 --- a/WatchApp Extension/Controllers/ActionHUDController.swift +++ b/WatchApp Extension/Controllers/ActionHUDController.swift @@ -42,11 +42,7 @@ final class ActionHUDController: HUDInterfaceController { super.willActivate() // Update the override button description based on the feature flag; this cannot be done earlier than `-willActivate` (e.g. didSet on the IBOutlet is too soon) - if FeatureFlags.sensitivityOverridesEnabled { - overrideButtonLabel?.setText(NSLocalizedString("Preset", comment: "The text for the Watch button for enabling a custom preset")) - } else { - overrideButtonLabel?.setText(NSLocalizedString("Workout", comment: "The text for the Watch button for enabling workout mode")) - } + overrideButtonLabel?.setText(NSLocalizedString("Preset", comment: "The text for the Watch button for enabling a custom preset")) let userActivity = NSUserActivity.forViewLoopStatus() if #available(watchOSApplicationExtension 5.0, *) { @@ -97,11 +93,7 @@ final class ActionHUDController: HUDInterfaceController { } private var canEnableOverride: Bool { - if FeatureFlags.sensitivityOverridesEnabled { - return !loopManager.watchInfo.loopSettings.overridePresets.isEmpty - } else { - return loopManager.watchInfo.loopSettings.legacyWorkoutTargetRange != nil - } + !loopManager.watchInfo.loopSettings.overridePresets.isEmpty } private func updateForPreMeal(enabled: Bool) { @@ -118,7 +110,7 @@ final class ActionHUDController: HUDInterfaceController { overrideButtonGroup.turnOff() case .preset?, .custom?: overrideButtonGroup.state = .on - case .legacyWorkout?: + case .activity: preMealButtonGroup.turnOff() overrideButtonGroup.state = .on case .preMeal?: @@ -156,11 +148,6 @@ final class ActionHUDController: HUDInterfaceController { let overrideContext = watchInfo.scheduleOverride?.context if isPreMealEnabled { watchInfo.enablePreMealOverride(for: .hours(1)) - - if !FeatureFlags.sensitivityOverridesEnabled { - watchInfo.clearOverride(matching: .legacyWorkout) - updateForOverrideContext(nil) - } } else { watchInfo.clearOverride(matching: .preMeal) } @@ -203,25 +190,9 @@ final class ActionHUDController: HUDInterfaceController { } @IBAction func toggleOverride() { - if FeatureFlags.sensitivityOverridesEnabled { - overrideButtonGroup.state == .on - ? sendOverride(nil) - : presentController(withName: OverrideSelectionController.className, context: self as OverrideSelectionControllerDelegate) - } 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.watchInfo.legacyWorkoutOverride(for: .infinity) : nil - self.sendOverride(override) - }, - selectedButton: buttonToSelect, - selectedButtonTint: .glucose - ) - presentController(withName: OnOffSelectionController.className, context: viewModel) - } + overrideButtonGroup.state == .on + ? sendOverride(nil) + : presentController(withName: OverrideSelectionController.className, context: self as OverrideSelectionControllerDelegate) } private func formattedGlucoseRangeString(from range: ClosedRange) -> String { @@ -245,9 +216,6 @@ final class ActionHUDController: HUDInterfaceController { var watchInfo = loopManager.watchInfo let isPreMealEnabled = watchInfo.preMealOverride?.isActive() == true - if override?.context == .legacyWorkout { - watchInfo.preMealOverride = nil - } watchInfo.scheduleOverride = override do { diff --git a/WatchApp Extension/Controllers/OverrideSelectionController.swift b/WatchApp Extension/Controllers/OverrideSelectionController.swift index a34847df9a..3175ddce5f 100644 --- a/WatchApp Extension/Controllers/OverrideSelectionController.swift +++ b/WatchApp Extension/Controllers/OverrideSelectionController.swift @@ -45,7 +45,12 @@ final class OverrideSelectionController: WKInterfaceController, IdentifiableClas for index in presets.indices { let row = table.rowController(at: index) as! OverridePresetRow let preset = presets[index] - row.symbolLabel.setText(preset.symbol) + if let symbol = preset.symbol?.textualRepresentation { + row.symbolLabel.setText(symbol) + row.symbolLabel.setHidden(false) + } else { + row.symbolLabel.setHidden(true) + } row.nameLabel.setText(preset.name) } } diff --git a/WatchApp Extension/Views/OnOffSelectionView.swift b/WatchApp Extension/Views/OnOffSelectionView.swift index fc44b51efc..b6bf586045 100644 --- a/WatchApp Extension/Views/OnOffSelectionView.swift +++ b/WatchApp Extension/Views/OnOffSelectionView.swift @@ -79,12 +79,6 @@ struct OnOffSelectionView_Previews: PreviewProvider { OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Pre-Meal", message: "80-90 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .off, selectedButtonTint: .carbsColor)) .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 2 - 42mm")) - - OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Workout", message: "180-190 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .on, selectedButtonTint: .glucose)) - .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 4 - 44mm")) - - OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Workout", message: "180-190 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .off, selectedButtonTint: .glucose)) - .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 4 - 40mm")) } } } From cd450dfecee41e7e5df2fb5b50d4380fb1c4dd91 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 25 Aug 2025 10:39:42 -0700 Subject: [PATCH 279/421] [LOOP-5402 & LOOP-5393] Fix status overview labeling for increased and decreased insulin delivery and basal rate (#819) --- .../StatusTableViewController.swift | 5 +-- .../InsulinDeliveryLogViewModel.swift | 23 +++++++------ .../InsulinDeliveryOverview.swift | 32 +++++++++---------- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 7854000d94..367a63e0f8 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1311,12 +1311,13 @@ final class StatusTableViewController: LoopChartsTableViewController { case .iob: let showLegacy = false - if !showLegacy { + if !showLegacy, let pumpManager = deviceManager.pumpManager { let hostingController = UIHostingController( rootView: InsulinDeliveryLog( viewModel: InsulinDeliveryLogViewModel( loopDataManager: loopManager, - pumpManager: deviceManager.pumpManager + pumpManager: pumpManager, + settingsManager: settingsManager ), onTapGesture: { [weak navigationController] doseEntry in Task { diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index 1a064b0eb4..06bdae9c80 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -60,7 +60,8 @@ class InsulinDeliveryLogViewModel { }() private let loopDataManager: LoopDataManager - private let pumpManager: PumpManager? + private let pumpManager: PumpManager + private let settingsManager: SettingsManager private(set) var state: State @@ -142,11 +143,13 @@ class InsulinDeliveryLogViewModel { init( loopDataManager: LoopDataManager, - pumpManager: PumpManager?, + pumpManager: PumpManager, + settingsManager: SettingsManager, initialState: State = .loading ) { self.loopDataManager = loopDataManager self.pumpManager = pumpManager + self.settingsManager = settingsManager self.state = initialState self.doseStore = (loopDataManager.doseStore as? DoseStore) @@ -197,12 +200,12 @@ class InsulinDeliveryLogViewModel { private func fetchStatusState() -> InsulinDeliveryOverview.State { var insulinSuspended = false - if case .suspended = pumpManager?.status.basalDeliveryState { + if case .suspended = pumpManager.status.basalDeliveryState { insulinSuspended = true } let automationEnabled = loopDataManager.automaticDosingStatus.automaticDosingEnabled - let automatedTreatmentState = pumpManager?.pumpManagerDelegate?.automatedTreatmentState ?? .neutralNoOverride + let automatedTreatmentState = pumpManager.pumpManagerDelegate?.automatedTreatmentState ?? .neutralNoOverride if insulinSuspended { return .error(status: .suspended) @@ -212,9 +215,9 @@ class InsulinDeliveryLogViewModel { case .neutralNoOverride, .neutralOverride: basalStatus = .scheduled case .increasedInsulin: - basalStatus = .moreThanScheduled + basalStatus = .increased case .decreasedInsulin, .minimumDelivery: - basalStatus = .lessThanScheduled + basalStatus = .decreased } return .automationOn(basalStatus: basalStatus, preset: loopDataManager.temporaryPresetsManager.activePreset) @@ -224,12 +227,12 @@ class InsulinDeliveryLogViewModel { } private func fetchCurrentBasal(startDate: Date) -> DatedQuantity? { - guard let basalRateSchedule = loopDataManager.temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory ?? loopDataManager.settings.basalRateSchedule else { + guard let basalSchedule = loopDataManager.temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory ?? loopDataManager.settings.basalRateSchedule, + let netBasal = pumpManager.status.basalDeliveryState?.getNetBasal(basalSchedule: basalSchedule, maximumBasalRatePerHour: settingsManager.settings.maximumBasalRatePerHour) else { return nil } - - let currentValue = basalRateSchedule.scheduleSegment(at: startDate) - return DatedQuantity(date: currentValue.startDate, quantity: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: currentValue.value)) + + return DatedQuantity(date: netBasal.start, quantity: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: netBasal.rate)) } private func fetchLastAutoBolus(doses: [DoseEntry]) -> DatedQuantity? { diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift index d6c2093699..265e5d7059 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift @@ -19,8 +19,8 @@ struct InsulinDeliveryOverview: View { enum State: Hashable { enum AutomatedBasalStatus: Hashable { case scheduled - case moreThanScheduled - case lessThanScheduled + case increased + case decreased } case automationOn(basalStatus: AutomatedBasalStatus, preset: SelectablePreset?) @@ -63,9 +63,9 @@ struct InsulinDeliveryOverview: View { switch basalStatus { case .scheduled: Text(Image(systemName: "arrow.right.square.fill")) - case .moreThanScheduled: + case .increased: Text(Image(systemName: "arrow.up.square.fill")) - case .lessThanScheduled: + case .decreased: Text(Image(systemName: "arrow.down.square.fill")) } } @@ -94,11 +94,11 @@ struct InsulinDeliveryOverview: View { case .automationOn(let basalStatus, _): switch basalStatus { case .scheduled: - Text("Scheduled basal") - case .moreThanScheduled: - Text("More than scheduled") - case .lessThanScheduled: - Text("Less than scheduled") + Text("Scheduled Basal") + case .increased: + Text("Increased Delivery") + case .decreased: + Text("Decreased Delivery") } case .automationOff: Text("Scheduled basal") @@ -119,12 +119,12 @@ struct InsulinDeliveryOverview: View { switch basalStatus { case .scheduled: Text("A preset with \(preset.insulinNeedsScaleFactor.formatted(.percent)) overall insulin is on. This is your new preset baseline and it overrides your Scheduled Basal.") - case .moreThanScheduled: + case .increased: Text("A preset with \(preset.insulinNeedsScaleFactor.formatted(.percent)) overall insulin is on. The system is currently delivering more than your preset baseline.") - case .lessThanScheduled: + case .decreased: Text("A preset with \(preset.insulinNeedsScaleFactor.formatted(.percent)) overall insulin is on. The system is currently delivering less than your preset baseline.") } - } else if basalStatus == .moreThanScheduled { + } else if basalStatus == .increased { Text("Includes basal and automated boluses") } else { nil @@ -274,7 +274,7 @@ let preset = SelectablePreset.custom(TemporaryPreset(symbol: "🏃", name: "Runn #Preview("Automated Delivery (less than scheduled)", traits: .sizeThatFitsLayout) { InsulinDeliveryOverview( - state: .automationOn(basalStatus: .lessThanScheduled, preset: nil), + state: .automationOn(basalStatus: .decreased, preset: nil), time: time, currentBasalRate: currentBasalRate, lastAutoBolus: lastAutoBolus @@ -285,7 +285,7 @@ let preset = SelectablePreset.custom(TemporaryPreset(symbol: "🏃", name: "Runn #Preview("Automated Delivery (more than scheduled)", traits: .sizeThatFitsLayout) { InsulinDeliveryOverview( - state: .automationOn(basalStatus: .moreThanScheduled, preset: nil), + state: .automationOn(basalStatus: .increased, preset: nil), time: time, currentBasalRate: currentBasalRate, lastAutoBolus: nil @@ -307,7 +307,7 @@ let preset = SelectablePreset.custom(TemporaryPreset(symbol: "🏃", name: "Runn #Preview("Preset (less than scheduled)", traits: .sizeThatFitsLayout) { InsulinDeliveryOverview( - state: .automationOn(basalStatus: .lessThanScheduled, preset: preset), + state: .automationOn(basalStatus: .decreased, preset: preset), time: time, currentBasalRate: currentBasalRate, lastAutoBolus: lastAutoBolus @@ -318,7 +318,7 @@ let preset = SelectablePreset.custom(TemporaryPreset(symbol: "🏃", name: "Runn #Preview("Preset (more than scheduled)", traits: .sizeThatFitsLayout) { InsulinDeliveryOverview( - state: .automationOn(basalStatus: .moreThanScheduled, preset: preset), + state: .automationOn(basalStatus: .increased, preset: preset), time: time, currentBasalRate: currentBasalRate, lastAutoBolus: lastAutoBolus From 20c5b0329cc09c553544686fb30df6b5ab60a78e Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 26 Aug 2025 09:46:08 -0700 Subject: [PATCH 280/421] [LOOP-5405 & LOOP-5420] Update Activity Preset Default Insulin Sensitivity & Fix Delivery Log Current Basal Value (#820) --- Loop/Models/SelectablePreset.swift | 9 ++++ .../StatusTableViewController.swift | 3 +- .../InsulinDeliveryLogViewModel.swift | 26 ++++++------ .../Components/InsulinScaleAdjustView.swift | 42 +++++++++++++++---- Loop/Views/Presets/DurationPickerView.swift | 17 +++++--- Loop/Views/Presets/EditPresetView.swift | 3 +- 6 files changed, 71 insertions(+), 29 deletions(-) diff --git a/Loop/Models/SelectablePreset.swift b/Loop/Models/SelectablePreset.swift index aeee930a80..57c1be6a2c 100644 --- a/Loop/Models/SelectablePreset.swift +++ b/Loop/Models/SelectablePreset.swift @@ -310,6 +310,15 @@ enum SelectablePreset: Hashable, Identifiable { } } + var allowsIndefiniteDuration: Bool { + switch self { + case .custom: + return true + case .preMeal, .activity: + return false + } + } + var canAdjustDuration: Bool { switch self { case .custom, .activity: diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 367a63e0f8..2535b99738 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1316,8 +1316,7 @@ final class StatusTableViewController: LoopChartsTableViewController { rootView: InsulinDeliveryLog( viewModel: InsulinDeliveryLogViewModel( loopDataManager: loopManager, - pumpManager: pumpManager, - settingsManager: settingsManager + pumpManager: pumpManager ), onTapGesture: { [weak navigationController] doseEntry in Task { diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index 06bdae9c80..38d84894c9 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -61,7 +61,6 @@ class InsulinDeliveryLogViewModel { private let loopDataManager: LoopDataManager private let pumpManager: PumpManager - private let settingsManager: SettingsManager private(set) var state: State @@ -144,12 +143,10 @@ class InsulinDeliveryLogViewModel { init( loopDataManager: LoopDataManager, pumpManager: PumpManager, - settingsManager: SettingsManager, initialState: State = .loading ) { self.loopDataManager = loopDataManager self.pumpManager = pumpManager - self.settingsManager = settingsManager self.state = initialState self.doseStore = (loopDataManager.doseStore as? DoseStore) @@ -168,17 +165,17 @@ class InsulinDeliveryLogViewModel { let fetchedDate = Date() let startDate = fetchedDate.addingTimeInterval(.days(-1)) - guard let currentBasalRate = fetchCurrentBasal(startDate: startDate) else { - state = .error(.noBasalRateSchedule) - return - } - let statusState = fetchStatusState() let totalInsulinDelivered = await fetchTotalInsulinDeliveredToday() let doses = await fetchDoses(since: startDate) let lastAutoBolus = fetchLastAutoBolus(doses: doses) let decisions = await fetchDosingDecisions(doses.compactMap(\.decisionId)) + guard let currentBasalRate = fetchCurrentBasal(from: doses) else { + state = .error(.noBasalRateSchedule) + return + } + // map raw event data into delivery log events for display var events = [InsulinDeliveryLogEvent]() handleDoseEvents(doses: doses, decisions: decisions, fetchedDate: fetchedDate, events: &events) @@ -226,13 +223,18 @@ class InsulinDeliveryLogViewModel { } } - private func fetchCurrentBasal(startDate: Date) -> DatedQuantity? { - guard let basalSchedule = loopDataManager.temporaryPresetsManager.basalRateScheduleApplyingOverrideHistory ?? loopDataManager.settings.basalRateSchedule, - let netBasal = pumpManager.status.basalDeliveryState?.getNetBasal(basalSchedule: basalSchedule, maximumBasalRatePerHour: settingsManager.settings.maximumBasalRatePerHour) else { + private func fetchCurrentBasal(from doses: [DoseEntry]) -> DatedQuantity? { + guard let lastDose = doses.last(where: { $0.type == .basal || $0.type == .tempBasal }) else { return nil } - return DatedQuantity(date: netBasal.start, quantity: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: netBasal.rate)) + return DatedQuantity( + date: lastDose.startDate, + quantity: LoopQuantity( + unit: .internationalUnitsPerHour, + doubleValue: lastDose.value + ) + ) } private func fetchLastAutoBolus(doses: [DoseEntry]) -> DatedQuantity? { diff --git a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift index 346649309e..312ec33abe 100644 --- a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift +++ b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift @@ -21,7 +21,7 @@ public struct InsulinScaleAdjustView: View { @Binding var insulinMultiplier: Double var insulinPercentage: Double { - get { return (insulinMultiplier * 20).rounded() * 5 } + insulinMultiplier * 100 } public var body: some View { @@ -83,9 +83,7 @@ public struct InsulinScaleAdjustView: View { private var adjustInsulinControls: some View { HStack(spacing: 24) { Button(action: { - if insulinPercentage > 10 { - insulinMultiplier = (insulinPercentage - 5) / 100 - } + decreaseInsulinMultiplier() }) { Text(Image(systemName: "minus.circle.fill").symbolRenderingMode(.hierarchical)) .font(.system(size: 44, weight: .bold)) @@ -99,9 +97,7 @@ public struct InsulinScaleAdjustView: View { .foregroundColor(valueColor) Button(action: { - if insulinPercentage < 200 { - insulinMultiplier = (insulinPercentage + 5) / 100 - } + increaseInsulinMultiplier() }) { Text(Image(systemName: "plus.circle.fill").symbolRenderingMode(.hierarchical)) .font(.system(size: 44, weight: .bold)) @@ -109,7 +105,18 @@ public struct InsulinScaleAdjustView: View { } .buttonStyle(BorderlessButtonStyle()) } - + } + + private func decreaseInsulinMultiplier() { + if insulinPercentage > 10 { + insulinMultiplier = insulinPercentage.snap(direction: .down) / 100 + } + } + + private func increaseInsulinMultiplier() { + if insulinPercentage < 200 { + insulinMultiplier = insulinPercentage.snap(direction: .up) / 100 + } } private var settingsImpact: some View { @@ -187,3 +194,22 @@ public struct InsulinScaleAdjustView: View { } } } + +extension Double { + enum StepDirection { case up, down } + + func snap(step: Double = 5, direction: StepDirection) -> Double { + let remainder = truncatingRemainder(dividingBy: step) + + if remainder == 0 { + return direction == .up ? self + step : self - step + } else { + switch direction { + case .up: + return self + (step - remainder) + case .down: + return self - remainder + } + } + } +} diff --git a/Loop/Views/Presets/DurationPickerView.swift b/Loop/Views/Presets/DurationPickerView.swift index dd702a2a90..bdd732d8e6 100644 --- a/Loop/Views/Presets/DurationPickerView.swift +++ b/Loop/Views/Presets/DurationPickerView.swift @@ -11,12 +11,13 @@ import SwiftUI struct DurationPickerView: View { @Binding var durationType: PresetDuration @State private var lastUsedDuration: TimeInterval + @State private var allowIndefinite: Bool // Available values (respecting min 5min and max 8hr constraints) private let availableHours = Array(0...8) private let availableMinutes = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55] - init(durationType: Binding) { + init(durationType: Binding, allowIndefinite: Bool = true) { self._durationType = durationType // Initialize lastUsedDuration based on current durationType or default to 1 hour @@ -28,6 +29,7 @@ struct DurationPickerView: View { initialDuration = 3600 // 1 hour default } self._lastUsedDuration = State(initialValue: initialDuration) + self.allowIndefinite = allowIndefinite } private var hours: Binding { @@ -130,11 +132,14 @@ struct DurationPickerView: View { if !isIndefinite.wrappedValue { picker } - HStack { - Text("Until I turn off") - Spacer() - Toggle("", isOn: isIndefinite) - .labelsHidden() + + if allowIndefinite { + HStack { + Text("Until I turn off") + Spacer() + Toggle("", isOn: isIndefinite) + .labelsHidden() + } } } } diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 121603e1f9..e41fe87223 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -202,7 +202,8 @@ struct EditPresetView: View { if isDurationPickerExpanded { DurationPickerView( - durationType: $preset.duration + durationType: $preset.duration, + allowIndefinite: preset.allowsIndefiniteDuration ) .id("durationPicker") // Assign an ID for scrolling } From b64285c626f99d5160d129d505a2acb10bf0c532 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 27 Aug 2025 11:26:37 -0700 Subject: [PATCH 281/421] [LOOP-5405] Add Unmodified Badge to PresetDetentView (#821) --- Loop/Views/Presets/Components/PresetDetentView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index e949fddc2c..45450c0479 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -148,6 +148,14 @@ struct PresetDetentView: View { isActive: temporaryPresetsManager.activePreset?.id == preset.id ) + if case let .activity(activityPreset) = preset, !activityPreset.isModifiedFromDefault { + Text("\(Image(systemName: "checkmark.seal.fill")) Recommended starting values") + .font(.subheadline) + .foregroundStyle(Color.accentColor) + .frame(maxWidth: .infinity) + .padding(.bottom, 4) + } + actionArea } .toolbar(.hidden) From 7a51b8b06314887673d71b54ad720217b98e8f7e Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 29 Aug 2025 14:00:22 -0700 Subject: [PATCH 282/421] [LOOP-5417] Presets Training Part 1 * [LOOP-5417] Presets Training Part 1 * [LOOP-5417] Fix floating point math for insulin stepper --- Loop.xcodeproj/project.pbxproj | 70 ++--- .../PresetsTrainingViewModel.swift | 84 ------ .../{ => Components}/ActivePresetBanner.swift | 0 .../Components/InsulinScaleAdjustView.swift | 10 +- .../Views/Presets/Components/PresetCard.swift | 2 + .../Presets/Components/PresetSymbolView.swift | 1 + .../Components/PresetsTrainingCard.swift | 36 --- .../PresetsTrainingContentContainerView.swift | 105 -------- Loop/Views/Presets/PresetsTrainingView.swift | 38 --- Loop/Views/Presets/PresetsView.swift | 37 +-- .../Components/EstimatedReadTime.swift | 40 +++ .../Components/InsetContent.swift | 32 +++ .../Components/PresetsTrainingCard.swift | 31 +++ .../Components/TimelineSteps.swift | 160 +++++++++++ .../CreatingYourOwnPresetsContentView.swift | 63 ----- .../HowTheyWorkContentView.swift | 118 -------- .../PresetsAndExerciseContentView.swift | 160 ----------- .../PresetsAndIllnessContentView.swift | 144 ---------- .../Training Content/PresetsTraining.swift | 212 +++++++++++++++ .../PresetsTrainingContent.swift | 253 ++++++++++++++++++ .../PresetsTrainingView.swift | 132 +++++++++ 21 files changed, 928 insertions(+), 800 deletions(-) delete mode 100644 Loop/View Models/PresetsTrainingViewModel.swift rename Loop/Views/Presets/{ => Components}/ActivePresetBanner.swift (100%) delete mode 100644 Loop/Views/Presets/Components/PresetsTrainingCard.swift delete mode 100644 Loop/Views/Presets/Components/PresetsTrainingContentContainerView.swift delete mode 100644 Loop/Views/Presets/PresetsTrainingView.swift create mode 100644 Loop/Views/Presets/Training Content/Components/EstimatedReadTime.swift create mode 100644 Loop/Views/Presets/Training Content/Components/InsetContent.swift create mode 100644 Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift create mode 100644 Loop/Views/Presets/Training Content/Components/TimelineSteps.swift delete mode 100644 Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift delete mode 100644 Loop/Views/Presets/Training Content/HowTheyWorkContentView.swift delete mode 100644 Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift delete mode 100644 Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift create mode 100644 Loop/Views/Presets/Training Content/PresetsTraining.swift create mode 100644 Loop/Views/Presets/Training Content/PresetsTrainingContent.swift create mode 100644 Loop/Views/Presets/Training Content/PresetsTrainingView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 218d61e6d8..73eb5168ba 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -248,8 +248,15 @@ 84213C8B2D94948900642E78 /* InsulinDeliveryOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */; }; 84213C8D2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */; }; 843F32EA2E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */; }; + 84475DF02E5E644D00FC5E7C /* PresetsTraining.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475DEF2E5E644D00FC5E7C /* PresetsTraining.swift */; }; + 84475DF22E5E64A700FC5E7C /* PresetsTrainingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475DF12E5E64A700FC5E7C /* PresetsTrainingContent.swift */; }; + 84475E0A2E5ECD4F00FC5E7C /* EstimatedReadTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475E092E5ECD4F00FC5E7C /* EstimatedReadTime.swift */; }; + 84475E0C2E5EDF1800FC5E7C /* InsetContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475E0B2E5EDF1800FC5E7C /* InsetContent.swift */; }; + 84475E0E2E5F00B900FC5E7C /* TimelineSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475E0D2E5F00B900FC5E7C /* TimelineSteps.swift */; }; + 84475E102E5F870800FC5E7C /* PresetsTrainingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475E0F2E5F870800FC5E7C /* PresetsTrainingCard.swift */; }; 847F23432E4543140035C864 /* ActivePresetBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847F23422E4543140035C864 /* ActivePresetBanner.swift */; }; 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; + 849E06232E5E41BA00A71614 /* PresetsTrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849E06222E5E41BA00A71614 /* PresetsTrainingView.swift */; }; 84A7B5502D2D972C00B6D202 /* Image+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */; }; 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */; }; @@ -264,18 +271,10 @@ 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A82D09800700CB271F /* PresetDetentView.swift */; }; 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; 84DEF35D2E566757006126F9 /* PresetSymbolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */; }; - 84E8BBAE2CC9791E0078E6CF /* PresetsTrainingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */; }; - 84E8BBB12CC979820078E6CF /* PresetsTrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */; }; - 84E8BBB32CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB22CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift */; }; - 84E8BBB52CC98C3F0078E6CF /* PresetsTrainingContentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB42CC98C3F0078E6CF /* PresetsTrainingContentContainerView.swift */; }; - 84E8BBB82CC9924B0078E6CF /* HowTheyWorkContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB72CC9924B0078E6CF /* HowTheyWorkContentView.swift */; }; - 84E8BBBA2CC9925C0078E6CF /* PresetsAndExerciseContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBB92CC9925C0078E6CF /* PresetsAndExerciseContentView.swift */; }; - 84E8BBBC2CC992660078E6CF /* PresetsAndIllnessContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBBB2CC992660078E6CF /* PresetsAndIllnessContentView.swift */; }; 84E8BBC42CC9B9890078E6CF /* AdjustedGlucoseRangeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */; }; 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */; }; 84E8BBC82CC9D34B0078E6CF /* TherapySettingsExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */; }; 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC92CCA16290078E6CF /* PresetsView.swift */; }; - 84E8BBCE2CCA1E070078E6CF /* PresetsTrainingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */; }; 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */; }; 84F20DFD2D0B9C3A0089DF02 /* EditPresetDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */; }; @@ -1160,8 +1159,15 @@ 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryOverview.swift; sourceTree = ""; }; 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogEventRow.swift; sourceTree = ""; }; 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogViewModel.swift; sourceTree = ""; }; + 84475DEF2E5E644D00FC5E7C /* PresetsTraining.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTraining.swift; sourceTree = ""; }; + 84475DF12E5E64A700FC5E7C /* PresetsTrainingContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingContent.swift; sourceTree = ""; }; + 84475E092E5ECD4F00FC5E7C /* EstimatedReadTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedReadTime.swift; sourceTree = ""; }; + 84475E0B2E5EDF1800FC5E7C /* InsetContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetContent.swift; sourceTree = ""; }; + 84475E0D2E5F00B900FC5E7C /* TimelineSteps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSteps.swift; sourceTree = ""; }; + 84475E0F2E5F870800FC5E7C /* PresetsTrainingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingCard.swift; sourceTree = ""; }; 847F23422E4543140035C864 /* ActivePresetBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivePresetBanner.swift; sourceTree = ""; }; 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; + 849E06222E5E41BA00A71614 /* PresetsTrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingView.swift; sourceTree = ""; }; 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Optional.swift"; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; @@ -1176,18 +1182,10 @@ 84D1F1A82D09800700CB271F /* PresetDetentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDetentView.swift; sourceTree = ""; }; 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.swift; sourceTree = ""; }; 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetSymbolView.swift; sourceTree = ""; }; - 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingViewModel.swift; sourceTree = ""; }; - 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingView.swift; sourceTree = ""; }; - 84E8BBB22CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatingYourOwnPresetsContentView.swift; sourceTree = ""; }; - 84E8BBB42CC98C3F0078E6CF /* PresetsTrainingContentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingContentContainerView.swift; sourceTree = ""; }; - 84E8BBB72CC9924B0078E6CF /* HowTheyWorkContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowTheyWorkContentView.swift; sourceTree = ""; }; - 84E8BBB92CC9925C0078E6CF /* PresetsAndExerciseContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsAndExerciseContentView.swift; sourceTree = ""; }; - 84E8BBBB2CC992660078E6CF /* PresetsAndIllnessContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsAndIllnessContentView.swift; sourceTree = ""; }; 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustedGlucoseRangeView.swift; sourceTree = ""; }; 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PercentPickerView.swift; sourceTree = ""; }; 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySettingsExampleView.swift; sourceTree = ""; }; 84E8BBC92CCA16290078E6CF /* PresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsView.swift; sourceTree = ""; }; - 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingCard.swift; sourceTree = ""; }; 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetStatsView.swift; sourceTree = ""; }; 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPresetDurationView.swift; sourceTree = ""; }; @@ -2518,6 +2516,17 @@ path = "Insulin Delivery Log"; sourceTree = ""; }; + 84475E082E5ECD3400FC5E7C /* Components */ = { + isa = PBXGroup; + children = ( + 84475E092E5ECD4F00FC5E7C /* EstimatedReadTime.swift */, + 84475E0B2E5EDF1800FC5E7C /* InsetContent.swift */, + 84475E0D2E5F00B900FC5E7C /* TimelineSteps.swift */, + 84475E0F2E5F870800FC5E7C /* PresetsTrainingCard.swift */, + ); + path = Components; + sourceTree = ""; + }; 84AA81D12A4A2778000B658B /* Components */ = { isa = PBXGroup; children = ( @@ -2585,12 +2594,10 @@ C1AC039D2D6FC8BB004D4D2B /* NewPresetRangeEdit.swift */, C16971F82D1231AB001B7DF6 /* PresetRangeEditor.swift */, 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */, - 84E8BBB02CC9793C0078E6CF /* PresetsTrainingView.swift */, 84E8BBC92CCA16290078E6CF /* PresetsView.swift */, C105095E2D7A310B00118A37 /* ReviewNewPresetView.swift */, 84E8BBC22CC9B9780078E6CF /* Components */, 84E8BBB62CC990480078E6CF /* Training Content */, - 847F23422E4543140035C864 /* ActivePresetBanner.swift */, ); path = Presets; sourceTree = ""; @@ -2598,10 +2605,10 @@ 84E8BBB62CC990480078E6CF /* Training Content */ = { isa = PBXGroup; children = ( - 84E8BBB22CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift */, - 84E8BBB72CC9924B0078E6CF /* HowTheyWorkContentView.swift */, - 84E8BBB92CC9925C0078E6CF /* PresetsAndExerciseContentView.swift */, - 84E8BBBB2CC992660078E6CF /* PresetsAndIllnessContentView.swift */, + 84475E082E5ECD3400FC5E7C /* Components */, + 849E06222E5E41BA00A71614 /* PresetsTrainingView.swift */, + 84475DEF2E5E644D00FC5E7C /* PresetsTraining.swift */, + 84475DF12E5E64A700FC5E7C /* PresetsTrainingContent.swift */, ); path = "Training Content"; sourceTree = ""; @@ -2617,15 +2624,14 @@ C105095C2D7A1DB300118A37 /* CardSection.swift */, 84C170EC2CCA361F0098E52F /* ImpactView.swift */, 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */, - 84E8BBCD2CCA1E070078E6CF /* PresetsTrainingCard.swift */, 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */, 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */, - 84E8BBB42CC98C3F0078E6CF /* PresetsTrainingContentContainerView.swift */, 84C170EE2CCA37680098E52F /* PresetCard.swift */, 84D1F1A82D09800700CB271F /* PresetDetentView.swift */, 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */, 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */, 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */, + 847F23422E4543140035C864 /* ActivePresetBanner.swift */, ); path = Components; sourceTree = ""; @@ -2704,7 +2710,6 @@ 1D49795724E7289700948F05 /* ServicesViewModel.swift */, C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */, 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */, - 84E8BBAD2CC9791E0078E6CF /* PresetsTrainingViewModel.swift */, ); path = "View Models"; sourceTree = ""; @@ -3600,7 +3605,6 @@ C14F68C92D4AC54300BC3B8D /* DurationPickerView.swift in Sources */, 14C9706E2C5A83AF00E8A01B /* IOBChartView.swift in Sources */, E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */, - 84E8BBCE2CCA1E070078E6CF /* PresetsTrainingCard.swift in Sources */, B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */, E98A55ED24EDD6380008715D /* LatestStoredSettingsProvider.swift in Sources */, C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */, @@ -3618,7 +3622,6 @@ 1452F4AD2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift in Sources */, C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */, 14C970822C5C2EC100E8A01B /* FavoriteFoodInsightsView.swift in Sources */, - 84E8BBB12CC979820078E6CF /* PresetsTrainingView.swift in Sources */, 1D080CBD2473214A00356610 /* AlertStore.xcdatamodeld in Sources */, C11BD0552523CFED00236B08 /* SimpleBolusViewModel.swift in Sources */, C1F2CAAA2B76B3EE00D7F581 /* TempBasalRecommendation.swift in Sources */, @@ -3626,17 +3629,21 @@ 149A28BB2A853E5100052EDF /* CarbEntryViewModel.swift in Sources */, C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */, C1AC039C2D6E7551004D4D2B /* ExistingPresetRangeEdit.swift in Sources */, + 84475E0C2E5EDF1800FC5E7C /* InsetContent.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, + 84475E0A2E5ECD4F00FC5E7C /* EstimatedReadTime.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */, 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */, + 84475E0E2E5F00B900FC5E7C /* TimelineSteps.swift in Sources */, 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, C16971F92D1231B5001B7DF6 /* PresetRangeEditor.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, + 849E06232E5E41BA00A71614 /* PresetsTrainingView.swift in Sources */, 84F20DFD2D0B9C3A0089DF02 /* EditPresetDurationView.swift in Sources */, 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */, 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */, @@ -3646,11 +3653,10 @@ 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */, 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */, A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */, + 84475DF22E5E64A700FC5E7C /* PresetsTrainingContent.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, - 84E8BBBA2CC9925C0078E6CF /* PresetsAndExerciseContentView.swift in Sources */, C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */, 1452F4AB2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift in Sources */, - 84E8BBAE2CC9791E0078E6CF /* PresetsTrainingViewModel.swift in Sources */, C1742332259BEADC00399C9D /* ManualEntryDoseView.swift in Sources */, 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */, 84213C8D2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift in Sources */, @@ -3672,7 +3678,6 @@ C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */, 4372E487213C86240068E043 /* SampleValue.swift in Sources */, 14C970682C5991CD00E8A01B /* LoopChartView.swift in Sources */, - 84E8BBB52CC98C3F0078E6CF /* PresetsTrainingContentContainerView.swift in Sources */, 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */, C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */, 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */, @@ -3713,7 +3718,6 @@ A9347F3124E7521800C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */, C105095D2D7A1DB700118A37 /* CardSection.swift in Sources */, A9CBE458248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift in Sources */, - 84E8BBBC2CC992660078E6CF /* PresetsAndIllnessContentView.swift in Sources */, 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */, 4374B5EF209D84BF00D17AA8 /* OSLog.swift in Sources */, 1DB619AC270BAD3D006C9D07 /* VersionUpdateViewModel.swift in Sources */, @@ -3755,7 +3759,6 @@ C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */, B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */, 14C970842C5C2FB400E8A01B /* HowCarbEffectsWorksView.swift in Sources */, - 84E8BBB32CC97C480078E6CF /* CreatingYourOwnPresetsContentView.swift in Sources */, 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */, 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, @@ -3803,6 +3806,7 @@ 84E8BBC82CC9D34B0078E6CF /* TherapySettingsExampleView.swift in Sources */, 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */, + 84475E102E5F870800FC5E7C /* PresetsTrainingCard.swift in Sources */, 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */, C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */, A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, @@ -3817,7 +3821,6 @@ 892A5D59222F0A27008961AB /* Debug.swift in Sources */, 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */, 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, - 84E8BBB82CC9924B0078E6CF /* HowTheyWorkContentView.swift in Sources */, 14C9706A2C5A833100E8A01B /* CarbEffectChartView.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */, @@ -3827,6 +3830,7 @@ A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */, 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */, 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, + 84475DF02E5E644D00FC5E7C /* PresetsTraining.swift in Sources */, 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, 84A7B5502D2D972C00B6D202 /* Image+Optional.swift in Sources */, diff --git a/Loop/View Models/PresetsTrainingViewModel.swift b/Loop/View Models/PresetsTrainingViewModel.swift deleted file mode 100644 index 3b12681a01..0000000000 --- a/Loop/View Models/PresetsTrainingViewModel.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// PresetsTrainingViewModel.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import Foundation -import SwiftUI - -class PresetsTrainingViewModel: ObservableObject { - - @Published var navigationPath: [Step] - - init(step: Step = .creatingYourOwnPresets) { - self.navigationPath = step.fullPath() - } - - func nextPage() { - if navigationPath.isEmpty, let firstPage = Step.allCases.dropFirst().first { - navigationPath.append(firstPage) - } else if let next = navigationPath.last?.next() { - navigationPath.append(next) - } - } -} - -extension PresetsTrainingViewModel { - enum Step: Int, Hashable, CaseIterable { - case creatingYourOwnPresets - case howTheyWork1 - case howTheyWork2 - case presetsAndExercise1 - case presetsAndExercise2 - case presetsAndExercise3 - case presetsAndExercise4 - case presetsAndIllness1 - case presetsAndIllness2 - case presetsAndIllness3 - case presetsAndIllness4 - - var localizedTitle: String { - switch self { - case .creatingYourOwnPresets: - return NSLocalizedString("Creating Your Own Presets", comment: "Preset training, Creating your own presets, title") - case .howTheyWork1, .howTheyWork2: - return NSLocalizedString("How They Work", comment: "Preset training, How they work, title") - case .presetsAndExercise1, .presetsAndExercise2, .presetsAndExercise3, .presetsAndExercise4: - return NSLocalizedString("Presets and Exercise", comment: "Preset training, Presets and exercise, title") - case .presetsAndIllness1, .presetsAndIllness2, .presetsAndIllness3, .presetsAndIllness4: - return NSLocalizedString("Presets and Illness", comment: "Preset training, Presets and illness, title") - } - } - - var isFinalStep: Bool { - self.rawValue == Step.allCases.last?.rawValue - } - - fileprivate func next() -> Self? { - switch self { - case .creatingYourOwnPresets: .howTheyWork1 - case .howTheyWork1: .howTheyWork2 - case .howTheyWork2: .presetsAndExercise1 - case .presetsAndExercise1: .presetsAndExercise2 - case .presetsAndExercise2: .presetsAndExercise3 - case .presetsAndExercise3: .presetsAndExercise4 - case .presetsAndExercise4: .presetsAndIllness1 - case .presetsAndIllness1: .presetsAndIllness2 - case .presetsAndIllness2: .presetsAndIllness3 - case .presetsAndIllness3: .presetsAndIllness4 - case .presetsAndIllness4: nil - } - } - - fileprivate func fullPath() -> [Self] { - guard let currentIndex = Step.allCases.firstIndex(of: self), currentIndex != 0 else { - return [] - } - - return Array((1...currentIndex).map({ Step.allCases[$0] })) - } - } -} diff --git a/Loop/Views/Presets/ActivePresetBanner.swift b/Loop/Views/Presets/Components/ActivePresetBanner.swift similarity index 100% rename from Loop/Views/Presets/ActivePresetBanner.swift rename to Loop/Views/Presets/Components/ActivePresetBanner.swift diff --git a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift index 312ec33abe..a96a452f15 100644 --- a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift +++ b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift @@ -199,16 +199,18 @@ extension Double { enum StepDirection { case up, down } func snap(step: Double = 5, direction: StepDirection) -> Double { - let remainder = truncatingRemainder(dividingBy: step) + let value = self / step + let tolerance = 1e-9 // Smooths out floating point math quirks + let isExactMultiple = abs(value.rounded() - value) < tolerance - if remainder == 0 { + if isExactMultiple { return direction == .up ? self + step : self - step } else { switch direction { case .up: - return self + (step - remainder) + return ceil(value) * step case .down: - return self - remainder + return floor(value) * step } } } diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index 308274304f..afb8d935bd 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -14,6 +14,7 @@ import LoopKit struct PresetCard: View { @Environment(\.colorPalette) private var colorPalette + @Environment(\.isEnabled) private var isEnabled @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference @@ -116,6 +117,7 @@ struct PresetCard: View { .fill(Color(UIColor.tertiarySystemBackground)) .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) .frame(maxWidth: .infinity)) + .opacity(isEnabled ? 1 : 0.6) } } diff --git a/Loop/Views/Presets/Components/PresetSymbolView.swift b/Loop/Views/Presets/Components/PresetSymbolView.swift index 7a8b47c48e..324ddc5d36 100644 --- a/Loop/Views/Presets/Components/PresetSymbolView.swift +++ b/Loop/Views/Presets/Components/PresetSymbolView.swift @@ -37,6 +37,7 @@ struct PresetSymbolView: View { .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize))) } } + .fontDesign(.monospaced) } } diff --git a/Loop/Views/Presets/Components/PresetsTrainingCard.swift b/Loop/Views/Presets/Components/PresetsTrainingCard.swift deleted file mode 100644 index c16d14d5a7..0000000000 --- a/Loop/Views/Presets/Components/PresetsTrainingCard.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// PresetsTrainingCard.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopKitUI -import SwiftUI - -struct PresetsTrainingCard: View { - - @Environment(\.appName) private var appName - - @Binding var showTraining: Bool - - var body: some View { - VStack(spacing: 24) { - VStack(spacing: 16) { - Text(String(format: NSLocalizedString("Take more control of your insulin management with presets. Presets inform %1$@ that you anticipate a temporary change in how your diabetes behaves.", comment: "Presets training card, paragraph 1"), appName)) - .multilineTextAlignment(.center) - - Text("Complete the preset training to begin creating your own custom presets.", comment: "Presets training card, paragraph 2") - .multilineTextAlignment(.center) - } - - Button("Start Preset Training") { - showTraining = true - } - .buttonStyle(ActionButtonStyle(.primary)) - } - .padding(16) - .background(RoundedRectangle(cornerRadius: 8).fill( Color(UIColor.tertiarySystemBackground))) - } -} diff --git a/Loop/Views/Presets/Components/PresetsTrainingContentContainerView.swift b/Loop/Views/Presets/Components/PresetsTrainingContentContainerView.swift deleted file mode 100644 index aae40ee05a..0000000000 --- a/Loop/Views/Presets/Components/PresetsTrainingContentContainerView.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// PresetsTrainingContentContainerView.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopKitUI -import SwiftUI - -struct PresetsTrainingContentContainerView: View { - - @ObservedObject var viewModel: PresetsTrainingViewModel - - @State var confirmEndTraining: Bool = false - @State var step: PresetsTrainingViewModel.Step - - var dismiss: () -> Void - let onComplete: () -> Void - - init( - viewModel: PresetsTrainingViewModel, - step: PresetsTrainingViewModel.Step, - dismiss: @escaping () -> Void, - onComplete: @escaping () -> Void = {} - ) { - self.viewModel = viewModel - self.step = step - self.dismiss = dismiss - self.onComplete = onComplete - } - - var body: some View { - ViewThatFits(in: .vertical) { - content(withSpacer: true) - - ScrollView { - content() - } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Close") { - confirmEndTraining = true - } - } - } - .alert("End Presets Training?", isPresented: $confirmEndTraining) { - Button("Cancel", role: .cancel) {} - - Button("End Training", role: .destructive) { - dismiss() - } - } message: { - Text("Ending now will require you to restart training before creating new presets.\n\nDo you want to end training?", comment: "End presets training alert message") - } - } - - private func content(withSpacer: Bool = false) -> some View { - VStack(alignment: .leading, spacing: 24) { - VStack(alignment: .leading, spacing: 8) { - Text(step.localizedTitle) - .font(.largeTitle.bold()) - - Divider() - .padding(.horizontal, -16) - } - - switch step { - case .creatingYourOwnPresets: CreatingYourOwnPresetsContentView() - case .howTheyWork1: HowTheyWorkContentView(step: .one) - case .howTheyWork2: HowTheyWorkContentView(step: .two) - case .presetsAndExercise1: PresetsAndExerciseContentView(step: .one) - case .presetsAndExercise2: PresetsAndExerciseContentView(step: .two) - case .presetsAndExercise3: PresetsAndExerciseContentView(step: .three) - case .presetsAndExercise4: PresetsAndExerciseContentView(step: .four) - case .presetsAndIllness1: PresetsAndIllnessContentView(step: .one) - case .presetsAndIllness2: PresetsAndIllnessContentView(step: .two) - case .presetsAndIllness3: PresetsAndIllnessContentView(step: .three) - case .presetsAndIllness4: PresetsAndIllnessContentView(step: .four) - } - - VStack(spacing: 0) { - if withSpacer { - Spacer() - } - - Button { - if step.isFinalStep { - dismiss() - onComplete() - } else { - viewModel.nextPage() - } - } label: { - Text(step.isFinalStep ? "Finish Training" : "Continue") - } - .buttonStyle(ActionButtonStyle(.primary)) - } - } - .padding(.horizontal, 16) - .padding(.bottom) - } -} diff --git a/Loop/Views/Presets/PresetsTrainingView.swift b/Loop/Views/Presets/PresetsTrainingView.swift deleted file mode 100644 index 3132e8253e..0000000000 --- a/Loop/Views/Presets/PresetsTrainingView.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// PresetsTrainingView.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopKitUI -import SwiftUI - -struct PresetsTrainingView: View { - - @Environment(\.dismiss) private var dismiss - - @StateObject var viewModel = PresetsTrainingViewModel() - - let onComplete: () -> Void - - var body: some View { - NavigationStack(path: $viewModel.navigationPath) { - PresetsTrainingContentContainerView( - viewModel: viewModel, - step: .creatingYourOwnPresets, - dismiss: { dismiss() } - ) - .navigationDestination(for: PresetsTrainingViewModel.Step.self) { step in - PresetsTrainingContentContainerView( - viewModel: viewModel, - step: step, - dismiss: { dismiss() }, - onComplete: onComplete - ) - } - } - .interactiveDismissDisabled() - } -} diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index b410cd831f..fd962f8677 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -50,12 +50,12 @@ struct PresetsView: View { case presetsHistory } - @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.settingsManager) private var settingsManager @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @Environment(\.dismiss) private var dismiss - + + @State private var trainingCompletion: PresetsTrainingCompletion = PresetsTrainingCompletion() @State private var editMode: EditMode = .inactive @State private var showingMenu: Bool = false @State private var showTraining: Bool = false @@ -65,7 +65,6 @@ struct PresetsView: View { @AppStorage("presetsSortAscending") private var presetsSortAscending: Bool = true @AppStorage("presetsSortOrder") private var selectedSortOption: PresetSortOption = .name - @AppStorage("hasCompletedPresetsTraining") private var hasCompletedTraining: Bool = false var isDescending: Bool { !presetsSortAscending } @@ -92,10 +91,6 @@ struct PresetsView: View { NavigationStack(path: $navigationPath) { ScrollView { VStack(spacing: 20) { - if !hasCompletedTraining { - PresetsTrainingCard(showTraining: $showTraining) - } - if let activePreset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == temporaryPresetsManager.activePreset?.id }) { PresetCard( @@ -112,7 +107,7 @@ struct PresetsView: View { VStack(alignment: .leading, spacing: 16) { HStack { Text("All Presets") - .font(.title2.bold()) + .font(.headline.weight(.semibold)) .accessibilityIdentifier("text_AllPresets") Spacer() @@ -128,10 +123,18 @@ struct PresetsView: View { }) { Image(systemName: "plus") } - .disabled(!hasCompletedTraining) + .disabled(!trainingCompletion.isComplete) } + .padding(.horizontal, 10) LazyVStack(spacing: 12) { + if !trainingCompletion.isComplete { + PresetsTrainingCard(trainingCompletion: trainingCompletion) + .onTapGesture { + showTraining = true + } + } + ForEach(presetsSorted) { preset in PresetCard( preset, @@ -142,6 +145,7 @@ struct PresetsView: View { .onTapGesture { activeSheet = .presetDetent(preset) } + .disabled(preset.id.hasPrefix("activity-") && trainingCompletion.completedChapters[.introduction] != true) } } } @@ -149,7 +153,8 @@ struct PresetsView: View { // Support Section VStack(alignment: .leading, spacing: 16) { Text("Support") - .font(.title2.bold()) + .font(.headline.weight(.semibold)) + .padding(.horizontal, 10) NavigationLink(value: NavigationDestination.presetsHistory) { HStack { @@ -172,7 +177,7 @@ struct PresetsView: View { .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) .frame(maxWidth: .infinity)) - if hasCompletedTraining { + if trainingCompletion.isComplete { Button { showTraining = true } label: { @@ -194,7 +199,6 @@ struct PresetsView: View { } } .padding() - .animation(.default, value: hasCompletedTraining) .animation(.default, value: temporaryPresetsManager.activeOverride) } .background(Color(UIColor.secondarySystemBackground)) @@ -207,6 +211,11 @@ struct PresetsView: View { } } } + .onAppear { + if trainingCompletion.completedChapters[.entry] != true { + showTraining = true + } + } .sheet(item: $activeSheet) { sheet in switch sheet { case .presetDetent(let preset): @@ -237,9 +246,7 @@ struct PresetsView: View { } } .sheet(isPresented: $showTraining) { - PresetsTrainingView { - hasCompletedTraining = true - } + PresetsTrainingView(trainingCompletion: trainingCompletion) } .sheet(isPresented: $presentCreateView) { CreatePresetView() diff --git a/Loop/Views/Presets/Training Content/Components/EstimatedReadTime.swift b/Loop/Views/Presets/Training Content/Components/EstimatedReadTime.swift new file mode 100644 index 0000000000..08aa8ca412 --- /dev/null +++ b/Loop/Views/Presets/Training Content/Components/EstimatedReadTime.swift @@ -0,0 +1,40 @@ +// +// EstimatedReadTime.swift +// Loop +// +// Created by Cameron Ingham on 8/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct EstimatedReadTime: View { + + private let readTimeString: String + + private let formatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute] + formatter.unitsStyle = .short + return formatter + }() + + init?(_ readTime: TimeInterval) { + guard let readTimeString = formatter.string(from: readTime) else { + return nil + } + + self.readTimeString = readTimeString + } + + var body: some View { + Text(Image(systemName: "clock")) + .foregroundStyle(Color.accentColor) + + Text(" \(readTimeString) read") + .foregroundStyle(Color.secondary) + } +} + +#Preview { + EstimatedReadTime(.minutes(3)) +} diff --git a/Loop/Views/Presets/Training Content/Components/InsetContent.swift b/Loop/Views/Presets/Training Content/Components/InsetContent.swift new file mode 100644 index 0000000000..326689b6e4 --- /dev/null +++ b/Loop/Views/Presets/Training Content/Components/InsetContent.swift @@ -0,0 +1,32 @@ +// +// InsetContent.swift +// Loop +// +// Created by Cameron Ingham on 8/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct InsetContent: View { + + let alignment: HorizontalAlignment + let content: () -> Content + + init(alignment: HorizontalAlignment = .center, @ViewBuilder content: @escaping () -> Content) { + self.alignment = alignment + self.content = content + } + + var body: some View { + VStack(alignment: alignment, spacing: 24) { + content() + .frame(maxWidth: .infinity, alignment: alignment == .leading ? .leading : .center) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.gray.quinary, lineWidth: 1) + ) + } +} diff --git a/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift b/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift new file mode 100644 index 0000000000..12aee72c7b --- /dev/null +++ b/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift @@ -0,0 +1,31 @@ +// +// PresetsTrainingCard.swift +// Loop +// +// Created by Cameron Ingham on 8/27/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct PresetsTrainingCard: View { + + let imageName: String? + + init(trainingCompletion: PresetsTrainingCompletion) { + if trainingCompletion.completedChapters[.introduction] != true { + self.imageName = "PresetsTrainingRequiredCard" + } else { + self.imageName = nil + } + } + + var body: some View { + if let imageName, let image = Image(imageName) { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + } +} diff --git a/Loop/Views/Presets/Training Content/Components/TimelineSteps.swift b/Loop/Views/Presets/Training Content/Components/TimelineSteps.swift new file mode 100644 index 0000000000..4738650e96 --- /dev/null +++ b/Loop/Views/Presets/Training Content/Components/TimelineSteps.swift @@ -0,0 +1,160 @@ +// +// TimelineSteps.swift +// Loop +// +// Created by Cameron Ingham on 8/27/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +@resultBuilder +struct TimelineBuilder { + static func buildBlock(_ components: TimelineStep...) -> [TimelineStep] { + components + } +} + +protocol TimelineStyle { + var iconSize: Double { get } + var baseIconPadding: Double { get } + var iconTint: Color { get } + var iconBackgroundColor: Color { get } + var lineWidth: Double { get } + var stepSpacing: Double { get } + var stepSeparatorColor: Color { get } + var titleFont: Font { get } + var titleColor: Color { get } + var subtitleFont: Font { get } + var subtitleColor: Color { get } +} + +extension TimelineStyle { + var iconSize: Double { 32 } + var baseIconPadding: Double { 6 } + var iconTint: Color { .accentColor } + var iconBackgroundColor: Color { iconTint.opacity(0.1) } + var lineWidth: Double { 4 } + var stepSpacing: Double { 24 } + var stepSeparatorColor: Color { iconTint.opacity(0.1) } + var titleFont: Font { .body.weight(.semibold) } + var titleColor: Color { .primary } + var subtitleFont: Font { .subheadline } + var subtitleColor: Color { .primary } +} + +struct DefaultTimelineStyle: TimelineStyle {} + +extension TimelineStyle where Self == DefaultTimelineStyle { + static var `default`: DefaultTimelineStyle { DefaultTimelineStyle() } +} + +struct TimelineStep { + let symbol: Image + let symbolInset: Double + let title: Text + let subtitle: Text + + init(symbol: Image, symbolInset: Double = 0, title: Text, subtitle: Text) { + self.symbol = symbol + self.symbolInset = symbolInset + self.title = title + self.subtitle = subtitle + } +} + +struct Timeline: View { + private let steps: [TimelineStep] + + private var style: TimelineStyle = DefaultTimelineStyle() + + init(steps: [TimelineStep]) { + self.steps = steps + } + + public init(@TimelineBuilder _ steps: () -> [TimelineStep]) { + self.steps = steps() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(steps.indices, id: \.self) { index in + let step = steps[index] + + ZStack(alignment: .leading) { + GeometryReader { proxy in + style.stepSeparatorColor + .frame(width: style.lineWidth) + .padding(.top, index == 0 ? proxy.size.height / 2 : 0) + .padding(.bottom, index == steps.count - 1 ? proxy.size.height / 2 : 0) + .padding(.leading, style.iconSize / 2 - style.lineWidth / 2) + .mask { + InverseCircleMask(diameter: style.iconSize) + .fill(Color(UIColor.systemBackground), style: FillStyle(eoFill: true)) + } + } + + HStack(spacing: 12) { + step.symbol + .resizable() + .scaledToFit() + .foregroundStyle(style.iconTint) + .padding(style.baseIconPadding + step.symbolInset) + .frame(width: style.iconSize, height: style.iconSize) + .background( + Circle() + .fill(style.iconBackgroundColor) + ) + + VStack(alignment: .leading) { + step.title + .font(style.titleFont) + .foregroundStyle(style.titleColor) + + step.subtitle + .font(style.subtitleFont) + .foregroundStyle(style.subtitleColor) + } + } + } + + if index < steps.count - 1 { + style.stepSeparatorColor + .frame(width: style.lineWidth, height: style.stepSpacing) + .padding(.leading, style.iconSize / 2 - style.lineWidth / 2) + } + } + } + } + + func style(_ style: TimelineStyle) -> Timeline { + var copy = self + copy.style = style + return copy + } +} + +private struct InverseCircleMask: Shape { + + let diameter: Double + + func path(in rect: CGRect) -> Path { + var path = Path() + + // Fills all available space + path.addRect(rect) + + // Creates a hole in the middle with the specified diameter + let hole = CGRect( + x: 0, + y: (rect.height - diameter) / 2, + width: diameter, + height: diameter + ) + + // Cuts the hole out of the path + path.addEllipse(in: hole) + + return path + } +} diff --git a/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift b/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift deleted file mode 100644 index 422adaca3e..0000000000 --- a/Loop/Views/Presets/Training Content/CreatingYourOwnPresetsContentView.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// CreatingYourOwnPresetsContentView.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopUI -import LoopKitUI -import SwiftUI - -struct CreatingYourOwnPresetsContentView: View { - - @Environment(\.appName) private var appName - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(String(format: NSLocalizedString("%1$@ comes with two provider recommended presets with your prescription:", comment: "Creating your own presets training content, paragraph 1"), appName)) - - BulletedListView { - Text(Image("Pre-Meal-symbol")).foregroundColor(.carbTintColor) + Text(" Pre-Meal") - } - } - - Text(String(format: NSLocalizedString("After reviewing this required training, you’ll be able to create your own custom presets. This is an optional feature that can enhance and personalize how the %1$@ system works for you.", comment: "Creating your own presets training content, paragraph 2"), appName)) - - Text("Using presets, you can let the system know about events that may impact your diabetes management such as exercising, sickness or hormonal changes.", comment: "Creating your own presets training content, paragraph 3") - - Text("We encourage you to work with your healthcare provider to find the right preset settings for you.", comment: "Creating your own presets training content, paragraph 4") - - VStack(alignment: .leading, spacing: 8) { - Text("Managing Presets", comment: "Creating your own presets training content, managing presets, subtitle 1") - .font(.title2.bold()) - - Text("You can manage all presets by tapping the Presets button on the toolbar.", comment: "Creating your own presets training content, managing presets, paragraph 1") - - if let image = Image("PresetsTraining1") { - image - .resizable() - .aspectRatio(contentMode: .fill) - .accessibilityHidden(true) - } - } - - VStack(alignment: .leading, spacing: 8) { - Text("When a preset is ON, you’ll notice the following indicators on the home screen:", comment: "Creating your own presets training content, managing presets, paragraph 2") - - BulletedListView { - Text("the Presets button will display with inverted colors on the toolbar", comment: "Creating your own presets training content, managing presets, paragraph 2, bullet 1") - Text("a banner will display at the top of the home screen", comment: "Creating your own presets training content, managing presets, paragraph 2, bullet 2") - Text("(if applicable) the glucose chart will show your adjusted correction range", comment: "Creating your own presets training content, managing presets, paragraph 2, bullet 3") - } - - if let image = Image("PresetsTraining2") { - image - .resizable() - .aspectRatio(contentMode: .fill) - .accessibilityHidden(true) - } - } - } -} diff --git a/Loop/Views/Presets/Training Content/HowTheyWorkContentView.swift b/Loop/Views/Presets/Training Content/HowTheyWorkContentView.swift deleted file mode 100644 index 3fd3247255..0000000000 --- a/Loop/Views/Presets/Training Content/HowTheyWorkContentView.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// HowTheyWorkContentView.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopKitUI -import SwiftUI - -struct HowTheyWorkContentView: View { - - @Environment(\.appName) private var appName - - enum StepNumber { - case one - case two - } - - let step: StepNumber - - var body: some View { - switch step { - case .one: - stepOneView - case .two: - stepTwoView - } - } - - @ViewBuilder - var stepOneView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("The most important settings when creating a preset will be those that impact your insulin delivery and safety:", comment: "How presets work training content, paragraph 1") - - BulletedListView { - Text("Overall insulin", comment: "How presets work training content, paragraph 1, bullet 1") - - Text("Correction range", comment: "How presets work training content, paragraph 1, bullet 2") - } - } - - Text("Let's do a brief review of each setting.", comment: "How presets work training content, paragraph 2") - - VStack(alignment: .leading, spacing: 16) { - Text("Adjusting Overall Insulin", comment: "How presets work training content, adjusting overall insulin, subtitle 1") - .font(.title2.bold()) - - if let image = Image("PresetsTraining3") { - image - .resizable() - .aspectRatio(contentMode: .fill) - .accessibilityHidden(true) - } - - Text("Presets allow you to specify an adjusted overall insulin value for the duration of the preset.", comment: "How presets work training content, adjusting overall insulin, paragraph 1") - } - - VStack(alignment: .leading, spacing: 8) { - Group { - Text("Overall insulin is a ", comment: "How presets work training content, adjusting overall insulin, subtitle 1, paragraph 2, part 1") + Text("metabolic", comment: "How presets work training content, adjusting overall insulin, paragraph 2, part 2").bold() + Text(" setting and should be used when your body needs more or less insulin than normal. Adjusting the overall insulin percentage will impact the following settings:", comment: "How presets work training content, adjusting overall insulin, paragraph 2, part 3") - } - - BulletedListView { - Text("Basal Rate", comment: "How presets work training content, adjusting overall insulin, paragraph 2, bullet 1") - - Text("Carb Ratio", comment: "How presets work training content, adjusting overall insulin, paragraph 2, bullet 2") - - Text("Insulin Sensitivity Factor (ISF)", comment: "How presets work training content, adjusting overall insulin, paragraph 2, bullet 3") - } - } - - VStack(alignment: .leading, spacing: 8) { - Text("Before adjusting your overall insulin, ask yourself, does my body need more or less than normal?", comment: "How presets work training content, adjusting overall insulin, paragraph 3").bold() - - if - let lower = try? AttributedString(markdown: "Setting a percentage _**lower**_ than 100% will let the system know that you are more insulin sensitive and need less insulin."), - let higher = try? AttributedString(markdown: "Setting a percentage _**higher**_ than 100% will let the system know that you are more insulin resistant and need more insulin.") - { - BulletedListView { - Text(lower) - - Text(higher) - } - } - } - } - - @ViewBuilder - var stepTwoView: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Adjusting the Correction Range", comment: "How presets work training content, adjusting correction range, subtitle 1") - .font(.title2.bold()) - - if let image = Image("PresetsTraining4") { - image - .resizable() - .aspectRatio(contentMode: .fill) - .accessibilityHidden(true) - } - } - - Text("Presets allow you to specify an adjusted correction range for the duration of the preset to help you meet your glucose goals.", comment: "How presets work training content, adjusting correction range, paragraph 1") - - Text(String(format: NSLocalizedString("It allows you to choose the specific glucose value (or range of values) that you want %1$@ to aim for in adjusting your basal insulin.", comment: "How presets work training content, adjusting correction range, paragraph 2"), appName)) - - if let string = try? AttributedString(markdown: "The correction range is a **safety** setting. Changing the correction range from your scheduled correction range may be particularly useful in reducing the risk of lows / hypoglycemia if you expect your glucose to vary more than normal.") { - Text(string) - .fixedSize(horizontal: false, vertical: true) - } - - Text("You do not have to set a new correction range for each preset.", comment: "How presets work training content, adjusting correction range, paragraph 4") - - Text("Before adjusting your correction range, ask yourself, am I more likely to go high or low during this event?", comment: "How presets work training content, adjusting correction range, paragraph 5") - .bold() - } -} diff --git a/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift b/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift deleted file mode 100644 index 58a014ce5b..0000000000 --- a/Loop/Views/Presets/Training Content/PresetsAndExerciseContentView.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// PresetsAndExerciseContentView.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopAlgorithm -import LoopKit -import LoopKitUI -import SwiftUI - -struct PresetsAndExerciseContentView: View { - - @Environment(\.appName) private var appName - @Environment(\.settingsManager) private var settingsManager - @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - - enum StepNumber { - case one - case two - case three - case four - } - - let step: StepNumber - - var body: some View { - switch step { - case .one: - stepOneView - case .two: - stepTwoView - case .three: - stepThreeView - case .four: - stepFourView - } - } - - private let lowerBound = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140) - private let upperBound = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 160) - - @ViewBuilder - var stepOneView: some View { - HStack(alignment: .top, spacing: 16) { - Image("workout") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 28, height: 28) - .foregroundColor(.glucoseTintColor) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 4) { - Text("Common Scenarios", comment: "Presets and exercise training content, callout title") - .fontWeight(.semibold) - - Text("The next few screens will walk you through two common scenarios for using presets to help you better understand how this feature may work for you.", comment: "Presets and exercise training content, callout subtitle") - .font(.subheadline) - } - } - .background(Color.accentColor.opacity(0.1).padding(.horizontal, -16).padding(.vertical, -16)) - .padding(.bottom, 24) - .padding(.top, -8) - - Text("Exercise is a common use case for setting a preset. The following is an example of how a preset can support insulin management during physical activity.", comment: "Presets and exercise training content, paragraph 1") - .bold() - - Text("Let’s imagine Omar Octopus wants to create a preset for a 30-minute walk to work. He wants the system to know he'll be active, so it should aim for a higher glucose correction range during that time.", comment: "Presets and exercise training content, paragraph 2") - - TherapySettingsExampleView( - title: NSLocalizedString("Omar's Therapy Settings", comment: "Presets and exercise training content, therapy settings example, title"), - basalRate: 0.5, - carbRatio: 13, - isf: 50 - ) - - Text("Let’s explore each of the configurable settings that will impact Omar’s insulin delivery.", comment: "Presets and exercise training content, paragraph 3") - } - - @ViewBuilder - var stepTwoView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Overall Insulin Needs", comment: "Presets and exercise training content, overall insulin, subtitle 1") - .font(.title2.bold()) - - Text("Omar asks himself, do I expect I will need more or less insulin than usual?", comment: "Presets and exercise training content, overall insulin, paragraph 1") - } - - Text("In this example, Omar’s overall insulin needs remain the same for his walk, so he will not adjust the overall insulin value.", comment: "Presets and exercise training content, overall insulin, paragraph 2") - - Text("Pay attention to your insulin needs before and after exercising, playing sports, or doing unusually hard physical labor. ", comment: "Presets and exercise training content, overall insulin, paragraph 3") - - PercentPickerView(value: 100) - - ImpactView { - if let string = try? AttributedString(markdown: "Omar’s **basal rate, carb ratio and insulin sensitivity factor (ISF)** remain unchanged.") { - Text(string) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - @ViewBuilder - var stepThreeView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Correction Range", comment: "Presets and exercise training content, correction range, subtitle") - .font(.title2.bold()) - - let range = displayGlucosePreference.format(lowerQuantity: lowerBound, higherQuantity: upperBound) - - if let string = try? AttributedString(markdown: String(format: NSLocalizedString("Omar is worried he will go low while walking so he slightly increases his correction range to **%1$@**. Increasing the lower bound of the correction range tells %2$@ to begin taking action sooner.", comment: "Presets and exercise training content, correction range, paragraph 1"), range, appName)) { - Text(string) - } - } - - Text("You may choose to set a higher temporary glucose Correction Range for physical activity where you anticipate an increased risk of low glucose.", comment: "Presets and exercise training content, correction range, paragraph 2") - - if let string = try? AttributedString(markdown: "For exercise, this range will typically be _**higher**_ than your usual correction range.") { - Text(string) - } - - AdjustedGlucoseRangeView( - lowerBound: lowerBound, - upperBound: upperBound - ) - - VStack(alignment: .leading, spacing: 8) { - Text("Optional: Scheduling a Preset", comment: "Presets and exercise training content, scheduling preset, subtitle") - .font(.title2.bold()) - - Text("Some people use this feature before exercise for a pre-programmed 1-hour, 2-hour, or indefinite length of time in an effort to decrease their risk of low glucose during exercise or other physical activity.", comment: "Presets and exercise training content, scheduling preset, paragraph 1") - } - } - - @ViewBuilder - var stepFourView: some View { - Text("Once saved, Omar’s completed preset will display in his Presets lists.", comment: "Presets and exercise training content, scheduling preset, paragraph 2") - - PresetCard( - SelectablePreset.custom( - TemporaryPreset( - symbol: "🚶", - name: NSLocalizedString("Walk to Work", comment: "Presets and exercise training content, scheduling preset, preset example, title"), - settings: TemporaryPresetSettings( - targetRange: ClosedRange( - uncheckedBounds: ( - LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140), - LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 160) - ) - ), - insulinNeedsScaleFactor: 1.0 - ), - duration: TemporaryScheduleOverride.Duration.finite(1800) - ) - ), guardrail: .correctionRange - ) - } -} diff --git a/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift b/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift deleted file mode 100644 index 77d5f8883b..0000000000 --- a/Loop/Views/Presets/Training Content/PresetsAndIllnessContentView.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// PresetsAndIllnessContentView.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopAlgorithm -import LoopKit -import LoopKitUI -import SwiftUI - -struct PresetsAndIllnessContentView: View { - - @Environment(\.appName) private var appName - @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - - enum StepNumber { - case one - case two - case three - case four - } - - let step: StepNumber - - var body: some View { - switch step { - case .one: - stepOneView - case .two: - stepTwoView - case .three: - stepThreeView - case .four: - stepFourView - } - } - - private let lowerBound = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 130) - private let upperBound = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140) - - private let basalRateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) - - @ViewBuilder - var stepOneView: some View { - Text("Physical stressors can cause your glucose to rise and sickness is a common example. Your healthcare provider can help you make a personal plan for sickness. The following is one example of using presets to manage an illness.", comment: "Presets and illness training content, paragraph 1") - .fixedSize(horizontal: false, vertical: true) - .bold() - - Text("Let’s imagine Paloma Porpoise notices her glucose is higher than usual and wants to create a preset to help keep her glucose in range while she is sick.", comment: "Presets and illness training content, paragraph 2") - .fixedSize(horizontal: false, vertical: true) - - TherapySettingsExampleView( - title: NSLocalizedString("Paloma’s Therapy Settings", comment: "Presets and illness training content, therapy settings example, title"), - basalRate: 0.5, - carbRatio: 13, - isf: 50 - ) - } - - @ViewBuilder - var stepTwoView: some View { - Text("Let’s explore each of the configurable settings that will impact Paloma’s insulin delivery.", comment: "Presets and illness training content, paragraph 2") - - VStack(alignment: .leading, spacing: 8) { - Text("Overall Insulin Needs", comment: "Presets and illness training content, overall insulin, subtitle") - .font(.title2.bold()) - - if let string = try? AttributedString(markdown: String(format: NSLocalizedString("Paloma wants to tell the %1$@ system that she needs more insulin than usual since her glucose has been elevated. She will adjust her overall insulin **above** her scheduled delivery.", comment: "Presets and illness training content, overall insulin, paragraph 1"), appName)) { - Text(string) - .fixedSize(horizontal: false, vertical: true) - } - } - - PercentPickerView(value: .constant(110)) - - ImpactView { - if let string = try? AttributedString(markdown: "**Basal** Rate was \(basalRateFormatter.string(from: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 0.5)) ?? "0") and will be \(basalRateFormatter.string(from: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 0.6)) ?? "0")\n**Carb Ratio** was 13 g and will be 11.7 g\n**ISF** was \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 50))) and will be \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 45)))", options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { - Text(string) - .font(.subheadline) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - @ViewBuilder - var stepThreeView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Correction Range", comment: "Presets and illness training content, correction range, subtitle") - .font(.title2.bold()) - - Text("Paloma’s normal correction range is set to \(displayGlucosePreference.format(lowerQuantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 105), higherQuantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 110))).", comment: "Presets and illness training content, correction range, paragraph 1") - } - - if let string = try? AttributedString(markdown: "In this scenario, Paloma will increase her correction range to **\(displayGlucosePreference.format(lowerQuantity: lowerBound, higherQuantity: upperBound))** to prevent drops due to eating less or not absorbing what she eats while sick.") { - Text(string) - } - - AdjustedGlucoseRangeView( - lowerBound: lowerBound, - upperBound: upperBound - ) - - VStack(alignment: .leading, spacing: 8) { - Text("Duration", comment: "Presets and illness training content, duration, subtitle") - .font(.title2.bold()) - - Text(String(format: NSLocalizedString("Paloma will set her preset duration to “Until I Turn Off” since she is not sure when her illness will pass. %1$@ will remind her every 8 hours that the preset is running. ", comment: "Presets and illness training content, duration, paragraph 1"), appName)) - .fixedSize(horizontal: false, vertical: true) - } - } - - @ViewBuilder - var stepFourView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Impact on Bolusing", comment: "Presets and illness training content, impact on bolusing, subtitle") - .font(.title2.bold()) - - Text("Let’s imagine Paloma decides to eat a meal of 31g carbs. How will her preset impact her bolus recommendation?", comment: "Presets and illness training content, impact on bolusing, paragraph 1") - .fixedSize(horizontal: false, vertical: true) - } - - Text("While a preset is ON, the modified basal rates, carb ratio and insulin sensitivity factor (ISF) are applied for every bolus.", comment: "Presets and illness training content, impact on bolusing, paragraph 2") - - ImpactView { - VStack(alignment: .leading, spacing: 16) { - Text("Paloma’s bolus recommendation for 31g of carbs will increase due to her preset.", comment: "Presets and illness training content, impact on bolusing, impact title") - .font(.subheadline) - .fixedSize(horizontal: false, vertical: true) - - HStack(spacing: 16) { - Group { Text("3.9").font(.title.weight(.bold)) + Text(" U").font(.title2) } - - Text(Image(systemName: "arrow.forward")) - .font(.title.weight(.medium)) - - Group { Text("4.3").font(.title.weight(.bold)) + Text(" U").font(.title2) } - } - } - } - } -} diff --git a/Loop/Views/Presets/Training Content/PresetsTraining.swift b/Loop/Views/Presets/Training Content/PresetsTraining.swift new file mode 100644 index 0000000000..632b95e3e8 --- /dev/null +++ b/Loop/Views/Presets/Training Content/PresetsTraining.swift @@ -0,0 +1,212 @@ +// +// PresetsTraining.swift +// Loop +// +// Created by Cameron Ingham on 8/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI + +@Observable +class PresetsTrainingCompletion { + var completedChapters: [PresetsTraining.Chapter: Bool] { + get { + guard let rawValue = UserDefaults.standard.string(forKey: "completedPresetTrainingChapters") else { + return .default + } + + return [PresetsTraining.Chapter: Bool](rawValue: rawValue) ?? .default + } + set { + withMutation(keyPath: \.completedChapters) { + UserDefaults.standard.setValue(newValue.rawValue, forKey: "completedPresetTrainingChapters") + } + } + } + + var isComplete: Bool { + completedChapters.values.allSatisfy({ $0 }) + } +} + +@Observable +public class PresetsTraining { + public enum Chapter: CaseIterable, Hashable, Sendable, Codable { + case entry + case introduction + + var firstStep: Step { + switch self { + case .entry: .entryPoint + case .introduction: .tier1(.introduction(.introduction)) + } + } + } + + enum Step: Hashable, Sendable { + case entryPoint + + enum Tier1Chapter: Hashable, Sendable { + enum Introduction: CaseIterable, Hashable, Sendable { + case introduction + case exercisingWithLoop + case timingYourPresets + case safeGlucoseRanges + case performanceHistory + case complete + } + + case introduction(Introduction) + } + + case tier1(Tier1Chapter) + + func title(appName: String) -> String { + switch self { + case .entryPoint: + return NSLocalizedString("Presets Training", comment: "") + case .tier1(let tier1Chapter): + switch tier1Chapter { + case .introduction(let introduction): + switch introduction { + case .introduction: + return NSLocalizedString("Part 1: Introduction to Presets", comment: "") + case .exercisingWithLoop: + return String(format: NSLocalizedString("Exercising with %1$@", comment: ""), appName) + case .timingYourPresets: + return NSLocalizedString("Timing Your Presets for Exercise", comment: "") + case .safeGlucoseRanges: + return NSLocalizedString("Safe Glucose Ranges for Exercise", comment: "") + case .performanceHistory: + return NSLocalizedString("Performance History", comment: "") + case .complete: + return NSLocalizedString("Part 1: Complete", comment: "") + } + } + } + } + + func previous(startingFrom: Chapter) -> Step? { + switch self { + case .entryPoint: + return nil + case .tier1(let tier1Chapter): + switch tier1Chapter { + case .introduction(let introduction): + switch introduction { + case .introduction: + guard chapter != startingFrom else { return nil } + return .entryPoint + case .exercisingWithLoop: return .tier1(.introduction(.introduction)) + case .timingYourPresets: return .tier1(.introduction(.exercisingWithLoop)) + case .safeGlucoseRanges: return .tier1(.introduction(.timingYourPresets)) + case .performanceHistory: return .tier1(.introduction(.safeGlucoseRanges)) + case .complete: return .tier1(.introduction(.performanceHistory)) + } + } + } + } + + func next() -> (Step?, completedChapter: Chapter?) { + switch self { + case .entryPoint: return (.tier1(.introduction(.introduction)), .entry) + case .tier1(let tier1Chapter): + switch tier1Chapter { + case .introduction(let introduction): + switch introduction { + case .introduction: return (.tier1(.introduction(.exercisingWithLoop)), nil) + case .exercisingWithLoop: return (.tier1(.introduction(.timingYourPresets)), nil) + case .timingYourPresets: return (.tier1(.introduction(.safeGlucoseRanges)), nil) + case .safeGlucoseRanges: return (.tier1(.introduction(.performanceHistory)), nil) + case .performanceHistory: return (.tier1(.introduction(.complete)), nil) + case .complete: return (nil, .introduction) + } + } + } + } + + var chapter: Chapter { + switch self { + case .entryPoint: + return .entry + case .tier1: + return .introduction + } + } + } + + var navigationPath: [Step] + + var currentStep: Step { + navigationPath.last ?? startingAt.firstStep + } + + private(set) var startingAt: Chapter = .entry + + let trainingCompletion: PresetsTrainingCompletion + + init( + navigationPath: [Step] = [], + startingAt: Chapter? = nil, + trainingCompletion: PresetsTrainingCompletion = PresetsTrainingCompletion() + ) { + self.navigationPath = navigationPath + self.trainingCompletion = trainingCompletion + + if let startingAt { + self.startingAt = startingAt + } else { + if trainingCompletion.completedChapters[.entry] != true { + self.startingAt = .entry + } else if trainingCompletion.completedChapters[.introduction] != true { + self.startingAt = .introduction + } else { + self.startingAt = .entry + } + } + } + + func next() { + let (next, completedChapter) = currentStep.next() + if let next { + navigationPath.append(next) + } + + if let completedChapter, trainingCompletion.completedChapters[completedChapter] != true { + trainingCompletion.completedChapters[completedChapter] = true + } + } +} + +extension Dictionary: @retroactive RawRepresentable where Key == PresetsTraining.Chapter, Value == Bool { + public init?(rawValue: String) { + guard + let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode([Key: Value].self, from: data) + else { + return nil + } + + self = result + } + + public var rawValue: String { + guard + let data = try? JSONEncoder().encode(self), + let result = String(data: data, encoding: .utf8) + else { + return "{}" + } + + return result + } + + public static var `default`: Self = PresetsTraining.Chapter.allCases + .reduce([:]) { partialResult, chapter in + var partial = partialResult + partial[chapter] = false + return partial + } +} diff --git a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift new file mode 100644 index 0000000000..9299c39e0d --- /dev/null +++ b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift @@ -0,0 +1,253 @@ +// +// PresetsTrainingContent.swift +// Loop +// +// Created by Cameron Ingham on 8/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +extension PresetsTraining { + enum CTA { + case start + case `continue` + case closeOrContinue(_ to: String, chapter: Chapter) + } +} + +protocol PresetsTrainingContent { + associatedtype B: View + func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette) -> B + var cta: PresetsTraining.CTA? { get } +} + +extension PresetsTraining.Step: PresetsTrainingContent { + @ViewBuilder + func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette) -> some View { + switch self { + case .entryPoint: + if let image = Image("PresetsTrainingEntryHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + EstimatedReadTime(.minutes(3)) + + Text("Presets allow you temporarily adjust your settings for events like meals, exercise, illness, or hormonal changes that may affect your diabetes management.") + + VStack(alignment: .leading) { + Text("We'll walk you through the following:") + + BulletedListView { + Text("How Presets Work") + Text("Using pre-configured presets") + Text("Timing your presets for exercise") + Text("Safe Glucose Ranges for Exercise") + } + .padding(.leading, 8) + } + + case .tier1(let tier1Chapter): + switch tier1Chapter { + case .introduction(let introduction): + switch introduction { + case .introduction: + if let image = Image("PresetsTrainingEntryHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + EstimatedReadTime(.minutes(3)) + + VStack(alignment: .leading) { + Text("With a preset, you can:") + + BulletedListView { + Text("Adjust your overall insulin needs") + Text("Set an adjusted correction range") + Text("Choose a duration") + Text("Schedule a preset in advance") + } + .padding(.leading, 8) + } + + VStack(alignment: .leading) { + Text("Adjusting Overall Insulin Needs") + .font(.title2.bold()) + + Text("Overall insulin should be adjusted when your body needs more or less insulin than normal.") + } + + VStack(alignment: .leading) { + Text("Adjusting Correction Range") + .font(.title2.bold()) + + Text("The correction range is a safety setting. Adjusting it can help reduce the risk of low glucose if you expect unusual changes.") + } + + case .exercisingWithLoop: + if let image = Image("PresetsTrainingExercisingWithLoopHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Exercise and physical activity are common uses for presets.") + + Text("\(appName) has a few preset options designed to help with your insulin management. We designed these for various types of physical activities.") + + ActivityPreset.bulletList(full: true) + .padding(.leading, 8) + + Text("These presets are a starting point to help manage your glucose. You may need to work with your healthcare provider to edit them to meet your personal diabetes needs.") + + case .timingYourPresets: + Text("\(appName) suggests starting a preset for exercise at least 1 hour ahead of time. Keep it on until you finish your activity.") + + Text("If you forget to turn on a preset, turn it on as soon as you remember and keep it on until the activity ends.") + + InsetContent { + Timeline { + TimelineStep( + symbol: Image(systemName: "clock"), + title: Text("1 Hour Before"), + subtitle: Text("Enable your preset") + ) + + TimelineStep( + symbol: Image(systemName: "figure.run"), + title: Text("During Activity"), + subtitle: Text("Keep preset on throughout your exercise") + ) + + TimelineStep( + symbol: Image(systemName: "checkmark"), + symbolInset: 2, + title: Text("Activity Ends"), + subtitle: Text("Turn off preset when you finish exercising") + ) + } + } + + Text("You can plan ahead and schedule presets to start at a certain date and time. The app will send you a reminder and ask if you'd like to start the preset.") + + case .safeGlucoseRanges: + Text("Before starting exercise, make sure to check your glucose.") + + Text("Aim for your glucose to be between \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), includeUnit: false)) and \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: true)) before exercising. Based on current research, this can help prevent high and low levels during or after your workout.") + + InsetContent { + Text("Safe Starting Glucose Range") + .bold() + + Group { + Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: false)) ") + .font(.system(size: UIFontMetrics.default.scaledValue(for: 32)).weight(.heavy)) + + Text(displayGlucosePreference.unit.localizedShortUnitString) + } + .foregroundStyle(colorPalette.carbTintColor) + + Text("\(Image(systemName: "exclamationmark.circle")) Consider a small snack to prevent lows") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Callout(.caution, title: Text("Starting a preset, especially one decreasing insulin, when your glucose is above \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: true)) may reduce its effectiveness and impact your results.")) + .padding(.horizontal, -16) + + Text("Always check your glucose before, during, and after any activity to ensure safe and optimal outcomes.") + + case .performanceHistory: + if let image = Image("PresetsTrainingPerformanceHistoryHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Performance History gives you a clear picture of how each preset helped manage your glucose.") + + Text("You can quickly review a summary of key data during the preset and for the six hours that follow to understand the full impact of the preset’s settings.") + + Text("To get started, tap Presets, then Performance History, and select the preset you want to review.") + + Text("Performance history is available for up to seven days.") + + case .complete: + Text("You can now use the following presets:") + + ActivityPreset.bulletList(full: false) + + Text("Complete Part 2 to enable preset editing and creation.") + } + } + } + } + + var cta: PresetsTraining.CTA? { + switch self { + case .entryPoint: .start + case .tier1(let tier1Chapter): + switch tier1Chapter { + case .introduction(let introduction): + switch introduction { + case .introduction: .continue + case .exercisingWithLoop: .continue + case .timingYourPresets: .continue + case .safeGlucoseRanges: .continue + case .performanceHistory: .continue + case .complete: .closeOrContinue("Step 2", chapter: .introduction) + } + } + } + } +} + +extension PresetsTraining.Chapter: PresetsTrainingContent { + @ViewBuilder + func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette) -> some View { + firstStep.content(appName: appName, displayGlucosePreference: displayGlucosePreference, colorPalette: colorPalette) + } + + var cta: PresetsTraining.CTA? { + firstStep.cta + } +} + +extension ActivityPreset.ActivityType { + func bulletItem(full: Bool) -> Text { + if full { + return Text(Image(systemName: systemImageName)) + .fontDesign(.monospaced) + + Text(" \(name) · ") + .fontWeight(.semibold) + + Text("\(defaultInsulinNeedsScaleFactor.formatted(.percent)) of insulin") + } else { + return Text(Image(systemName: systemImageName)) + .fontDesign(.monospaced) + + Text(" \(name)") + .fontWeight(.semibold) + } + } +} + +extension ActivityPreset { + @ViewBuilder + static func bulletList(full: Bool) -> some View { + BulletedListView { + ActivityPreset.ActivityType.jogging.bulletItem(full: full) + ActivityPreset.ActivityType.walking.bulletItem(full: full) + ActivityPreset.ActivityType.biking.bulletItem(full: full) + ActivityPreset.ActivityType.strengthTraining.bulletItem(full: full) + } + } +} diff --git a/Loop/Views/Presets/Training Content/PresetsTrainingView.swift b/Loop/Views/Presets/Training Content/PresetsTrainingView.swift new file mode 100644 index 0000000000..504d8a901b --- /dev/null +++ b/Loop/Views/Presets/Training Content/PresetsTrainingView.swift @@ -0,0 +1,132 @@ +// +// PresetsTrainingView.swift +// Loop +// +// Created by Cameron Ingham on 8/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import SwiftUI + +struct PresetsTrainingView: View { + + @Environment(\.appName) private var appName + @Environment(\.colorPalette) private var colorPalette + @Environment(\.dismiss) private var dismiss + + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + + @Bindable private var training: PresetsTraining + + @State private var confirmDismiss: Bool = false + + init(trainingCompletion: PresetsTrainingCompletion) { + self.training = PresetsTraining(trainingCompletion: trainingCompletion) + } + + @ViewBuilder + private var closeButton: some View { + Button("Close") { + if training.trainingCompletion.isComplete { + close() + } else if training.trainingCompletion.completedChapters[.entry] != true { + training.trainingCompletion.completedChapters[.entry] = true + close() + } else { + confirmDismiss = true + } + } + } + + var body: some View { + NavigationStack(path: $training.navigationPath) { + stepView(training.startingAt.firstStep) + .navigationDestination(for: PresetsTraining.Step.self) { step in + stepView(step) + } + } + .environment(training) + .interactiveDismissDisabled(!training.trainingCompletion.isComplete) + } + + private func close() { + dismiss() + } + + @ViewBuilder + private func stepView(_ step: PresetsTraining.Step) -> some View { + GeometryReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + VStack(spacing: 8) { + Text(step.title(appName: appName)) + .font(.largeTitle.bold()) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 16) + + Divider() + } + .padding(.bottom, 24) + + step.content(appName: appName, displayGlucosePreference: displayGlucosePreference, colorPalette: colorPalette) + .padding(.bottom, 24) + .padding(.horizontal, 16) + + if let cta = step.cta { + Spacer(minLength: 0) + + Group { + switch cta { + case .start: + Button("Start Required Training") { + training.next() + } + .buttonStyle(ActionButtonStyle()) + case .continue: + Button("Continue") { + training.next() + } + .buttonStyle(ActionButtonStyle()) + case .closeOrContinue(let continueTo, let chapter): + VStack(spacing: 12) { + Button("Close Training") { + if training.trainingCompletion.completedChapters[chapter] != true { + training.trainingCompletion.completedChapters[chapter] = true + } + + close() + } + .buttonStyle(ActionButtonStyle(.secondary)) + + Button("Continue to \(continueTo)") { + training.next() + } + .buttonStyle(ActionButtonStyle()) + } + } + } + .padding(.horizontal, 16) + } + } + .frame(maxWidth: .infinity) + .frame(minHeight: proxy.size.height, alignment: .top) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + closeButton + } + } + .alert(isPresented: $confirmDismiss) { + Alert( + title: Text("End Training?"), + message: Text("You’ll have to restart this section and some features will be disabled until you complete the training."), + primaryButton: .cancel(), + secondaryButton: .destructive(Text("End"), action: { close() }) + ) + } + } +} From 887064fef8aaabb6e1daf715c508bf860cdfbe66 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 2 Sep 2025 12:56:04 -0500 Subject: [PATCH 283/421] Do not truncate non-premeal overrides when meal bolusing (#823) --- Loop/Managers/LoopDataManager.swift | 13 +++++++-- Loop/View Models/BolusEntryViewModel.swift | 8 +++--- .../InsulinDeliveryEventDetailsView.swift | 28 +++++++++++++++++-- .../ViewModels/BolusEntryViewModelTests.swift | 2 +- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 83587e8538..a6e4060455 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -668,10 +668,19 @@ final class LoopDataManager: ObservableObject { manualGlucoseSample: NewGlucoseSample? = nil, potentialCarbEntry: NewCarbEntry? = nil, originalCarbEntry: StoredCarbEntry? = nil, - ignoringOverride: Bool = false + truncatingActiveOverride: Bool = false ) async throws -> ManualBolusRecommendation? { - var input = try await self.fetchData(for: now(), presumePresetEndingNow: ignoringOverride || potentialCarbEntry != nil) + var endingPremealOverride = false + + if potentialCarbEntry != nil, + let activeOverride = temporaryPresetsManager.activeOverride, + activeOverride.context == .preMeal + { + endingPremealOverride = true + } + + var input = try await self.fetchData(for: now(), presumePresetEndingNow: truncatingActiveOverride || endingPremealOverride) .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseSample) .removingCarbEntry(carbEntry: originalCarbEntry) .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 87e55e82ff..7712be10e4 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -41,7 +41,7 @@ protocol BolusEntryViewModelDelegate: AnyObject { manualGlucoseSample: NewGlucoseSample?, potentialCarbEntry: NewCarbEntry?, originalCarbEntry: StoredCarbEntry?, - ignoringOverride: Bool + truncatingActiveOverride: Bool ) async throws -> ManualBolusRecommendation? func generatePrediction(input: StoredDataAlgorithmInput) throws -> [PredictedGlucoseValue] @@ -590,7 +590,7 @@ final class BolusEntryViewModel: ObservableObject { recommendation = try await computeBolusRecommendation() if let recommendation, deliveryDelegate != nil { - if let originalAmount = try await computeBolusRecommendation(ignoringOverride: true)?.amount { + if let originalAmount = try await computeBolusRecommendation(truncatingActiveOverride: true)?.amount { presetEffectedRecommendation = PresetEffectedRecommendation(originalAmount: originalAmount, recommendedAmount: recommendation.amount) } recommendedBolus = LoopQuantity(unit: .internationalUnit, doubleValue: recommendation.amount) @@ -641,7 +641,7 @@ final class BolusEntryViewModel: ObservableObject { } } - private func computeBolusRecommendation(ignoringOverride: Bool = false) async throws -> ManualBolusRecommendation? { + private func computeBolusRecommendation(truncatingActiveOverride: Bool = false) async throws -> ManualBolusRecommendation? { guard let delegate else { return nil } @@ -650,7 +650,7 @@ final class BolusEntryViewModel: ObservableObject { manualGlucoseSample: manualGlucoseSample, potentialCarbEntry: potentialCarbEntry, originalCarbEntry: originalCarbEntry, - ignoringOverride: ignoringOverride + truncatingActiveOverride: truncatingActiveOverride ) } diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift index ba5b2d22df..8e22e1b71f 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift @@ -53,9 +53,13 @@ struct InsulinDeliveryEventDetailsView: View { } var startTimeValue: String? { - doseEntry.startDate.formatted(date: .omitted, time: .shortened) + doseEntry.startDate.formatted(date: .omitted, time: .standard) } - + + var endTimeValue: String? { + doseEntry.endDate.formatted(date: .omitted, time: .standard) + } + var durationValue: String? { durationFormatter.unitsStyle = .abbreviated @@ -93,7 +97,25 @@ struct InsulinDeliveryEventDetailsView: View { Text(startTimeValue) } } - + + if let endTimeValue { + VStack(alignment: .leading) { + Text("End Time") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(endTimeValue) + } + } + + VStack(alignment: .leading) { + Text("Mutable") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(doseEntry.isMutable ? "Yes" : "No") + } + switch pumpEventType { case .basal, .bolus: if let durationValue { diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 33b9bc5f3d..26d7de9ef4 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -947,7 +947,7 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { manualGlucoseSample: NewGlucoseSample?, potentialCarbEntry: NewCarbEntry?, originalCarbEntry: StoredCarbEntry?, - ignoringOverride: Bool + truncatingActiveOverride: Bool ) async throws -> ManualBolusRecommendation? { manualGlucoseSampleForBolusRecommendation = manualGlucoseSample From 33e3a293d866e5c7bd5bbd031a85962f9650da20 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 10 Sep 2025 10:30:29 -0700 Subject: [PATCH 284/421] [LOOP-5432] Active Preset Banner on CarbEntryView (#825) --- Loop/View Models/CarbEntryViewModel.swift | 12 +++++------- Loop/Views/BolusEntryView.swift | 3 ++- Loop/Views/CarbEntryView.swift | 9 +++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 2558654848..d53e501b2d 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -40,15 +40,12 @@ final class CarbEntryViewModel: ObservableObject { switch self { case .entryIsMissedMeal: return 1 - case .overrideInProgress: - return 2 case .glucoseRisingRapidly: return 3 } } case entryIsMissedMeal - case overrideInProgress case glucoseRisingRapidly } @@ -344,18 +341,19 @@ final class CarbEntryViewModel: ObservableObject { .store(in: &cancellables) } + @Published var currentOverride: TemporaryScheduleOverride? + private func checkIfOverrideEnabled() { guard let delegate else { return } if delegate.isScheduleOverrideActive(at: Date()), - let overrideSettings = delegate.scheduleOverride?.settings, - overrideSettings.effectiveInsulinNeedsScaleFactor != 1.0 + let override = delegate.scheduleOverride ?? delegate.preMealOverride { - self.warnings.insert(.overrideInProgress) + currentOverride = override } else { - self.warnings.remove(.overrideInProgress) + currentOverride = nil } } diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index de7eda1cf4..f7677fd9ad 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -132,7 +132,8 @@ struct BolusEntryView: View { } header: { if let scheduleOverride = viewModel.scheduleOverride ?? viewModel.preMealOverride { ActivePresetBanner(override: scheduleOverride) - .padding(.horizontal, -32) + .listRowInsets(EdgeInsets(top: 30, leading: 0, bottom: 12, trailing: 0)) + .padding(.horizontal, -20) .padding(.bottom, 8) .textCase(nil) } diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift index 05e62f2aff..23900d8fa9 100644 --- a/Loop/Views/CarbEntryView.swift +++ b/Loop/Views/CarbEntryView.swift @@ -69,6 +69,11 @@ struct CarbEntryView: View, HorizontalSizeClassOverride { .edgesIgnoringSafeArea(.all) ScrollView { + if let currentOverride = viewModel.currentOverride { + ActivePresetBanner(override: currentOverride) + .padding(.bottom, 8) + } + warningsCard mainCard @@ -192,8 +197,6 @@ extension CarbEntryView { switch warning { case .entryIsMissedMeal: return .critical - case .overrideInProgress: - return .warning case .glucoseRisingRapidly: return .critical } @@ -203,8 +206,6 @@ extension CarbEntryView { switch warning { case .entryIsMissedMeal: return NSLocalizedString("Loop has detected an missed meal and estimated its size. Edit the carb amount to match the amount of any carbs you may have eaten.", comment: "Warning displayed when user is adding a meal from an missed meal notification") - case .overrideInProgress: - return NSLocalizedString("An active override is modifying your carb ratio and insulin sensitivity. If you don't want this to affect your bolus calculation and projected glucose, consider turning off the override.", comment: "Warning to ensure the carb entry is accurate during an override") case .glucoseRisingRapidly: return NSLocalizedString("Your glucose is rapidly rising. Check that any carbs you've eaten were logged. If you logged carbs, check that the time you entered lines up with when you started eating.", comment: "Warning to ensure the carb entry is accurate") } From 410f2cec3cd1c952dc5a01a320a109ee5f0feab7 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 10 Sep 2025 10:31:01 -0700 Subject: [PATCH 285/421] [LOOP-5418] Presets Training Part 2 (#824) --- Loop.xcodeproj/project.pbxproj | 22 +- .../Views/Presets/Components/ImpactView.swift | 32 - .../Components/InsulinScaleAdjustView.swift | 2 +- .../TherapySettingsExampleView.swift | 62 -- Loop/Views/Presets/PresetsView.swift | 27 +- .../Components/CommonUseStep.swift | 89 ++ .../Components/InsetContent.swift | 16 +- .../Components/IntensitySlider.swift | 281 +++++ .../Components/PlayMediaButton.swift | 63 ++ .../Components/PresetsTrainingCard.swift | 4 + .../TherapySettingsExampleView.swift | 177 ++++ .../Components/TintedContent.swift | 84 ++ .../Training Content/PresetsTraining.swift | 323 +++++- .../PresetsTrainingContent.swift | 999 +++++++++++++++++- .../PresetsTrainingView.swift | 67 +- 15 files changed, 2084 insertions(+), 164 deletions(-) delete mode 100644 Loop/Views/Presets/Components/ImpactView.swift delete mode 100644 Loop/Views/Presets/Components/TherapySettingsExampleView.swift create mode 100644 Loop/Views/Presets/Training Content/Components/CommonUseStep.swift create mode 100644 Loop/Views/Presets/Training Content/Components/IntensitySlider.swift create mode 100644 Loop/Views/Presets/Training Content/Components/PlayMediaButton.swift create mode 100644 Loop/Views/Presets/Training Content/Components/TherapySettingsExampleView.swift create mode 100644 Loop/Views/Presets/Training Content/Components/TintedContent.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 73eb5168ba..b92592e2ad 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -243,11 +243,13 @@ 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 */; }; + 840BB39D2E67796D00537FFB /* CommonUseStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840BB39C2E67796D00537FFB /* CommonUseStep.swift */; }; 84213C752D932F0F00642E78 /* InsulinDeliveryLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */; }; 84213C892D94941000642E78 /* InsulinDeliveryLogEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */; }; 84213C8B2D94948900642E78 /* InsulinDeliveryOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */; }; 84213C8D2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */; }; 843F32EA2E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */; }; + 8443566B2E6F8325000EBD1A /* TintedContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8443566A2E6F8325000EBD1A /* TintedContent.swift */; }; 84475DF02E5E644D00FC5E7C /* PresetsTraining.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475DEF2E5E644D00FC5E7C /* PresetsTraining.swift */; }; 84475DF22E5E64A700FC5E7C /* PresetsTrainingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475DF12E5E64A700FC5E7C /* PresetsTrainingContent.swift */; }; 84475E0A2E5ECD4F00FC5E7C /* EstimatedReadTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475E092E5ECD4F00FC5E7C /* EstimatedReadTime.swift */; }; @@ -265,8 +267,9 @@ 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */; }; 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E62A4A4DEF000B658B /* PumpView.swift */; }; 84BC5BDF2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BC5BDE2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift */; }; - 84C170ED2CCA362A0098E52F /* ImpactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EC2CCA361F0098E52F /* ImpactView.swift */; }; 84C170EF2CCA37680098E52F /* PresetCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EE2CCA37680098E52F /* PresetCard.swift */; }; + 84C77CF22E6A054B00839FEC /* PlayMediaButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C77CF12E6A054B00839FEC /* PlayMediaButton.swift */; }; + 84C77CF42E6A17FB00839FEC /* IntensitySlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C77CF32E6A17FB00839FEC /* IntensitySlider.swift */; }; 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A62D09053A00CB271F /* StatusTableView.swift */; }; 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A82D09800700CB271F /* PresetDetentView.swift */; }; 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; @@ -1154,11 +1157,13 @@ 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; }; + 840BB39C2E67796D00537FFB /* CommonUseStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonUseStep.swift; sourceTree = ""; }; 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLog.swift; sourceTree = ""; }; 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogEvent.swift; sourceTree = ""; }; 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryOverview.swift; sourceTree = ""; }; 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogEventRow.swift; sourceTree = ""; }; 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogViewModel.swift; sourceTree = ""; }; + 8443566A2E6F8325000EBD1A /* TintedContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TintedContent.swift; sourceTree = ""; }; 84475DEF2E5E644D00FC5E7C /* PresetsTraining.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTraining.swift; sourceTree = ""; }; 84475DF12E5E64A700FC5E7C /* PresetsTrainingContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingContent.swift; sourceTree = ""; }; 84475E092E5ECD4F00FC5E7C /* EstimatedReadTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedReadTime.swift; sourceTree = ""; }; @@ -1176,8 +1181,9 @@ 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelineProvider.swift; sourceTree = ""; }; 84AA81E62A4A4DEF000B658B /* PumpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpView.swift; sourceTree = ""; }; 84BC5BDE2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryEventDetailsView.swift; sourceTree = ""; }; - 84C170EC2CCA361F0098E52F /* ImpactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImpactView.swift; sourceTree = ""; }; 84C170EE2CCA37680098E52F /* PresetCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetCard.swift; sourceTree = ""; }; + 84C77CF12E6A054B00839FEC /* PlayMediaButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMediaButton.swift; sourceTree = ""; }; + 84C77CF32E6A17FB00839FEC /* IntensitySlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntensitySlider.swift; sourceTree = ""; }; 84D1F1A62D09053A00CB271F /* StatusTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableView.swift; sourceTree = ""; }; 84D1F1A82D09800700CB271F /* PresetDetentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDetentView.swift; sourceTree = ""; }; 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.swift; sourceTree = ""; }; @@ -2523,6 +2529,11 @@ 84475E0B2E5EDF1800FC5E7C /* InsetContent.swift */, 84475E0D2E5F00B900FC5E7C /* TimelineSteps.swift */, 84475E0F2E5F870800FC5E7C /* PresetsTrainingCard.swift */, + 840BB39C2E67796D00537FFB /* CommonUseStep.swift */, + 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */, + 84C77CF12E6A054B00839FEC /* PlayMediaButton.swift */, + 84C77CF32E6A17FB00839FEC /* IntensitySlider.swift */, + 8443566A2E6F8325000EBD1A /* TintedContent.swift */, ); path = Components; sourceTree = ""; @@ -2622,10 +2633,8 @@ C10509642D7B6B0000118A37 /* CorrectionRangePreview.swift */, C10509602D7B3DEA00118A37 /* CardSectionScrollView.swift */, C105095C2D7A1DB300118A37 /* CardSection.swift */, - 84C170EC2CCA361F0098E52F /* ImpactView.swift */, 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */, 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */, - 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */, 84C170EE2CCA37680098E52F /* PresetCard.swift */, 84D1F1A82D09800700CB271F /* PresetDetentView.swift */, 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */, @@ -3642,6 +3651,7 @@ C16971F92D1231B5001B7DF6 /* PresetRangeEditor.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, + 84C77CF22E6A054B00839FEC /* PlayMediaButton.swift in Sources */, 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, 849E06232E5E41BA00A71614 /* PresetsTrainingView.swift in Sources */, 84F20DFD2D0B9C3A0089DF02 /* EditPresetDurationView.swift in Sources */, @@ -3667,7 +3677,6 @@ 84213C892D94941000642E78 /* InsulinDeliveryLogEvent.swift in Sources */, B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */, A96DAC242838325900D94E38 /* DiagnosticLog.swift in Sources */, - 84C170ED2CCA362A0098E52F /* ImpactView.swift in Sources */, A9CBE45A248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift in Sources */, A9C62D8A2331703100535612 /* ServicesManager.swift in Sources */, C11445B22DA98A3400034864 /* CorrectionRangeInformationView.swift in Sources */, @@ -3708,6 +3717,7 @@ C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */, E98A55EF24EDD6E60008715D /* DosingDecisionStoreProtocol.swift in Sources */, B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */, + 840BB39D2E67796D00537FFB /* CommonUseStep.swift in Sources */, E9C00EF524C623EF00628F35 /* LoopSettings+Loop.swift in Sources */, 4389916B1E91B689000EEF90 /* ChartSettings+Loop.swift in Sources */, C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, @@ -3787,7 +3797,9 @@ A9CBE45C248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift in Sources */, 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */, + 84C77CF42E6A17FB00839FEC /* IntensitySlider.swift in Sources */, 84DEF35D2E566757006126F9 /* PresetSymbolView.swift in Sources */, + 8443566B2E6F8325000EBD1A /* TintedContent.swift in Sources */, C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */, 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */, 439A7944211FE22F0041B75F /* NSUserActivity.swift in Sources */, diff --git a/Loop/Views/Presets/Components/ImpactView.swift b/Loop/Views/Presets/Components/ImpactView.swift deleted file mode 100644 index 3dfcb3a27d..0000000000 --- a/Loop/Views/Presets/Components/ImpactView.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// ImpactView.swift -// Loop -// -// Created by Cameron Ingham on 10/24/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -public struct ImpactView: View { - - @ViewBuilder let content: () -> Content - - public var body: some View { - GroupBox { - VStack(alignment: .leading, spacing: 10) { - Group { - Text(Image(systemName: "exclamationmark.circle.fill")) - .foregroundColor(.accentColor) + - Text(" Consider the Impact", comment: "Impact title") - } - .font(.title3.weight(.semibold)) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityLabel(Text("Consider the Impact", comment: "Impact title accessibility label")) - - content() - .font(.subheadline) - } - } - } -} diff --git a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift index a96a452f15..e17e5f87f8 100644 --- a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift +++ b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift @@ -21,7 +21,7 @@ public struct InsulinScaleAdjustView: View { @Binding var insulinMultiplier: Double var insulinPercentage: Double { - insulinMultiplier * 100 + (insulinMultiplier * 100).rounded() } public var body: some View { diff --git a/Loop/Views/Presets/Components/TherapySettingsExampleView.swift b/Loop/Views/Presets/Components/TherapySettingsExampleView.swift deleted file mode 100644 index e33029e65b..0000000000 --- a/Loop/Views/Presets/Components/TherapySettingsExampleView.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// TherapySettingsExampleView.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopAlgorithm -import LoopKit -import LoopKitUI -import SwiftUI - -struct TherapySettingsExampleView: View { - - @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - - let title: String - let basalRate: Double - let carbRatio: Double - let isf: Double - - let numberFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter - }() - - private let basalRateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) - - var body: some View { - GroupBox { - VStack(alignment: .leading, spacing: 10) { - Text(title) - .font(.title3.weight(.semibold)) - .frame(maxWidth: .infinity, alignment: .leading) - - HStack(spacing: 36) { - VStack(alignment: .leading, spacing: 6) { - Text("Basal Rate") - - Text("Carb Ratio") - - Text("ISF") - } - .fontWeight(.semibold) - - VStack(alignment: .leading, spacing: 6) { - if let basalRateValue = basalRateFormatter.string(from: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: basalRate)) { - Text(basalRateValue) - } - - Text("\(numberFormatter.string(from: carbRatio) ?? "0") g/U") - - Text(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: isf))) - } - } - .font(.subheadline) - } - } - } -} diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index fd962f8677..c187e0af10 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -32,6 +32,7 @@ enum PresetSortOption: Int, CaseIterable { enum ActiveSheet: Identifiable { case editPreset(SelectablePreset) // For EditPresetView case presetDetent(SelectablePreset) // For PresetDetentView + case training(navigationPath: [PresetsTraining.Step] = [], startingAt: PresetsTraining.Chapter? = nil, editPresetWhenComplete: SelectablePreset? = nil) var id: String { switch self { @@ -39,6 +40,8 @@ enum ActiveSheet: Identifiable { return "edit_\(preset.id)" // Assuming Preset has an id case .presetDetent(let preset): return "detent_\(preset.id)" + case .training: + return "training" } } } @@ -51,6 +54,7 @@ struct PresetsView: View { } @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.appName) private var appName @Environment(\.settingsManager) private var settingsManager @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @Environment(\.dismiss) private var dismiss @@ -58,7 +62,6 @@ struct PresetsView: View { @State private var trainingCompletion: PresetsTrainingCompletion = PresetsTrainingCompletion() @State private var editMode: EditMode = .inactive @State private var showingMenu: Bool = false - @State private var showTraining: Bool = false @State private var presentCreateView: Bool = false @State private var activeSheet: ActiveSheet? @State private var navigationPath = NavigationPath() @@ -131,7 +134,7 @@ struct PresetsView: View { if !trainingCompletion.isComplete { PresetsTrainingCard(trainingCompletion: trainingCompletion) .onTapGesture { - showTraining = true + activeSheet = .training() } } @@ -179,7 +182,7 @@ struct PresetsView: View { if trainingCompletion.isComplete { Button { - showTraining = true + activeSheet = .training() } label: { HStack { Text("Review Presets Training") @@ -195,7 +198,6 @@ struct PresetsView: View { .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) .frame(maxWidth: .infinity)) } - } } .padding() @@ -213,14 +215,18 @@ struct PresetsView: View { } .onAppear { if trainingCompletion.completedChapters[.entry] != true { - showTraining = true + activeSheet = .training() } } .sheet(item: $activeSheet) { sheet in switch sheet { case .presetDetent(let preset): PresetDetentView(preset: preset, didTapEdit: { - activeSheet = .editPreset(preset) + if case .activity(_) = preset, !trainingCompletion.isComplete { + activeSheet = .training(editPresetWhenComplete: preset) + } else { + activeSheet = .editPreset(preset) + } }) case .editPreset(let preset): Group { @@ -243,11 +249,14 @@ struct PresetsView: View { ) } } + case .training(let navigationPath, let startingAt, let editPresetWhenComplete): + PresetsTrainingView(navigationPath: navigationPath, startingAt: startingAt, trainingCompletion: trainingCompletion) { + if let editPresetWhenComplete { + activeSheet = .editPreset(editPresetWhenComplete) + } + } } } - .sheet(isPresented: $showTraining) { - PresetsTrainingView(trainingCompletion: trainingCompletion) - } .sheet(isPresented: $presentCreateView) { CreatePresetView() } diff --git a/Loop/Views/Presets/Training Content/Components/CommonUseStep.swift b/Loop/Views/Presets/Training Content/Components/CommonUseStep.swift new file mode 100644 index 0000000000..ef0ef53db5 --- /dev/null +++ b/Loop/Views/Presets/Training Content/Components/CommonUseStep.swift @@ -0,0 +1,89 @@ +// +// CommonUseStep.swift +// Loop +// +// Created by Cameron Ingham on 9/2/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct CommonUseStep: View { + + @Environment(\.isEnabled) private var isEnabled + + private let title: Text + private let readTimeString: String + private let onTapGesture: (() -> Void)? + + private let formatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute] + formatter.unitsStyle = .short + return formatter + }() + + init?( + title: Text, + readTime: TimeInterval, + onTapGesture: (() -> Void)? = nil + ) { + self.title = title + + guard let readTimeString = formatter.string(from: readTime) else { + return nil + } + + self.readTimeString = readTimeString + self.onTapGesture = onTapGesture + } + + init?( + title: String, + readTime: TimeInterval, + onTapGesture: (() -> Void)? = nil + ) { + self.init( + title: Text(title), + readTime: readTime, + onTapGesture: onTapGesture + ) + } + + var body: some View { + HStack(alignment: .top, spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + title + .fontWeight(.semibold) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("\(readTimeString) read") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 16) + + Image(systemName: isEnabled && onTapGesture == nil ? "checkmark.circle.fill" : "circle") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundStyle(Color.accentColor) + } + .padding(16) + .background( + Color(UIColor.systemBackground) + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + .shadow( + color: .primary.opacity(0.03), + radius: 6, + x: 0, + y: 4 + ) + .opacity(isEnabled ? 1 : 0.6) + .onTapGesture { + onTapGesture?() + } + } +} diff --git a/Loop/Views/Presets/Training Content/Components/InsetContent.swift b/Loop/Views/Presets/Training Content/Components/InsetContent.swift index 326689b6e4..126a2ac6be 100644 --- a/Loop/Views/Presets/Training Content/Components/InsetContent.swift +++ b/Loop/Views/Presets/Training Content/Components/InsetContent.swift @@ -11,19 +11,23 @@ import SwiftUI struct InsetContent: View { let alignment: HorizontalAlignment - let content: () -> Content + let spacing: Double + let padding: Double + let content: Content - init(alignment: HorizontalAlignment = .center, @ViewBuilder content: @escaping () -> Content) { + init(alignment: HorizontalAlignment = .center, spacing: Double = 24, padding: Double = 16, @ViewBuilder content: () -> Content) { self.alignment = alignment - self.content = content + self.spacing = spacing + self.padding = padding + self.content = content() } var body: some View { - VStack(alignment: alignment, spacing: 24) { - content() + VStack(alignment: alignment, spacing: spacing) { + content .frame(maxWidth: .infinity, alignment: alignment == .leading ? .leading : .center) } - .padding(16) + .padding(padding) .background( RoundedRectangle(cornerRadius: 10) .strokeBorder(Color.gray.quinary, lineWidth: 1) diff --git a/Loop/Views/Presets/Training Content/Components/IntensitySlider.swift b/Loop/Views/Presets/Training Content/Components/IntensitySlider.swift new file mode 100644 index 0000000000..8c2bf565f0 --- /dev/null +++ b/Loop/Views/Presets/Training Content/Components/IntensitySlider.swift @@ -0,0 +1,281 @@ +// +// IntensitySlider.swift +// Loop +// +// Created by Cameron Ingham on 9/4/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct IntensitySlider: UIViewRepresentable { + + static let thumbnailSize: Double = 32 + static let trackHeight: Double = 8 + + private struct ThumbnailView: View { + let color: Color + + var body: some View { + Circle() + .fill(color) + .frame(width: IntensitySlider.thumbnailSize, height: IntensitySlider.thumbnailSize) + } + } + + private struct TrackView: View { + var body: some View { + Rectangle() + .foregroundColor(.clear) + .frame(height: IntensitySlider.trackHeight) + .frame(maxWidth: .infinity) + .background(DerivedGradientView()) + } + } + + class RoundedSlider: UISlider { + override func layoutSubviews() { + super.layoutSubviews() + self.subviews.first?.subviews.forEach { subview in + if subview.bounds.height == IntensitySlider.trackHeight { + subview.layer.cornerRadius = subview.bounds.height / 2 + subview.clipsToBounds = true + } + } + } + } + + class Coordinator { + let minimumValue: Double + let maximumValue: Double + + let value: Binding + + init(value: Binding, minimumValue: Double = 0, maximumValue: Double = 10) { + self.value = value + self.minimumValue = minimumValue + self.maximumValue = maximumValue + } + + @objc + func sliderValueChanged(_ slider: UISlider) { + value.wrappedValue = Double(slider.value) + } + + @objc + func sliderEditingEnding(_ slider: UISlider) { + slider.value = Float(Int(value.wrappedValue.rounded())) + } + } + + @Binding var value: Double + let snapToInteger: Bool = true + + private let trackImage: UIImage? = TrackView().snapshot() + + func makeUIView(context: Context) -> RoundedSlider { + let sliderView = RoundedSlider() + + sliderView.minimumValue = Float(context.coordinator.minimumValue) + sliderView.maximumValue = Float(context.coordinator.maximumValue) + sliderView.value = Float(value) + sliderView.setThumbImage( + ThumbnailView( + color: thumbColorForValue( + value, + minimum: context.coordinator.minimumValue, + maximum: context.coordinator.maximumValue + ) + ).snapshot(), + for: .normal + ) + sliderView.setMinimumTrackImage(trackImage, for: .normal) + sliderView.setMaximumTrackImage(trackImage, for: .normal) + + sliderView.addTarget(context.coordinator, action: #selector(context.coordinator.sliderValueChanged(_:)), for: .valueChanged) + + if snapToInteger { + sliderView.addTarget(context.coordinator, action: #selector(context.coordinator.sliderEditingEnding(_:)), for: .touchUpInside) + sliderView.addTarget(context.coordinator, action: #selector(context.coordinator.sliderEditingEnding(_:)), for: .touchUpOutside) + } + + return sliderView + } + + func updateUIView(_ uiView: RoundedSlider, context: Context) { + uiView.value = Float(value) + uiView.setThumbImage( + ThumbnailView( + color: thumbColorForValue( + value, + minimum: context.coordinator.minimumValue, + maximum: context.coordinator.maximumValue + ) + ).snapshot(), + for: .normal + ) + } + + func makeCoordinator() -> Coordinator { + Coordinator(value: $value) + } + + private func thumbColorForValue(_ value: Double, minimum: Double, maximum: Double) -> Color { + DerivedGradientView().color(at: value / (maximum - minimum)) + } +} + +extension View { + func snapshot() -> UIImage? { + let render = ImageRenderer(content: self) + render.scale = UIScreen.main.scale + return render.uiImage + } +} + +private struct DerivedGradientView: View { + let stops: [Gradient.Stop] = [ + Gradient.Stop(color: Color(red: 0, green: 0.48, blue: 1), location: 0.00), + Gradient.Stop(color: Color(red: 0.2, green: 0.78, blue: 0.35), location: 0.33), + Gradient.Stop(color: Color(red: 1, green: 0.58, blue: 0), location: 0.69), + Gradient.Stop(color: Color(red: 1, green: 0.23, blue: 0.19), location: 1.00), + ] + + let startPoint: UnitPoint = UnitPoint(x: 0, y: 0.5) + let endPoint: UnitPoint = UnitPoint(x: 1, y: 0.5) + + var body: some View { + LinearGradient( + stops: stops, + startPoint: startPoint, + endPoint: endPoint, + ) + } + + func color(at value: Double) -> Color { + let clampedLocation = min(max(value, 0.0), 1.0) + + guard let upper = stops.first(where: { $0.location >= clampedLocation }) else { + return stops.last?.color ?? .clear + } + + guard let lower = stops.last(where: { $0.location <= clampedLocation }) else { + return stops.first?.color ?? .clear + } + + if lower.location == upper.location { + return lower.color + } + + let progress = (clampedLocation - lower.location) / (upper.location - lower.location) + + let lowerUIColor = UIColor(lower.color) + let upperUIColor = UIColor(upper.color) + + var lowerRed: CGFloat = 0 + var lowerGreen: CGFloat = 0 + var lowerBlue: CGFloat = 0 + var lowerAlpha: CGFloat = 0 + + var upperRed: CGFloat = 0 + var upperGreen: CGFloat = 0 + var upperBlue: CGFloat = 0 + var upperAlpha: CGFloat = 0 + + lowerUIColor.getRed(&lowerRed, green: &lowerGreen, blue: &lowerBlue, alpha: &lowerAlpha) + upperUIColor.getRed(&upperRed, green: &upperGreen, blue: &upperBlue, alpha: &upperAlpha) + + return Color( + red: (1 - progress) * lowerRed + progress * upperRed, + green: (1 - progress) * lowerGreen + progress * upperGreen, + blue: (1 - progress) * lowerBlue + progress * upperBlue, + opacity: (1 - progress) * lowerAlpha + progress * upperAlpha + ) + } +} + +struct IntensityInfo: View { + + @State private var lastValue: Double = 0 + @State private var value: Double = 0 + + var body: some View { + VStack(spacing: 2) { + Text(value.rounded().formatted()) + .contentTransition(.numericText(countsDown: lastValue > value)) + .font(.system(size: UIFontMetrics.default.scaledValue(for: 64)).weight(.heavy)) + .padding(.bottom, 16) + + HStack { + Text("0") + + Spacer() + + Text("10") + } + .font(.subheadline) + .foregroundStyle(.secondary) + + IntensitySlider(value: $value.animation(.easeInOut)) + + HStack { + Text("Very Easy") + + Spacer() + + Text("All Out") + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .onChange(of: value) { oldValue, _ in + lastValue = oldValue + } + + InsetContent { + VStack(spacing: 0) { + title + .font(.title2.bold()) + .padding(.bottom, 16) + + message + .padding(.bottom, 8) + + glucoseChange + } + .multilineTextAlignment(.center) + } + } + + var title: Text { + switch Int(value.rounded()) { + case 0: Text("No Activity") + case 1...2: Text("Light Intensity (Aerobic)") + case 3...8: Text("Medium Intensity (Aerobic)") + case 9: Text("High Intensity (Anaerobic)") + case 10: Text("Maximum Intensity (Anaerobic)") + default: Text("Unsupported") + } + } + + var message: Text { + switch Int(value.rounded()) { + case 0: Text("Sitting or laying down, no change in breathing.") + case 1...2: Text("Easy breath. Can carry on a conversation.") + case 3...5: Text("Breathing more heavily. Can carry on a conversation, but requires more effort.") + case 6...8: Text("Breathing is slightly uncomfortable. Conversation requires maximal effort.") + case 9: Text("Difficulty maintaining exercise or holding a conversation.") + case 10: Text("Full out effort. No conversation possible.") + default: Text("Unsupported") + } + } + + var glucoseChange: Text { + switch Int(value.rounded()) { + case 0: Text("No change in glucose.") + case 1...8: Text("May experience drops in glucose.") + case 9...10: Text("May experience a rise in glucose.") + default: Text("Unsupported") + } + } +} diff --git a/Loop/Views/Presets/Training Content/Components/PlayMediaButton.swift b/Loop/Views/Presets/Training Content/Components/PlayMediaButton.swift new file mode 100644 index 0000000000..75345c100c --- /dev/null +++ b/Loop/Views/Presets/Training Content/Components/PlayMediaButton.swift @@ -0,0 +1,63 @@ +// +// PlayMediaButton.swift +// Loop +// +// Created by Cameron Ingham on 9/4/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct PlayMediaButton: View { + + let image: Image + let title: Text + let duration: TimeInterval + + private let formatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = .abbreviated + return formatter + }() + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + image + .resizable() + .scaledToFill() + .frame(height: 160) + .frame(maxWidth: .infinity) + .clipped() + .padding([.top, .horizontal], -8) + .overlay { + Image("Play") + .resizable() + .scaledToFill() + .frame(width: 64, height: 64) + } + + title + .font(.headline.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + + ViewThatFits { + HStack(spacing: 4) { + Text("Tap to listen") + Text("•") + Text(formatter.string(from: duration) ?? "") + } + + VStack(alignment: .leading, spacing: 4) { + Text("Tap to listen") + Text(formatter.string(from: duration) ?? "") + } + } + .foregroundStyle(.secondary) + } + .padding(8) + .background(Color(UIColor.systemBackground)) + .cornerRadius(10) + .shadow(color: .primary.opacity(0.2), radius: 3, x: 0, y: 0) + } +} diff --git a/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift b/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift index 12aee72c7b..7aa1ac9ec6 100644 --- a/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift +++ b/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift @@ -15,6 +15,10 @@ struct PresetsTrainingCard: View { init(trainingCompletion: PresetsTrainingCompletion) { if trainingCompletion.completedChapters[.introduction] != true { self.imageName = "PresetsTrainingRequiredCard" + } else if trainingCompletion.completedChapters[.customizingPresets] != true { + self.imageName = "PresetsTrainingCreditEditStartCard" + } else if trainingCompletion.completedChapters[.trainingComplete] != true { + self.imageName = "PresetsTrainingCreditEditResumeCard" } else { self.imageName = nil } diff --git a/Loop/Views/Presets/Training Content/Components/TherapySettingsExampleView.swift b/Loop/Views/Presets/Training Content/Components/TherapySettingsExampleView.swift new file mode 100644 index 0000000000..c3f939377a --- /dev/null +++ b/Loop/Views/Presets/Training Content/Components/TherapySettingsExampleView.swift @@ -0,0 +1,177 @@ +// +// TherapySettingsExampleView.swift +// Loop +// +// Created by Cameron Ingham on 10/23/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +struct TherapySettingsExampleView: View { + + enum Component: Hashable { + case basalRate(Double) + case carbRatio(Double) + case isf(Double) + case correctionRange(ClosedRange) + case bolusRecommendation(starting: Double, ending: Double, action: String) + + var title: Text? { + switch self { + case .basalRate: Text("Basal Rate") + case .carbRatio: Text("Carb Ratio") + case .isf: Text("ISF") + case .correctionRange: Text("Correction Range") + case .bolusRecommendation: nil + } + } + } + + enum Style: Hashable { + case `default` + case adjusted + } + + @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + + let title: String + let components: [Component] + let style: Style + + init(title: String, components: [Component], style: Style = .default) { + self.title = title + self.components = components + self.style = style + } + + init(title: String, component: Component, style: Style = .default) { + self.title = title + self.components = [component] + self.style = style + } + + let numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() + + private let basalRateFormatter = QuantityFormatter(for: .internationalUnitsPerHour) + private let bolusFormatter = QuantityFormatter(for: .internationalUnit) + + var body: some View { + VStack(alignment: .center, spacing: 16) { + Text(title) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + + ViewThatFits { + HStack(spacing: 0) { + ForEach(components, id: \.self) { component in + VStack(alignment: .leading, spacing: 4) { + value(for: component) + .foregroundStyle(Color.accentColor) + + if let title = component.title { + title + } + } + + if component != components.last { + Spacer(minLength: 16) + } + } + } + + VStack(spacing: 8) { + ForEach(components, id: \.self) { component in + VStack(alignment: .leading, spacing: 4) { + value(for: component) + .foregroundStyle(Color.accentColor) + + if let title = component.title { + title + } + } + } + } + } + } + .inContainer(style: style) + } + + @ViewBuilder + func value(for component: Component) -> some View { + switch component { + case .basalRate(let double): + if let basalRateValue = basalRateFormatter.string(from: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: double), includeUnit: false) { + Text(basalRateValue).bold() + Text("\u{00a0}\(LoopUnit.internationalUnitsPerHour.shortLocalizedUnitString())") + } + case .carbRatio(let double): + Text("\(numberFormatter.string(from: double) ?? "0")").bold() + Text("\u{00a0}\(LoopUnit.gramsPerUnit.shortLocalizedUnitString())") + case .isf(let double): + Text(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: double), includeUnit: false)).bold() + Text("\u{00a0}\(displayGlucosePreference.unit.shortLocalizedUnitString())") + case .correctionRange(let closedRange): + Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: closedRange.lowerBound), includeUnit: false))").bold() + Text("\u{00a0}-\u{00a0}") + Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: closedRange.upperBound), includeUnit: false))").bold() + Text("\u{00a0}\(displayGlucosePreference.unit.shortLocalizedUnitString())") + case .bolusRecommendation(let starting, let ending, let action): + ZStack(alignment: .top) { + HStack(alignment: .top, spacing: 0) { + if let startingValue = bolusFormatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: starting)), let endingValue = bolusFormatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: ending)) { + VStack(spacing: 6) { + Text(startingValue) + .font(.title2.bold()) + .foregroundStyle(Color.accentColor) + + Text("Before") + .font(.subheadline) + .foregroundStyle(Color.primary) + } + .containerRelativeFrame(.horizontal, count: 2, spacing: 80, alignment: .center) + + VStack(spacing: 6) { + Text(endingValue) + .font(.title2.bold()) + .foregroundStyle(Color.accentColor) + + Text(action) + .font(.subheadline) + .foregroundStyle(Color.primary) + } + .containerRelativeFrame(.horizontal, count: 2, spacing: 80, alignment: .center) + } + } + + Image(systemName: "arrow.forward") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + .foregroundStyle(Color.primary) + } + } + } +} + +private extension View { + @ViewBuilder + func inContainer(style: TherapySettingsExampleView.Style) -> some View { + switch style { + case .default: + InsetContent { + self + } + case .adjusted: + self + .padding(16) + .background( + Color(UIColor.secondarySystemBackground) + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + } + } +} diff --git a/Loop/Views/Presets/Training Content/Components/TintedContent.swift b/Loop/Views/Presets/Training Content/Components/TintedContent.swift new file mode 100644 index 0000000000..be761aa96d --- /dev/null +++ b/Loop/Views/Presets/Training Content/Components/TintedContent.swift @@ -0,0 +1,84 @@ +// +// TintedContent.swift +// Loop +// +// Created by Cameron Ingham on 9/8/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +@Observable +private class TintColor { + let color: Color + + init(color: Color) { + self.color = color + } +} + +struct TintedContent: View { + + let tint: Color + let icon: Image + let title: Text + let content: () -> Content + + init( + tint: Color, + icon: Image, + title: Text, + @ViewBuilder content: @escaping () -> Content + ) { + self.tint = tint + self.icon = icon + self.title = title + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label { + title + .font(.title2).bold() + .frame(maxWidth: .infinity, alignment: .leading) + } icon: { + icon + .font(.title3) + .foregroundStyle(tint) + } + + VStack(alignment: .leading, spacing: 16) { + content() + .environment(TintColor(color: tint)) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(tint.opacity(0.1)) + ) + } +} + +struct TintedTip: View { + + @Environment(TintColor.self) private var tintColor + + let text: Text + + var body: some View { + text + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + tintColor.color + .cornerRadius(10) + .overlay( + Color(UIColor.systemBackground) + .cornerRadius(10) + .padding(.leading, 3) + ) + ) + } +} diff --git a/Loop/Views/Presets/Training Content/PresetsTraining.swift b/Loop/Views/Presets/Training Content/PresetsTraining.swift index 632b95e3e8..bd0b915890 100644 --- a/Loop/Views/Presets/Training Content/PresetsTraining.swift +++ b/Loop/Views/Presets/Training Content/PresetsTraining.swift @@ -29,6 +29,24 @@ class PresetsTrainingCompletion { var isComplete: Bool { completedChapters.values.allSatisfy({ $0 }) } + + func complete(to chapter: PresetsTraining.Chapter) { + guard FeatureFlags.allowDebugFeatures else { return } + + guard let chapterIndex = PresetsTraining.Chapter.allCases.firstIndex(of: chapter) else { + return + } + + for index in 0.. String { switch self { case .entryPoint: - return NSLocalizedString("Presets Training", comment: "") + NSLocalizedString("Presets Training", comment: "") case .tier1(let tier1Chapter): switch tier1Chapter { case .introduction(let introduction): switch introduction { case .introduction: - return NSLocalizedString("Part 1: Introduction to Presets", comment: "") + NSLocalizedString("Part 1: Introduction to Presets", comment: "") case .exercisingWithLoop: - return String(format: NSLocalizedString("Exercising with %1$@", comment: ""), appName) + String(format: NSLocalizedString("Exercising with %1$@", comment: ""), appName) case .timingYourPresets: - return NSLocalizedString("Timing Your Presets for Exercise", comment: "") + NSLocalizedString("Timing Your Presets for Exercise", comment: "") case .safeGlucoseRanges: - return NSLocalizedString("Safe Glucose Ranges for Exercise", comment: "") + NSLocalizedString("Safe Glucose Ranges for Exercise", comment: "") case .performanceHistory: - return NSLocalizedString("Performance History", comment: "") + NSLocalizedString("Performance History", comment: "") case .complete: - return NSLocalizedString("Part 1: Complete", comment: "") + NSLocalizedString("Part 1: Complete", comment: "") } } + case .tier2(let tier2Chapter): + switch tier2Chapter { + case .customizingPresets(let customizingPresets): + switch customizingPresets { + case .customizingPresets: + NSLocalizedString("Part 2: Customizing Presets", comment: "") + case .overallInsulin: + NSLocalizedString("Overall Insulin", comment: "") + case .correctionRange: + NSLocalizedString("Correction Range", comment: "") + } + case .illness(let illness): + switch illness { + case .commonUses: + NSLocalizedString("Common Uses of Presets", comment: "") + case .presetsForIllness: + NSLocalizedString("Presets for Illness", comment: "") + case .overallInsulin: + NSLocalizedString("Overall Insulin", comment: "") + case .correctionRange: + NSLocalizedString("Correction Range", comment: "") + case .duration: + NSLocalizedString("Duration", comment: "") + case .impactOnBolusing: + NSLocalizedString("Impact on Bolusing", comment: "") + } + case .dailyActivities(let dailyActivities): + switch dailyActivities { + case .commonUses: + NSLocalizedString("Common Uses of Presets", comment: "") + case .presetsForDailyActivities: + NSLocalizedString("Presets for Daily Activity", comment: "") + case .overallInsulin: + NSLocalizedString("Overall Insulin", comment: "") + case .correctionRange: + NSLocalizedString("Correction Range", comment: "") + case .savedPresets: + NSLocalizedString("Saved Presets", comment: "") + } + case .exercise(let exercise): + switch exercise { + case .commonUses: + NSLocalizedString("Common Uses of Presets", comment: "") + case .presetsForExercise: + NSLocalizedString("Presets for Exercise", comment: "") + case .perceivedIntensity: + NSLocalizedString("Perceived Intensity", comment: "") + case .lightToModerateExercise: + NSLocalizedString("Light-to-Moderate Intensity Exercise", comment: "") + case .highIntensityExercise: + NSLocalizedString("High-Intensity Exercise", comment: "") + case .mixedIntensityExercise: + NSLocalizedString("Mixed-Intensity Exercise", comment: "") + case .exerciseAndGlucoseActiveInsulin, + .exerciseAndGlucoseTimeOfDay, + .exerciseAndGlucoseMealTiming, + .exerciseAndGlucoseCompetitionStress: + NSLocalizedString("Exercise and Your Glucose Levels", comment: "") + case .preventingLows: + NSLocalizedString("Preventing Lows", comment: "") + case .unplannedActivity: + NSLocalizedString("Unplanned Activity", comment: "") + } + } + case .trainingComplete: + NSLocalizedString("Training Complete", comment: "") } } func previous(startingFrom: Chapter) -> Step? { switch self { - case .entryPoint: - return nil + case .entryPoint: nil case .tier1(let tier1Chapter): switch tier1Chapter { case .introduction(let introduction): switch introduction { - case .introduction: - guard chapter != startingFrom else { return nil } - return .entryPoint - case .exercisingWithLoop: return .tier1(.introduction(.introduction)) - case .timingYourPresets: return .tier1(.introduction(.exercisingWithLoop)) - case .safeGlucoseRanges: return .tier1(.introduction(.timingYourPresets)) - case .performanceHistory: return .tier1(.introduction(.safeGlucoseRanges)) - case .complete: return .tier1(.introduction(.performanceHistory)) + case .introduction: chapter != startingFrom ? nil : .entryPoint + case .exercisingWithLoop: .tier1(.introduction(.introduction)) + case .timingYourPresets: .tier1(.introduction(.exercisingWithLoop)) + case .safeGlucoseRanges: .tier1(.introduction(.timingYourPresets)) + case .performanceHistory: .tier1(.introduction(.safeGlucoseRanges)) + case .complete: .tier1(.introduction(.performanceHistory)) } } + case .tier2(let tier2Chapter): + switch tier2Chapter { + case .customizingPresets(let customizingPresets): + switch customizingPresets { + case .customizingPresets: chapter != startingFrom ? nil : .tier1(.introduction(.complete)) + case .overallInsulin: .tier2(.customizingPresets(.customizingPresets)) + case .correctionRange: .tier2(.customizingPresets(.overallInsulin)) + } + case .illness(let illness): + switch illness { + case .commonUses: chapter != startingFrom ? nil : .tier2(.customizingPresets(.correctionRange)) + case .presetsForIllness: .tier2(.illness(.commonUses)) + case .overallInsulin: .tier2(.illness(.presetsForIllness)) + case .correctionRange: .tier2(.illness(.overallInsulin)) + case .duration: .tier2(.illness(.correctionRange)) + case .impactOnBolusing: .tier2(.illness(.duration)) + } + case .dailyActivities(let dailyActivities): + switch dailyActivities { + case .commonUses: chapter != startingFrom ? nil : .tier2(.illness(.impactOnBolusing)) + case .presetsForDailyActivities: .tier2(.dailyActivities(.commonUses)) + case .overallInsulin: .tier2(.dailyActivities(.presetsForDailyActivities)) + case .correctionRange: .tier2(.dailyActivities(.overallInsulin)) + case .savedPresets: .tier2(.dailyActivities(.correctionRange)) + } + case .exercise(let exercise): + switch exercise { + case .commonUses: chapter != startingFrom ? nil : .tier2(.dailyActivities(.savedPresets)) + case .presetsForExercise: .tier2(.exercise(.commonUses)) + case .perceivedIntensity: .tier2(.exercise(.presetsForExercise)) + case .lightToModerateExercise: .tier2(.exercise(.perceivedIntensity)) + case .highIntensityExercise: .tier2(.exercise(.lightToModerateExercise)) + case .mixedIntensityExercise: .tier2(.exercise(.highIntensityExercise)) + case .exerciseAndGlucoseActiveInsulin: .tier2(.exercise(.mixedIntensityExercise)) + case .exerciseAndGlucoseTimeOfDay: .tier2(.exercise(.exerciseAndGlucoseActiveInsulin)) + case .exerciseAndGlucoseMealTiming: .tier2(.exercise(.exerciseAndGlucoseTimeOfDay)) + case .exerciseAndGlucoseCompetitionStress: .tier2(.exercise(.exerciseAndGlucoseMealTiming)) + case .preventingLows: .tier2(.exercise(.exerciseAndGlucoseCompetitionStress)) + case .unplannedActivity: .tier2(.exercise(.preventingLows)) + } + } + case .trainingComplete: chapter != startingFrom ? nil : .tier2(.exercise(.unplannedActivity)) } } func next() -> (Step?, completedChapter: Chapter?) { switch self { - case .entryPoint: return (.tier1(.introduction(.introduction)), .entry) + case .entryPoint: (.tier1(.introduction(.introduction)), .entry) case .tier1(let tier1Chapter): switch tier1Chapter { case .introduction(let introduction): switch introduction { - case .introduction: return (.tier1(.introduction(.exercisingWithLoop)), nil) - case .exercisingWithLoop: return (.tier1(.introduction(.timingYourPresets)), nil) - case .timingYourPresets: return (.tier1(.introduction(.safeGlucoseRanges)), nil) - case .safeGlucoseRanges: return (.tier1(.introduction(.performanceHistory)), nil) - case .performanceHistory: return (.tier1(.introduction(.complete)), nil) - case .complete: return (nil, .introduction) + case .introduction: (.tier1(.introduction(.exercisingWithLoop)), nil) + case .exercisingWithLoop: (.tier1(.introduction(.timingYourPresets)), nil) + case .timingYourPresets: (.tier1(.introduction(.safeGlucoseRanges)), nil) + case .safeGlucoseRanges: (.tier1(.introduction(.performanceHistory)), nil) + case .performanceHistory: (.tier1(.introduction(.complete)), nil) + case .complete: (.tier2(.customizingPresets(.customizingPresets)), .introduction) } } + case .tier2(let tier2Chapter): + switch tier2Chapter { + case .customizingPresets(let customizingPresets): + switch customizingPresets { + case .customizingPresets: (.tier2(.customizingPresets(.overallInsulin)), nil) + case .overallInsulin: (.tier2(.customizingPresets(.correctionRange)), nil) + case .correctionRange: (.tier2(.illness(.commonUses)), .customizingPresets) + } + case .illness(let illness): + switch illness { + case .commonUses: (.tier2(.illness(.presetsForIllness)), nil) + case .presetsForIllness: (.tier2(.illness(.overallInsulin)), nil) + case .overallInsulin: (.tier2(.illness(.correctionRange)), nil) + case .correctionRange: (.tier2(.illness(.duration)), nil) + case .duration: (.tier2(.illness(.impactOnBolusing)), nil) + case .impactOnBolusing: (.tier2(.dailyActivities(.commonUses)), .illness) + } + case .dailyActivities(let dailyActivities): + switch dailyActivities { + case .commonUses: (.tier2(.dailyActivities(.presetsForDailyActivities)), nil) + case .presetsForDailyActivities: (.tier2(.dailyActivities(.overallInsulin)), nil) + case .overallInsulin: (.tier2(.dailyActivities(.correctionRange)), nil) + case .correctionRange: (.tier2(.dailyActivities(.savedPresets)), nil) + case .savedPresets: (.tier2(.exercise(.commonUses)), .dailyActivities) + } + case .exercise(let exercise): + switch exercise { + case .commonUses: (.tier2(.exercise(.presetsForExercise)), nil) + case .presetsForExercise: (.tier2(.exercise(.perceivedIntensity)), nil) + case .perceivedIntensity: (.tier2(.exercise(.lightToModerateExercise)), nil) + case .lightToModerateExercise: (.tier2(.exercise(.highIntensityExercise)), nil) + case .highIntensityExercise: (.tier2(.exercise(.mixedIntensityExercise)), nil) + case .mixedIntensityExercise: (.tier2(.exercise(.exerciseAndGlucoseActiveInsulin)), nil) + case .exerciseAndGlucoseActiveInsulin: (.tier2(.exercise(.exerciseAndGlucoseTimeOfDay)), nil) + case .exerciseAndGlucoseTimeOfDay: (.tier2(.exercise(.exerciseAndGlucoseMealTiming)), nil) + case .exerciseAndGlucoseMealTiming: (.tier2(.exercise(.exerciseAndGlucoseCompetitionStress)), nil) + case .exerciseAndGlucoseCompetitionStress: (.tier2(.exercise(.preventingLows)), nil) + case .preventingLows: (.tier2(.exercise(.unplannedActivity)), nil) + case .unplannedActivity: (.trainingComplete, .exercise) + } + } + case .trainingComplete: (nil, .trainingComplete) } } var chapter: Chapter { switch self { - case .entryPoint: - return .entry - case .tier1: - return .introduction + case .entryPoint: .entry + case .tier1: .introduction + case .tier2(.customizingPresets): .customizingPresets + case .tier2(.illness): .illness + case .tier2(.dailyActivities): .dailyActivities + case .tier2(.exercise): .exercise + case .trainingComplete: .trainingComplete + } + } + + var contentBackground: Color { + switch self { + case .tier2(.dailyActivities(.commonUses)), + .tier2(.exercise(.commonUses)), + .tier2(.illness(.commonUses)): + Color(UIColor.secondarySystemBackground) + default: + Color(UIColor.systemBackground) } } } @@ -158,10 +411,16 @@ public class PresetsTraining { if let startingAt { self.startingAt = startingAt } else { - if trainingCompletion.completedChapters[.entry] != true { - self.startingAt = .entry - } else if trainingCompletion.completedChapters[.introduction] != true { - self.startingAt = .introduction + var startingAt: Chapter? + + Chapter.allCases.reversed().forEach { chapter in + if trainingCompletion.completedChapters[chapter] != true { + startingAt = chapter + } + } + + if let startingAt { + self.startingAt = startingAt } else { self.startingAt = .entry } diff --git a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift index 9299c39e0d..57330c4227 100644 --- a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift +++ b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift @@ -15,19 +15,20 @@ extension PresetsTraining { enum CTA { case start case `continue` + case close case closeOrContinue(_ to: String, chapter: Chapter) } } protocol PresetsTrainingContent { associatedtype B: View - func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette) -> B + func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, next: @escaping () -> Void) -> B var cta: PresetsTraining.CTA? { get } } extension PresetsTraining.Step: PresetsTrainingContent { @ViewBuilder - func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette) -> some View { + func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, next: @escaping () -> Void) -> some View { switch self { case .entryPoint: if let image = Image("PresetsTrainingEntryHero") { @@ -190,6 +191,937 @@ extension PresetsTraining.Step: PresetsTrainingContent { Text("Complete Part 2 to enable preset editing and creation.") } } + case .tier2(let tier2Chapter): + switch tier2Chapter { + case .customizingPresets(let customizingPresets): + switch customizingPresets { + case .customizingPresets: + if let image = Image("PresetsTrainingCustomizingPresetsHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + EstimatedReadTime(.minutes(10)) + + VStack(alignment: .leading) { + Text("Learn to tailor your settings! This training will teach you how to:") + .fixedSize(horizontal: false, vertical: true) + + BulletedListView { + Text("Configure each setting") + Text("Use Presets for when you are sick") + Text("Use Presets for Daily Activities") + Text("Use Presets for Exercise") + } + .padding(.leading, 8) + } + + Text("Complete this training to learn how to edit the pre-configured presets and adjust them to fit your needs, or create your own custom presets.") + + case .overallInsulin: + if let image = Image("PresetsTrainingOverallInsulinHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + VStack(alignment: .leading) { + Text("The \"Overall Insulin\" percentage controls total insulin delivery by adjusting your:") + + BulletedListView { + Text("Basal Rate") + Text("Carb Ratio") + Text("Insulin Sensitivity Factor (ISF)") + } + .padding(.leading, 8) + } + + Text("At 100%, \(appName) assumes your insulin needs are the same as usual.") + + Text("When deciding to adjust your overall insulin, **ask yourself, does my body need more or less than usual?**") + + Callout(.note) { + BulletedListView { + Text("A percentage **below 100%** tells the system you need **less** insulin") + + Text("A percentage **above 100%** tells the system you need **more** insulin") + } + .font(.footnote) + .padding(.top, 8) + } + .padding(.horizontal, -16) + + case .correctionRange: + if let image = Image("PresetsTrainingCorrectionRangeHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Correction range is a **safety setting**. Changing it can help lower your risk of going low if you expect unusual changes.") + + Text("Changing it can lower the chance of your glucose levels going too low if you expect unusual changes.") + + Text("Choose the glucose value (or values) you want \(appName) to target when changing how much basal insulin you get.") + + Text("You don’t need to change the correction range for every preset. But before you decide to change it, ask yourself: *Am I more likely to go high or low during this time?*") + + Callout(.note) { + Text("To help avoid lows, set a range **higher** than your typical correction range.") + .font(.footnote) + .padding(.top, 8) + } + .padding(.horizontal, -16) + } + case .illness(let illness): + switch illness { + case .commonUses: + Text("You can use presets for a variety of situations. Explore the uses below to learn tips for these common scenarios.") + + VStack(spacing: 16) { + CommonUseStep( + title: Text("Presets for Illness"), + readTime: .minutes(3), + onTapGesture: next + ) + + CommonUseStep( + title: Text("Presets for Daily Activity"), + readTime: .minutes(2) + ) + .disabled(true) + + CommonUseStep( + title: Text("Presets for Exercise"), + readTime: .minutes(5) + ) + .disabled(true) + } + + case .presetsForIllness: + Text("Physical stress, like illness, can cause glucose to rise.") + + InsetContent(alignment: .leading) { + Text("**Example:** Paloma Porpoise notices her glucose is higher than normal and wants to create a preset to manage it while she's sick.") + } + + Text("Let's look at the settings that will impact Paloma's insulin delivery.") + + case .overallInsulin: + Text("Paloma wants \(appName) to know she needs more insulin than usual.") + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Current Therapy Settings", comment: ""), + components: [ + .basalRate(0.5), + .carbRatio(13), + .isf(50) + ] + ) + + Text("She can do this by raising her **Overall Insulin** setting. This tells \(appName) to deliver more than her usual amount, making her insulin settings stronger.") + + if let image = Image("PresetsTrainingIllnessOverallInsulin") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Adjusted Therapy Settings", comment: ""), + components: [ + .basalRate(0.6), + .carbRatio(12), + .isf(45) + ], + style: .adjusted + ) + + case .correctionRange: + Text("While sick, Paloma expects to eat less or not absorb everything she eats.") + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Current Therapy Settings", comment: ""), + component: .correctionRange(105...110) + ) + + Text("To help prevent lows, she will increase her correction range.") + + if let image = Image("PresetsTrainingIllnessCorrectionRange") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Adjusted Therapy Settings", comment: ""), + component: .correctionRange(130...140), + style: .adjusted + ) + + case .duration: + Text("You can choose how long your preset lasts.") + + Text("Since Paloma doesn't know when she'll feel better, she sets hers to “Until I Turn Off”.") + + if let image = Image("PresetsTrainingIllnessDuration1") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("To be safe, \(appName) will remind her at 8 hours that the preset is still running.") + + if let image = Image("PresetsTrainingIllnessDuration2") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("While turned on, Paloma’s preset will display on the home screen and in her Presets list.") + + case .impactOnBolusing: + Text("Later that day, Paloma eats a meal with about 30g of carbs.") + + Text("How does her preset impact her bolus recommendation?") + + Text("Her preset is set to **110%**, which is more than she usually needs. This means \(appName) will make her basal rates, carb ratio, and insulin sensitivity factor (ISF) stronger. ") + + TherapySettingsExampleView( + title: NSLocalizedString("Her bolus recommendation is higher than usual because her overall insulin is set higher.", comment: ""), + component: .bolusRecommendation( + starting: 3.9, + ending: 4.3, + action: NSLocalizedString("With Preset On", comment: "") + ), + style: .adjusted + ) + } + case .dailyActivities(let dailyActivities): + switch dailyActivities { + case .commonUses: + Text("You can use presets for a variety of situations. Explore the uses below to learn tips for these common scenarios.") + + VStack(spacing: 16) { + CommonUseStep( + title: Text("Presets for Illness"), + readTime: .minutes(3) + ) + + CommonUseStep( + title: Text("Presets for Daily Activity"), + readTime: .minutes(2), + onTapGesture: next + ) + + CommonUseStep( + title: Text("Presets for Exercise"), + readTime: .minutes(5) + ) + .disabled(true) + } + + case .presetsForDailyActivities: + Text("For some people, routine chores and everyday activities can affect glucose levels similar to exercise.") + + InsetContent(alignment: .leading) { + Text("**Example:** Omar Octopus wants to create a preset for some yard work he’ll be doing around the house.") + } + + Text("Let's look at the settings that will impact Omar’s insulin delivery.") + + VStack(alignment: .leading, spacing: 16) { + Text("Learn More") + .font(.headline.weight(.semibold)) + + PlayMediaButton( + image: Image("ADLs"), + title: Text("Managing Activities of Daily Living"), + duration: .minutes(5) + .seconds(36) + ) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.presets.opacity(0.1)) + ) + + case .overallInsulin: + Text("Omar asks himself, **do I expect I will need more or less insulin than usual?**") + + Text("Since he doesn’t plan to push himself too hard, he expects his insulin needs to stay the same, so he leaves the setting at 100%.") + + if let image = Image("PresetsTrainingDailyActivityOverallInsulin") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Callout(.note) { + Text("Pay attention to your insulin needs before and after exercising, playing sports, or doing unusually hard physical labor.") + } + .padding(.horizontal, -16) + + case .correctionRange: + Text("For activities that raise your risk of going low, you can set a higher temporary correction range.") + + Text("This range is usually higher than your correction range when you are not exercising.") + + Text("Because Omar has gone low while working outdoors in the past, he raises his preset correction range to help prevent another low.") + + TherapySettingsExampleView( + title: NSLocalizedString("Omar’s Current Therapy Settings", comment: ""), + component: .correctionRange(110...120) + ) + + Text("Omar sets his correction range a little higher, to \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 110), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), includeUnit: false)) \(displayGlucosePreference.unit.localizedShortUnitString). This tells \(appName) to step in sooner.") + + if let image = Image("PresetsTrainingDailyActivityCorrectionRange") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + case .savedPresets: + if let image = Image("PresetsTrainingDailyActivitySavedPresets") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Once saved, Omar’s new preset will display in his Presets lists.") + + Callout(.note) { + Text("If your activity has a higher risk of low glucose, start a physical activity preset at least **1 hour before you begin** and keep it on until you finish.") + + Text("If you expect your glucose to rise during the activity, you may not need a preset.") + } + .padding(.horizontal, -16) + + } + case .exercise(let exercise): + switch exercise { + case .commonUses: + Text("Presets can be used for a variety of situations. Explore the uses below to learn tips for these common scenarios.") + + VStack(spacing: 16) { + CommonUseStep( + title: Text("Presets for Illness"), + readTime: .minutes(3) + ) + + CommonUseStep( + title: Text("Presets for Daily Activity"), + readTime: .minutes(2) + ) + + CommonUseStep( + title: Text("Presets for Exercise"), + readTime: .minutes(5), + onTapGesture: next + ) + } + case .presetsForExercise: + Text("Exercise is a common reason to use a preset.") + + Text("Different kinds of exercise and their intensity levels can affect your glucose levels in different ways.") + + Text("Depending on the activity, you may notice a few common patterns when it comes to your insulin needs:") + + BulletedListView { + Text("no change needed") + Text("you need **less** insulin than usual") + Text("you need **more** insulin than usual") + } + + Callout(.note) { + Text("These patterns are based on published exercise consensus guidelines and are meant to be used as a starting point. What works for one person may not work for you.") + } + .padding(.horizontal, -16) + + case .perceivedIntensity: + Text("Recognizing how hard you feel you're working during exercise can help you understand its impact on your glucose levels.") + + Text("Consider an exercise you do regularly and think about how hard you push yourself.") + + Text("Use the slider to rate the effort on a scale of 0–10, with 10 being the hardest you’ve ever worked.") + + IntensityInfo() + + VStack(alignment: .leading, spacing: 16) { + Text("Learn More") + .font(.headline.weight(.semibold)) + + PlayMediaButton( + image: Image("Same Activity Different Intensity"), + title: Text("Same Activity, Different Intensity"), + duration: .minutes(6) + .seconds(34) + ) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.presets.opacity(0.1)) + ) + + case .lightToModerateExercise: + if let image = Image("PresetsTrainingExerciseLightToModerateHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Light-to-moderate intensity exercise can cause a drop in glucose levels. This is because your body uses glucose (or sugar) for energy during physical activity.") + + InsetContent { + VStack(spacing: 4) { + Text("Aerobic") + .font(.title2.bold()) + + Text("Continuous or exercise without breaks") + .frame(maxWidth: .infinity) + } + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 16) { + Bullet(color: .secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("Walking") + + HStack(alignment: .center, spacing: 2) { + Text("\(Image(systemName: "lightbulb.max"))") + + Text(" **Tip** Use your \(Image(systemName: "figure.walk")) **Walking** preset") + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 16) { + Bullet(color: .secondary) + + Text("Hiking") + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 16) { + Bullet(color: .secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("Jogging") + + HStack(alignment: .center, spacing: 2) { + Text("\(Image(systemName: "lightbulb.max"))") + + Text(" **Tip** Use your \(Image(systemName: "figure.run")) **Jogging** preset") + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 16) { + Bullet(color: .secondary) + + Text("Swimming") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + Text("For these activities, consider setting your insulin needs to **less than 100%**.") + + if let image = Image("PresetsTrainingExerciseLightToModerate") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Callout(.note) { + Text("These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you.") + } + .padding(.horizontal, -16) + + case .highIntensityExercise: + if let image = Image("PresetsTrainingExerciseHighHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("High-intensity exercise means pushing yourself to your **maximum effort**. It is so hard that talking is nearly impossible, and you can’t keep it up for very long.") + + Text("During this kind of hard exercise, your body may release hormones that raise glucose. This is more common in the morning before eating.") + + InsetContent { + VStack(spacing: 4) { + Text("Aerobic") + .font(.title2.bold()) + + Text("Explosive sprints or bursts") + .frame(maxWidth: .infinity) + } + + BulletedListView(bulletColor: .secondary) { + Text("Power lifting") + Text("CrossFit") + Text("100m sprint") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Text("For these activities, consider setting your insulin needs to **more than 100%**.") + + Text("That said, insulin needs vary from person to person. Some people find they don’t need to adjust their insulin at all for high-intensity exercise.") + + Text("If you haven’t noticed a rise in glucose with high-intensity exercise, it may be due to:") + + BulletedListView { + Text("Starting your exercise with high active insulin") + Text("Automated insulin adjustments by \(appName) reduce a noticeable rise in glucose") + Text("The exercise may not be vigorous enough to produce these results") + } + + if let image = Image("PresetsTrainingExerciseHigh") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("When using high-insulin presets, **you may not need to start your preset 1 hour before**.") + + Callout(.note) { + Text("These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you.") + } + .padding(.horizontal, -16) + + case .mixedIntensityExercise: + if let image = Image("PresetsTrainingExerciseMixedHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Mixed-intensity exercise may cause only small changes in glucose levels. Your glucose may go up or down.") + + InsetContent { + VStack(spacing: 4) { + Text("Aerobic") + .font(.title2.bold()) + + Text("Combination of high and low intensity") + .frame(maxWidth: .infinity) + } + + BulletedListView(bulletColor: .secondary) { + Text("Soccer") + Text("Interval Training ") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Text("For mixed-intensity activity:") + + BulletedListView { + Text("If your glucose goes up, you may only need a small increase in insulin — less than you would for high-intensity activity.") + + Text("If your glucose goes down, you may only need a small decrease in insulin — less than you would for low to moderate-intensity activity.") + } + + Callout(.note) { + Text("These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you.") + } + .padding(.horizontal, -16) + + VStack(alignment: .leading, spacing: 16) { + Text("Learn More") + .font(.headline.weight(.semibold)) + + PlayMediaButton( + image: Image("Mixed Exercise"), + title: Text("Navigating the Challenges of Mixed Exercise"), + duration: .minutes(3) + .seconds(27) + ) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.presets.opacity(0.1)) + ) + + case .exerciseAndGlucoseActiveInsulin: + Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") + + TintedContent( + tint: colorPalette.insulinTintColor, + icon: Image(systemName: "cross.vial"), + title: Text("Active Insulin") + ) { + Text("If you have active insulin in your body when you start exercising, you generally have an increased risk of low glucose.") + + TintedTip(text: Text("**Tip:** Try exercising when your active insulin is close to zero at the start of an activity.")) + } + + case .exerciseAndGlucoseTimeOfDay: + Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") + + TintedContent( + tint: colorPalette.carbTintColor, + icon: Image(systemName: "clock"), + title: Text("Time of Day") + ) { + Text("Morning exercise before eating (like a fasted jog) usually causes a smaller drop in glucose levels and may even promote a rise, compared to afternoon exercise.") + + if dynamicTypeSize < .accessibility1 { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Morning Exercise") + .foregroundStyle(colorPalette.carbTintColor) + .font(.subheadline.weight(.semibold)) + + Text("Smaller glucose drop") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + + VStack(alignment: .leading, spacing: 4) { + Text("Afternoon Exercise") + .foregroundStyle(colorPalette.guidanceColors.critical) + .font(.subheadline.weight(.semibold)) + + Text("Larger glucose drop") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + } + } else { + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Morning Exercise") + .foregroundStyle(colorPalette.carbTintColor) + .font(.subheadline.weight(.semibold)) + + Text("Smaller glucose drop") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + + VStack(alignment: .leading, spacing: 4) { + Text("Afternoon Exercise") + .foregroundStyle(colorPalette.guidanceColors.critical) + .font(.subheadline.weight(.semibold)) + + Text("Larger glucose drop") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + } + } + + TintedTip(text: Text("**Try:** If you often experience low glucose, consider exercising earlier in the day before eating.")) + } + + case .exerciseAndGlucoseMealTiming: + Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") + + TintedContent( + tint: .orange, + icon: Image(systemName: "fork.knife"), + title: Text("Active Insulin") + ) { + Text("If you often experience low glucose, you may need to reduce how much insulin you deliver for meals eaten 1-2 hours before exercising.") + + VStack(spacing: 4) { + Text("Recommended Insulin Reduction") + .font(.headline.weight(.semibold)) + .frame(maxWidth: .infinity) + + Text("25-33%") + .font(.title.weight(.heavy)) + .foregroundStyle(Color.orange) + + Text("if eating less than 2 hours before exercise") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + + TintedTip(text: Text("**Try:** Reducing your meal bolus if you expect your glucose to drop.")) + } + + case .exerciseAndGlucoseCompetitionStress: + Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") + + TintedContent( + tint: colorPalette.glucoseTintColor, + icon: Image(systemName: "trophy"), + title: Text("Competition Stress") + ) { + Text("Stress during a game, match or tournament causes your body to release hormones like adrenaline and cortisol, which may raise your glucose and cause \(appName) to increase insulin delivery.") + + BulletedListView(bulletColor: colorPalette.glucoseTintColor, bulletOpacity: 1) { + Text("Monitor your glucose and active insulin at the start of a competition day") + + Text("Stay hydrated") + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + + TintedTip(text: Text("**Tip: If glucose rises to >270 mg/dl,** check \(appName) to see if a bolus is recommended to bring your glucose back into range.")) + } + + case .preventingLows: + Text("If you usually experience lows while exercising, watch your glucose levels closely during exercise and consider eating around 3 to 20g of fast-acting carbs.") + + Group { + if dynamicTypeSize < .accessibility1 { + HStack(alignment: .bottom, spacing: 12) { + InsetContent(spacing: 8) { + Text("Stable Glucose") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + Image("glucose-stable") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + .foregroundStyle(colorPalette.glucoseTintColor) + + VStack(spacing: 0) { + Text("3-6") + .font(.headline.weight(.semibold)) + + Text("grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + InsetContent(spacing: 8) { + Text("Falling Slowly") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + Image("glucose-stable") + .resizable() + .scaledToFit() + .rotationEffect(.degrees(30)) + .frame(width: 28, height: 28) + .foregroundStyle(colorPalette.glucoseTintColor) + + VStack(spacing: 0) { + Text("6-9") + .font(.headline.weight(.semibold)) + + Text("grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + InsetContent(spacing: 8) { + Text("Falling / Falling Quickly") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + HStack(spacing: 6) { + Image("glucose-stable") + .resizable() + .scaledToFit() + .rotationEffect(.degrees(90)) + .frame(width: 28, height: 28) + + Image("glucose-falling-fast") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + } + .foregroundStyle(colorPalette.glucoseTintColor) + + VStack(spacing: 0) { + Text("9-20") + .font(.headline.weight(.semibold)) + + Text("grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .fixedSize(horizontal: false, vertical: true) + } else { + VStack(spacing: 12) { + InsetContent(spacing: 8) { + Text("Stable Glucose") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + Image("glucose-stable") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + .foregroundStyle(colorPalette.glucoseTintColor) + + Text("3-6") + .font(.headline.weight(.semibold)) + + Text(" grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + + InsetContent(spacing: 8) { + Text("Falling Slowly") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + Image("glucose-stable") + .resizable() + .scaledToFit() + .rotationEffect(.degrees(30)) + .frame(width: 28, height: 28) + .foregroundStyle(colorPalette.glucoseTintColor) + + Text("6-9") + .font(.headline.weight(.semibold)) + + Text(" grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + + InsetContent(spacing: 8) { + Text("Falling / Falling Quickly") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + HStack(spacing: 6) { + Image("glucose-stable") + .resizable() + .scaledToFit() + .rotationEffect(.degrees(90)) + .frame(width: 28, height: 28) + + Image("glucose-falling-fast") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + } + .foregroundStyle(colorPalette.glucoseTintColor) + + Text("9-20") + .font(.headline.weight(.semibold)) + + Text(" grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .fixedSize(horizontal: false, vertical: true) + } + } + .multilineTextAlignment(.center) + + Text("Check your glucose levels around 20 to 30 min after eating. If you're still low, consider eating the same amount.") + + Callout(.note) { + Text("If your glucose isn't dropping, eating too many carbs can raise your blood sugar, trigger more insulin, and increase the risk of low blood sugar during or after the activity.") + } + .padding(.horizontal, -16) + + case .unplannedActivity: + Text("Planning for physical activity can be tough. If you forget to set a preset ahead of time, consider these strategies:") + + InsetContent(padding: 16) { + HStack(spacing: 16) { + Image("presets-selected") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 28, height: 28) + .foregroundStyle(Color.presets) + + VStack(alignment: .leading, spacing: 2) { + Text("Start Preset") + .frame(maxWidth: .infinity, alignment: .leading) + .fontWeight(.semibold) + + Text("Turn on the preset as soon as you remember and keep it on until the activity ends") + } + } + } + + InsetContent(padding: 16) { + HStack(spacing: 16) { + Image("candy-icon") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundStyle(colorPalette.carbTintColor) + + VStack(alignment: .leading, spacing: 2) { + Text("If glucose drops below 126 mg/dL") + .frame(maxWidth: .infinity, alignment: .leading) + .fontWeight(.semibold) + + Text("Consider eating around 10 to 20 grams of fast-acting carbs") + } + } + } + } + } + case .trainingComplete: + Text("Congratulations! You've finished the Presets training.") + + VStack(alignment: .leading, spacing: 8) { + Text("You can now:") + + BulletedListView { + Text("Edit presets") + Text("Create new presets") + } + .padding(.leading, 8) + } + + Text("You may review the training materials again at any time via the Learning Hub, located at the bottom of the Preset screen.") } } @@ -200,22 +1132,71 @@ extension PresetsTraining.Step: PresetsTrainingContent { switch tier1Chapter { case .introduction(let introduction): switch introduction { - case .introduction: .continue - case .exercisingWithLoop: .continue - case .timingYourPresets: .continue - case .safeGlucoseRanges: .continue - case .performanceHistory: .continue + case .introduction, + .exercisingWithLoop, + .timingYourPresets, + .safeGlucoseRanges, + .performanceHistory: .continue case .complete: .closeOrContinue("Step 2", chapter: .introduction) } } + case .tier2(let tier2Chapter): + switch tier2Chapter { + case .customizingPresets: .continue + case .illness(let illness): + switch illness { + case .commonUses: nil + case .presetsForIllness, + .overallInsulin, + .correctionRange, + .duration, + .impactOnBolusing: .continue + } + case .dailyActivities(let dailyActivities): + switch dailyActivities { + case .commonUses: nil + case .presetsForDailyActivities, + .overallInsulin, + .correctionRange, + .savedPresets: .continue + } + case .exercise(let exercise): + switch exercise { + case .commonUses: nil + case .presetsForExercise, + .perceivedIntensity, + .lightToModerateExercise, + .highIntensityExercise, + .mixedIntensityExercise, + .exerciseAndGlucoseActiveInsulin, + .exerciseAndGlucoseTimeOfDay, + .exerciseAndGlucoseMealTiming, + .exerciseAndGlucoseCompetitionStress, + .preventingLows, + .unplannedActivity: .continue + } + } + case .trainingComplete: .close } } } extension PresetsTraining.Chapter: PresetsTrainingContent { @ViewBuilder - func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette) -> some View { - firstStep.content(appName: appName, displayGlucosePreference: displayGlucosePreference, colorPalette: colorPalette) + func content( + appName: String, + displayGlucosePreference: DisplayGlucosePreference, + colorPalette: LoopUIColorPalette, + dynamicTypeSize: DynamicTypeSize, + next: @escaping () -> Void + ) -> some View { + firstStep.content( + appName: appName, + displayGlucosePreference: displayGlucosePreference, + colorPalette: colorPalette, + dynamicTypeSize: dynamicTypeSize, + next: next + ) } var cta: PresetsTraining.CTA? { diff --git a/Loop/Views/Presets/Training Content/PresetsTrainingView.swift b/Loop/Views/Presets/Training Content/PresetsTrainingView.swift index 504d8a901b..bb870123d6 100644 --- a/Loop/Views/Presets/Training Content/PresetsTrainingView.swift +++ b/Loop/Views/Presets/Training Content/PresetsTrainingView.swift @@ -10,19 +10,34 @@ import LoopKitUI import SwiftUI struct PresetsTrainingView: View { - + @Environment(\.appName) private var appName @Environment(\.colorPalette) private var colorPalette @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var dynamicTypeSize @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Bindable private var training: PresetsTraining @State private var confirmDismiss: Bool = false + @State private var showSkipToChapterSelector: Bool = false - init(trainingCompletion: PresetsTrainingCompletion) { - self.training = PresetsTraining(trainingCompletion: trainingCompletion) + private let onComplete: (() -> Void)? + + init( + navigationPath: [PresetsTraining.Step] = [], + startingAt: PresetsTraining.Chapter? = nil, + trainingCompletion: PresetsTrainingCompletion, + onComplete: (() -> Void)? = nil + ) { + self.training = PresetsTraining( + navigationPath: navigationPath, + startingAt: startingAt, + trainingCompletion: trainingCompletion + ) + + self.onComplete = onComplete } @ViewBuilder @@ -65,14 +80,27 @@ struct PresetsTrainingView: View { .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 16) + .onLongPressGesture { + guard FeatureFlags.allowDebugFeatures else { + return + } + + showSkipToChapterSelector = true + } Divider() } .padding(.bottom, 24) - step.content(appName: appName, displayGlucosePreference: displayGlucosePreference, colorPalette: colorPalette) - .padding(.bottom, 24) - .padding(.horizontal, 16) + step.content( + appName: appName, + displayGlucosePreference: displayGlucosePreference, + colorPalette: colorPalette, + dynamicTypeSize: dynamicTypeSize, + next: training.next + ) + .padding(.bottom, 24) + .padding(.horizontal, 16) if let cta = step.cta { Spacer(minLength: 0) @@ -89,6 +117,13 @@ struct PresetsTrainingView: View { training.next() } .buttonStyle(ActionButtonStyle()) + case .close: + Button("Close") { + close() + training.trainingCompletion.completedChapters[.trainingComplete] = true + onComplete?() + } + .buttonStyle(ActionButtonStyle()) case .closeOrContinue(let continueTo, let chapter): VStack(spacing: 12) { Button("Close Training") { @@ -114,10 +149,14 @@ struct PresetsTrainingView: View { .frame(minHeight: proxy.size.height, alignment: .top) } } + .background(step.contentBackground.ignoresSafeArea(.all)) .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(false) .toolbar { - ToolbarItem(placement: .topBarTrailing) { - closeButton + if step != .trainingComplete { + ToolbarItem(placement: .topBarTrailing) { + closeButton + } } } .alert(isPresented: $confirmDismiss) { @@ -128,5 +167,17 @@ struct PresetsTrainingView: View { secondaryButton: .destructive(Text("End"), action: { close() }) ) } + .confirmationDialog("Skip to Chapter", isPresented: $showSkipToChapterSelector) { + ForEach(PresetsTraining.Chapter.allCases, id: \.self) { chapter in + Button { + dismiss() + training.trainingCompletion.complete(to: chapter) + } label: { + chapter.title + } + } + } message: { + Text("Skip and complete training up to") + } } } From c8ae68aab57ab2e1ebf25aef6943c05d357133af Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 16 Sep 2025 13:09:17 -0700 Subject: [PATCH 286/421] [LOOP-5393 & LOOP-5418] Use correct current basal rate in Insulin Delivery Log & Small Preset Training Tweaks (#827) --- Loop/Managers/LoopDataManager.swift | 43 +++++++++++++------ .../InsulinDeliveryLogViewModel.swift | 16 ++++--- .../Components/IntensitySlider.swift | 1 + .../TherapySettingsExampleView.swift | 8 +++- .../Training Content/PresetsTraining.swift | 2 +- .../PresetsTrainingContent.swift | 13 +----- 6 files changed, 50 insertions(+), 33 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index a6e4060455..c20def227e 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -59,6 +59,19 @@ extension PumpManagerStatus.BasalDeliveryState { return nil } } + + func currentBasalRate(currentScheduledBasalRate: Double) -> Double? { + switch self { + case .tempBasal(let dose): + return dose.unitsPerHour + case .suspended: + return 0 + case .pumpInoperable: + return nil + default: + return currentScheduledBasalRate + } + } } protocol DosingManagerDelegate { @@ -1533,6 +1546,19 @@ extension LoopDataManager: DiagnosticReportGenerator { } extension LoopDataManager: LoopControl { + + func scheduledBasalRate(at date: Date = Date()) -> Double? { + settings.basalRateSchedule?.value(at: date) + } + + func currentBasalRate(at date: Date = Date()) -> Double? { + guard let scheduledBasalRate = scheduledBasalRate(at: date) else { + return nil + } + + return deliveryDelegate?.basalDeliveryState?.currentBasalRate(currentScheduledBasalRate: scheduledBasalRate) + } + var automatedTreatmentState: LoopKit.AutomatedTreatmentState? { guard let input = displayState.input else { return nil @@ -1540,19 +1566,8 @@ extension LoopDataManager: LoopControl { let now = Date() - let neutralBasal = input.basal.closestPrior(to: now)!.value - var scheduledBasalRate: Double - if let activeOverride = temporaryPresetsManager.presetHistory.activeOverride(at: now) { - scheduledBasalRate = neutralBasal / activeOverride.settings.effectiveInsulinNeedsScaleFactor - } else { - scheduledBasalRate = neutralBasal - } - - var currentBasalRate: Double - if let currentTempBasal = deliveryDelegate?.basalDeliveryState?.currentTempBasal { - currentBasalRate = currentTempBasal.unitsPerHour - } else { - currentBasalRate = scheduledBasalRate + guard let neutralBasal = input.basal.closestPrior(to: now)?.value, let currentBasalRate = currentBasalRate(at: now) else { + return nil } if currentBasalRate > neutralBasal { @@ -1572,7 +1587,7 @@ extension LoopDataManager: LoopControl { if !recentAutomaticBoluses.isEmpty { return .increasedInsulin } - return scheduledBasalRate != neutralBasal ? .neutralOverride : .neutralNoOverride + return scheduledBasalRate(at: now) != neutralBasal ? .neutralOverride : .neutralNoOverride } } } diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index 38d84894c9..c2fe7f0eef 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -171,7 +171,7 @@ class InsulinDeliveryLogViewModel { let lastAutoBolus = fetchLastAutoBolus(doses: doses) let decisions = await fetchDosingDecisions(doses.compactMap(\.decisionId)) - guard let currentBasalRate = fetchCurrentBasal(from: doses) else { + guard let currentBasalRate = fetchCurrentBasal() else { state = .error(.noBasalRateSchedule) return } @@ -223,16 +223,22 @@ class InsulinDeliveryLogViewModel { } } - private func fetchCurrentBasal(from doses: [DoseEntry]) -> DatedQuantity? { - guard let lastDose = doses.last(where: { $0.type == .basal || $0.type == .tempBasal }) else { + private func fetchCurrentBasal() -> DatedQuantity? { + let date = loopDataManager.lastLoopCompleted ?? Date() + + guard let scheduledBasalRate = loopDataManager.settings.basalRateSchedule?.value(at: date) else { return nil } + guard let currentBasalRate = pumpManager.status.basalDeliveryState?.currentBasalRate(currentScheduledBasalRate: scheduledBasalRate) else { + return nil + } + return DatedQuantity( - date: lastDose.startDate, + date: date, quantity: LoopQuantity( unit: .internationalUnitsPerHour, - doubleValue: lastDose.value + doubleValue: currentBasalRate ) ) } diff --git a/Loop/Views/Presets/Training Content/Components/IntensitySlider.swift b/Loop/Views/Presets/Training Content/Components/IntensitySlider.swift index 8c2bf565f0..3b322b574f 100644 --- a/Loop/Views/Presets/Training Content/Components/IntensitySlider.swift +++ b/Loop/Views/Presets/Training Content/Components/IntensitySlider.swift @@ -217,6 +217,7 @@ struct IntensityInfo: View { .foregroundStyle(.secondary) IntensitySlider(value: $value.animation(.easeInOut)) + .sensoryFeedback(lastValue > value ? .decrease : .increase, trigger: Int(value.rounded())) HStack { Text("Very Easy") diff --git a/Loop/Views/Presets/Training Content/Components/TherapySettingsExampleView.swift b/Loop/Views/Presets/Training Content/Components/TherapySettingsExampleView.swift index c3f939377a..ec4593809e 100644 --- a/Loop/Views/Presets/Training Content/Components/TherapySettingsExampleView.swift +++ b/Loop/Views/Presets/Training Content/Components/TherapySettingsExampleView.swift @@ -116,7 +116,7 @@ struct TherapySettingsExampleView: View { case .carbRatio(let double): Text("\(numberFormatter.string(from: double) ?? "0")").bold() + Text("\u{00a0}\(LoopUnit.gramsPerUnit.shortLocalizedUnitString())") case .isf(let double): - Text(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: double), includeUnit: false)).bold() + Text("\u{00a0}\(displayGlucosePreference.unit.shortLocalizedUnitString())") + Text(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: double), includeUnit: false)).bold() + Text("\u{00a0}\(displayGlucosePreference.unitRate.shortLocalizedUnitString())") case .correctionRange(let closedRange): Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: closedRange.lowerBound), includeUnit: false))").bold() + Text("\u{00a0}-\u{00a0}") + Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: closedRange.upperBound), includeUnit: false))").bold() + Text("\u{00a0}\(displayGlucosePreference.unit.shortLocalizedUnitString())") case .bolusRecommendation(let starting, let ending, let action): @@ -175,3 +175,9 @@ private extension View { } } } + +extension DisplayGlucosePreference { + var unitRate: LoopUnit { + unit.unitDivided(by: .internationalUnit) + } +} diff --git a/Loop/Views/Presets/Training Content/PresetsTraining.swift b/Loop/Views/Presets/Training Content/PresetsTraining.swift index bd0b915890..0499a2d1f8 100644 --- a/Loop/Views/Presets/Training Content/PresetsTraining.swift +++ b/Loop/Views/Presets/Training Content/PresetsTraining.swift @@ -182,7 +182,7 @@ public class PresetsTraining { case .customizingPresets(let customizingPresets): switch customizingPresets { case .customizingPresets: - NSLocalizedString("Part 2: Customizing Presets", comment: "") + NSLocalizedString("Customizing Presets", comment: "") case .overallInsulin: NSLocalizedString("Overall Insulin", comment: "") case .correctionRange: diff --git a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift index 57330c4227..8e15b11e7a 100644 --- a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift +++ b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift @@ -598,18 +598,7 @@ extension PresetsTraining.Step: PresetsTrainingContent { HStack(alignment: .firstTextBaseline, spacing: 16) { Bullet(color: .secondary) - VStack(alignment: .leading, spacing: 4) { - Text("Walking") - - HStack(alignment: .center, spacing: 2) { - Text("\(Image(systemName: "lightbulb.max"))") - - Text(" **Tip** Use your \(Image(systemName: "figure.walk")) **Walking** preset") - } - .padding(.vertical, 4) - .padding(.horizontal, 6) - .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) - } + Text("Walking") } .frame(maxWidth: .infinity, alignment: .leading) From 8bddda1e83ac3a27bf90f11a9200078db16ef496 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 16 Sep 2025 13:19:02 -0700 Subject: [PATCH 287/421] Automatic Xcode Signing (#829) --- Loop.xcconfig | 11 ---- Loop.xcodeproj/project.pbxproj | 96 ++++++++++++++++------------------ 2 files changed, 45 insertions(+), 62 deletions(-) diff --git a/Loop.xcconfig b/Loop.xcconfig index 9e1bb856ae..1d00650196 100644 --- a/Loop.xcconfig +++ b/Loop.xcconfig @@ -29,18 +29,7 @@ LOOP_LOCAL_CACHE_DURATION_DAYS = 7 LOOP_ENTITLEMENTS = Loop/Loop.entitlements // Code signing and provisioning [DEFAULT] -LOOP_CODE_SIGN_IDENTITY_DEBUG = Apple Development -LOOP_CODE_SIGN_IDENTITY_RELEASE = Apple Development -LOOP_CODE_SIGN_STYLE = Automatic LOOP_DEVELOPMENT_TEAM = -LOOP_PROVISIONING_PROFILE_SPECIFIER_DEBUG = -LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_DEBUG = -LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_DEBUG = -LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_DEBUG = -LOOP_PROVISIONING_PROFILE_SPECIFIER_RELEASE = -LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_RELEASE = -LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_RELEASE = -LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_RELEASE = // Min iOS Version [DEFAULT] IPHONEOS_DEPLOYMENT_TARGET = 15.1 diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index b92592e2ad..2e59f87a31 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -3196,7 +3196,6 @@ 43776F8B1B8022E90074EA36 = { CreatedOnToolsVersion = 7.0; LastSwiftMigration = 1020; - ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 1; @@ -3218,7 +3217,6 @@ 43A943711B926B7B0051FA24 = { CreatedOnToolsVersion = 7.0; LastSwiftMigration = 0800; - ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 0; @@ -3231,7 +3229,6 @@ 43A9437D1B926B7B0051FA24 = { CreatedOnToolsVersion = 7.0; LastSwiftMigration = 1020; - ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 0; @@ -3270,9 +3267,6 @@ LastSwiftMigration = 1020; ProvisioningStyle = Automatic; }; - E9B07F7B253BBA6500BAD8F8 = { - ProvisioningStyle = Automatic; - }; }; }; buildConfigurationList = 43776F871B8022E90074EA36 /* Build configuration list for PBXProject "Loop" */; @@ -4728,9 +4722,9 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CODE_SIGN_ENTITLEMENTS = "Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; GCC_DYNAMIC_NO_PIC = NO; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GENERATE_INFOPLIST_FILE = NO; @@ -4745,7 +4739,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWidgetExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WIDGET_EXTENSION_DEBUG)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -4776,8 +4770,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CODE_SIGN_ENTITLEMENTS = "Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -4793,7 +4787,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWidgetExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WIDGET_EXTENSION_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -5036,8 +5030,8 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -5051,7 +5045,7 @@ "OTHER_SWIFT_FLAGS[sdk=iphonesimulator*]" = "-D IOS_SIMULATOR -D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_DEBUG)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -5065,9 +5059,9 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5077,7 +5071,7 @@ OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -5091,9 +5085,9 @@ buildSettings = { ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5103,7 +5097,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch.watchkitextension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_DEBUG)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = watchos; SKIP_INSTALL = YES; SWIFT_INSTALL_OBJC_HEADER = NO; @@ -5118,9 +5112,9 @@ 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)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5130,7 +5124,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch.watchkitextension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = watchos; SKIP_INSTALL = YES; SWIFT_INSTALL_OBJC_HEADER = NO; @@ -5144,9 +5138,9 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -5156,7 +5150,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_DEBUG)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; @@ -5167,9 +5161,9 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -5179,7 +5173,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; @@ -5472,8 +5466,8 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -5485,7 +5479,7 @@ OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -5498,9 +5492,9 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; @@ -5510,7 +5504,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; @@ -5522,9 +5516,9 @@ 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)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5534,7 +5528,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch.watchkitextension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = watchos; SKIP_INSTALL = YES; SWIFT_INSTALL_OBJC_HEADER = NO; @@ -5562,9 +5556,9 @@ 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)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; GCC_DYNAMIC_NO_PIC = NO; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GENERATE_INFOPLIST_FILE = NO; @@ -5579,7 +5573,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWidgetExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WIDGET_EXTENSION_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -5596,9 +5590,9 @@ 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)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; @@ -5609,7 +5603,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).Loop-Intent-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_INTENT_EXTENSION_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -5707,9 +5701,9 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; - CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; - CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; @@ -5720,7 +5714,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).Loop-Intent-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_INTENT_EXTENSION_DEBUG)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -5734,9 +5728,9 @@ 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)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; @@ -5747,7 +5741,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).Loop-Intent-Extension"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_INTENT_EXTENSION_RELEASE)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; From 1b2e13d1af3c73cd7c7157ca9b49b375dd6ca955 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 24 Sep 2025 09:22:17 -0500 Subject: [PATCH 288/421] Fix bolus notification (#830) --- Loop/Managers/NotificationManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index bf344b0936..b69a384919 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -128,7 +128,7 @@ extension NotificationManager { ] if let decisionId { - notification.userInfo[LoopNotificationUserInfoKey.decisionId.rawValue] = decisionId + notification.userInfo[LoopNotificationUserInfoKey.decisionId.rawValue] = decisionId.uuidString } let request = UNNotificationRequest( From d213403d220f6c79c9a92b1e23e6bba7ba793235 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 24 Sep 2025 15:55:13 -0500 Subject: [PATCH 289/421] LOOP-5327 - Presets Watch Updates (#828) * New hosting controller * SwiftUI version of watch header complete * SwiftUI buttons for watch actions * Rename * Fetch settings update on watch app start * Watch preset card * Preset details view, scroll to start * Enable/Disable overrides on watch, cleanup concurrent premeal and preset attributes * Connect bolus and carb buttons on swiftui interface * Remove old action view controller from watch * Confirm preset activation on the watch * Reorg watch comm models into LoopCore * Merge watchkit and watchkit extension * Cleanup * Remove carb/bolus flow controller and usage so we can stay in swiftui * fix table controllers * Embed chart in SwiftUI view * Add label details to swiftui chart view * Carb list in swiftui * Fix watch app icon * Confirming preset page nav updates for design * Remove debugging code * Fix warnings, and remove storyboard * Remove deprecated key and use complicationDescriptors, and fix text on graph complication * Recommended project updates * Tests passing * Design updates * Add missing icons * Ensure bolus recommendation is current, and fix bug with double carbs * Fix initial recommendation for bolusing without carbs, and fix animation from carb to bolus screen --- .../Extensions/UserDefaults+LoopIntents.swift | 1 + Common/Models/LoopSettingsUserInfo.swift | 99 --- Loop.xcodeproj/project.pbxproj | 732 +++++++----------- .../xcschemes/DoseMathTests.xcscheme | 2 +- .../xcschemes/Loop Intent Extension.xcscheme | 2 +- .../xcschemes/Loop Status Extension.xcscheme | 2 +- .../xcshareddata/xcschemes/Loop.xcscheme | 2 +- .../xcshareddata/xcschemes/LoopTests.xcscheme | 2 +- .../SmallStatusWidgetExtension.xcscheme | 2 +- .../xcshareddata/xcschemes/WatchApp.xcscheme | 34 +- .../SettingsStore+SimulatedCoreData.swift | 8 - .../UserNotificationAlertScheduler.swift | 8 + Loop/Managers/DeviceDataManager.swift | 10 +- Loop/Managers/ExtensionDataManager.swift | 7 +- Loop/Managers/LoopAppManager.swift | 3 +- Loop/Managers/LoopDataManager.swift | 4 +- Loop/Managers/NotificationManager.swift | 25 +- Loop/Managers/OnboardingManager.swift | 1 + Loop/Managers/TemporaryPresetsManager.swift | 116 +-- Loop/Managers/WatchDataManager.swift | 86 +- Loop/Models/WatchContext+LoopKit.swift | 1 + .../StatusTableViewController.swift | 9 - Loop/View Models/StatusTableViewModel.swift | 1 + .../InsulinDeliveryOverview.swift | 1 + .../Components/EditPresetDurationView.swift | 4 +- .../Views/Presets/Components/PresetCard.swift | 65 +- .../Presets/Components/PresetDetentView.swift | 20 +- Loop/Views/Presets/CreatePresetView.swift | 2 +- Loop/Views/Presets/DurationPickerView.swift | 1 + Loop/Views/Presets/EditPresetView.swift | 1 + Loop/Views/Presets/NewCustomPreset.swift | 1 + .../{Components => }/PresetSymbolView.swift | 0 Loop/Views/Presets/PresetsView.swift | 1 + LoopCore/GetBolusRecommendationUserInfo.swift | 51 ++ .../Models/AcknowledgeAlertUserInfo.swift | 46 ++ .../Models/CarbBackfillRequestUserInfo.swift | 16 +- .../GlucoseBackfillRequestUserInfo.swift | 16 +- .../Models/IntentExtensionInfo.swift | 14 +- LoopCore/Models/LoopSettingsUserInfo.swift | 54 ++ .../Models/NotificationActionSelection.swift | 22 +- .../Models/SetBolusUserInfo.swift | 30 +- LoopCore/Models/SetPresetUserInfo.swift | 51 ++ LoopCore/Models/SettingsRequestUserInfo.swift | 37 + .../SupportedBolusVolumesUserInfo.swift | 16 +- .../Models/WatchContext.swift | 120 ++- .../Models/WatchContextRequestUserInfo.swift | 4 +- .../Models/WatchPredictedGlucose.swift | 13 +- LoopCore/NotificationManager.swift | 16 + LoopCore/PotentialCarbEntryUserInfo.swift | 46 -- .../SelectablePreset.swift | 158 +++- .../TemporaryPresetsManagerTests.swift | 2 +- .../CarbBackfillRequestUserInfoTests.swift | 2 +- LoopTests/Models/SetBolusUserInfoTests.swift | 1 + .../ComplicationController.swift | 32 +- .../Controllers/ActionHUDController.swift | 264 ------- .../CarbAndBolusFlowController.swift | 72 -- .../Controllers/CarbEntryListController.swift | 122 --- .../Controllers/ChartHUDController.swift | 212 ----- .../Controllers/HUDInterfaceController.swift | 120 --- .../Controllers/HUDRowController.swift | 126 --- .../Controllers/NotificationController.swift | 32 - .../OnOffSelectionController.swift | 29 - .../OverrideSelectionController.swift | 71 -- .../PresetConfirmHostingController.swift | 17 + WatchApp Extension/ExtensionDelegate.swift | 96 ++- .../Extensions/CLKComplicationTemplate.swift | 3 +- ...EnvironmentValues+GlucoseDisplayUnit.swift | 22 + WatchApp Extension/Extensions/UIColor.swift | 9 +- WatchApp Extension/Extensions/WCSession.swift | 89 +-- .../Extensions/WatchContext+WatchApp.swift | 1 + WatchApp Extension/Info.plist | 61 -- .../Managers/ComplicationChartManager.swift | 21 +- .../Managers/LoopDataManager.swift | 162 +++- .../Models/CarbAbsorptionTime.swift | 0 .../Models/GlucoseChartData.swift | 17 +- .../Models/PendingPresetReminder.swift | 14 + .../Scenes/GlucoseChartScene.swift | 13 +- .../CarbAndBolusFlowViewModel.swift | 183 ++--- .../View Models/OnOffSelectionViewModel.swift | 39 - WatchApp Extension/Views/ActionButton.swift | 1 - .../Views/ActiveOverrideView.swift | 167 ++++ .../Views/Carb Entry & Bolus/BolusArrow.swift | 2 +- .../BolusConfirmationVisual.swift | 2 +- .../Carb Entry & Bolus/CarbAndBolusFlow.swift | 43 +- WatchApp Extension/Views/CarbList.swift | 96 +++ WatchApp Extension/Views/ChartPageView.swift | 182 +++++ .../Views/CircleTintedButton.swift | 66 ++ .../Views/CircularProgressWithCheckmark.swift | 36 + .../Views/Extensions/Color.swift | 7 + .../Extensions/Environment+SizeClass.swift | 36 +- .../Views/Extensions/PeriodicPublisher.swift | 2 +- WatchApp Extension/Views/LabelValueRow.swift | 27 + WatchApp Extension/Views/LoopCircleView.swift | 60 ++ WatchApp Extension/Views/LoopHeader.swift | 43 + .../Views/OnOffSelectionView.swift | 84 -- .../Views/PresetActivateButtonConfirm.swift | 46 ++ .../Views/PresetActivateCrownConfirm.swift | 109 +++ .../Views/PresetConfirmationView.swift | 88 +++ .../Views/PresetDetailView.swift | 67 ++ .../Views/PresetWatchCard.swift | 141 ++++ .../Views/PresetsListView.swift | 35 + WatchApp Extension/Views/PresetsView.swift | 43 + .../Views/WatchActionsView.swift | 86 ++ .../en.lproj/ckcomplication.strings | 19 + WatchApp/Base.lproj/InfoPlist.strings | 6 - WatchApp/Base.lproj/Interface.storyboard | 445 ----------- WatchApp/ContentView.swift | 48 ++ .../Contents.json | 0 .../AppIcon.appiconset/Contents.json | 112 ++- .../icon-watchos-117x117@2x.png | Bin 0 -> 22468 bytes .../icon-watchos-129x129@2x.png | Bin 0 -> 19963 bytes .../icon-watchos-33x33@2x.png | Bin 0 -> 3826 bytes .../icon-watchos-46x46@2x.png | Bin 0 -> 5689 bytes .../icon-watchos-51x51@2x.png | Bin 0 -> 6674 bytes .../icon-watchos-54x54@2x.png | Bin 0 -> 7146 bytes .../DerivedAssetsBase.xcassets/Contents.json | 6 +- WatchApp/Info.plist | 14 +- WatchApp/LoopWatchApp.swift | 22 + WatchApp/ar.lproj/InfoPlist.strings | 6 - WatchApp/ar.lproj/Interface.strings | 60 -- WatchApp/cs.lproj/Interface.strings | 6 - WatchApp/da.lproj/InfoPlist.strings | 6 - WatchApp/da.lproj/Interface.strings | 60 -- WatchApp/de.lproj/InfoPlist.strings | 6 - WatchApp/de.lproj/Interface.strings | 60 -- WatchApp/en.lproj/Interface.strings | 15 - WatchApp/es.lproj/InfoPlist.strings | 6 - WatchApp/es.lproj/Interface.strings | 60 -- WatchApp/fi.lproj/InfoPlist.strings | 6 - WatchApp/fi.lproj/Interface.strings | 60 -- WatchApp/fr.lproj/InfoPlist.strings | 6 - WatchApp/fr.lproj/Interface.strings | 60 -- WatchApp/he.lproj/InfoPlist.strings | 6 - WatchApp/he.lproj/Interface.strings | 60 -- WatchApp/hi.lproj/Interface.strings | 6 - WatchApp/it.lproj/InfoPlist.strings | 6 - WatchApp/it.lproj/Interface.strings | 60 -- WatchApp/ja.lproj/InfoPlist.strings | 6 - WatchApp/ja.lproj/Interface.strings | 60 -- WatchApp/nb.lproj/InfoPlist.strings | 6 - WatchApp/nb.lproj/Interface.strings | 60 -- WatchApp/nl.lproj/InfoPlist.strings | 6 - WatchApp/nl.lproj/Interface.strings | 60 -- WatchApp/pl.lproj/InfoPlist.strings | 6 - WatchApp/pl.lproj/Interface.strings | 60 -- WatchApp/pt-BR.lproj/InfoPlist.strings | 6 - WatchApp/pt-BR.lproj/Interface.strings | 60 -- WatchApp/ro.lproj/InfoPlist.strings | 6 - WatchApp/ro.lproj/Interface.strings | 60 -- WatchApp/ru.lproj/InfoPlist.strings | 6 - WatchApp/ru.lproj/Interface.strings | 60 -- WatchApp/sk.lproj/InfoPlist.strings | 3 - WatchApp/sk.lproj/Interface.strings | 15 - WatchApp/sv.lproj/InfoPlist.strings | 6 - WatchApp/sv.lproj/Interface.strings | 60 -- WatchApp/tr.lproj/InfoPlist.strings | 6 - WatchApp/tr.lproj/Interface.strings | 60 -- WatchApp/vi.lproj/InfoPlist.strings | 6 - WatchApp/vi.lproj/Interface.strings | 60 -- WatchApp/zh-Hans.lproj/Interface.strings | 60 -- 160 files changed, 2942 insertions(+), 4287 deletions(-) delete mode 100644 Common/Models/LoopSettingsUserInfo.swift rename Loop/Views/Presets/{Components => }/PresetSymbolView.swift (100%) create mode 100644 LoopCore/GetBolusRecommendationUserInfo.swift create mode 100644 LoopCore/Models/AcknowledgeAlertUserInfo.swift rename {Common => LoopCore}/Models/CarbBackfillRequestUserInfo.swift (67%) rename {Common => LoopCore}/Models/GlucoseBackfillRequestUserInfo.swift (67%) rename {Common => LoopCore}/Models/IntentExtensionInfo.swift (64%) create mode 100644 LoopCore/Models/LoopSettingsUserInfo.swift rename {Common => LoopCore}/Models/NotificationActionSelection.swift (64%) rename {Common => LoopCore}/Models/SetBolusUserInfo.swift (63%) create mode 100644 LoopCore/Models/SetPresetUserInfo.swift create mode 100644 LoopCore/Models/SettingsRequestUserInfo.swift rename {Common => LoopCore}/Models/SupportedBolusVolumesUserInfo.swift (70%) rename {Common => LoopCore}/Models/WatchContext.swift (58%) rename {Common => LoopCore}/Models/WatchContextRequestUserInfo.swift (90%) rename {Common => LoopCore}/Models/WatchPredictedGlucose.swift (81%) create mode 100644 LoopCore/NotificationManager.swift delete mode 100644 LoopCore/PotentialCarbEntryUserInfo.swift rename {Loop/Models => LoopCore}/SelectablePreset.swift (64%) delete mode 100644 WatchApp Extension/Controllers/ActionHUDController.swift delete mode 100644 WatchApp Extension/Controllers/CarbAndBolusFlowController.swift delete mode 100644 WatchApp Extension/Controllers/CarbEntryListController.swift delete mode 100644 WatchApp Extension/Controllers/ChartHUDController.swift delete mode 100644 WatchApp Extension/Controllers/HUDInterfaceController.swift delete mode 100644 WatchApp Extension/Controllers/HUDRowController.swift delete mode 100644 WatchApp Extension/Controllers/NotificationController.swift delete mode 100644 WatchApp Extension/Controllers/OnOffSelectionController.swift delete mode 100644 WatchApp Extension/Controllers/OverrideSelectionController.swift create mode 100644 WatchApp Extension/Controllers/PresetConfirmHostingController.swift create mode 100644 WatchApp Extension/Extensions/EnvironmentValues+GlucoseDisplayUnit.swift delete mode 100644 WatchApp Extension/Info.plist rename {Common => WatchApp Extension}/Models/CarbAbsorptionTime.swift (100%) create mode 100644 WatchApp Extension/Models/PendingPresetReminder.swift delete mode 100644 WatchApp Extension/View Models/OnOffSelectionViewModel.swift create mode 100644 WatchApp Extension/Views/ActiveOverrideView.swift create mode 100644 WatchApp Extension/Views/CarbList.swift create mode 100644 WatchApp Extension/Views/ChartPageView.swift create mode 100644 WatchApp Extension/Views/CircleTintedButton.swift create mode 100644 WatchApp Extension/Views/CircularProgressWithCheckmark.swift create mode 100644 WatchApp Extension/Views/LabelValueRow.swift create mode 100644 WatchApp Extension/Views/LoopCircleView.swift create mode 100644 WatchApp Extension/Views/LoopHeader.swift delete mode 100644 WatchApp Extension/Views/OnOffSelectionView.swift create mode 100644 WatchApp Extension/Views/PresetActivateButtonConfirm.swift create mode 100644 WatchApp Extension/Views/PresetActivateCrownConfirm.swift create mode 100644 WatchApp Extension/Views/PresetConfirmationView.swift create mode 100644 WatchApp Extension/Views/PresetDetailView.swift create mode 100644 WatchApp Extension/Views/PresetWatchCard.swift create mode 100644 WatchApp Extension/Views/PresetsListView.swift create mode 100644 WatchApp Extension/Views/PresetsView.swift create mode 100644 WatchApp Extension/Views/WatchActionsView.swift create mode 100644 WatchApp Extension/en.lproj/ckcomplication.strings delete mode 100644 WatchApp/Base.lproj/InfoPlist.strings delete mode 100644 WatchApp/Base.lproj/Interface.storyboard create mode 100644 WatchApp/ContentView.swift rename WatchApp/DerivedAssetsBase.xcassets/{accent.colorset => AccentColor.colorset}/Contents.json (100%) create mode 100644 WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-117x117@2x.png create mode 100644 WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-129x129@2x.png create mode 100644 WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-33x33@2x.png create mode 100644 WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-46x46@2x.png create mode 100644 WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-51x51@2x.png create mode 100644 WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-54x54@2x.png create mode 100644 WatchApp/LoopWatchApp.swift delete mode 100644 WatchApp/ar.lproj/InfoPlist.strings delete mode 100644 WatchApp/ar.lproj/Interface.strings delete mode 100644 WatchApp/cs.lproj/Interface.strings delete mode 100644 WatchApp/da.lproj/InfoPlist.strings delete mode 100644 WatchApp/da.lproj/Interface.strings delete mode 100644 WatchApp/de.lproj/InfoPlist.strings delete mode 100644 WatchApp/de.lproj/Interface.strings delete mode 100644 WatchApp/en.lproj/Interface.strings delete mode 100644 WatchApp/es.lproj/InfoPlist.strings delete mode 100644 WatchApp/es.lproj/Interface.strings delete mode 100644 WatchApp/fi.lproj/InfoPlist.strings delete mode 100644 WatchApp/fi.lproj/Interface.strings delete mode 100644 WatchApp/fr.lproj/InfoPlist.strings delete mode 100644 WatchApp/fr.lproj/Interface.strings delete mode 100644 WatchApp/he.lproj/InfoPlist.strings delete mode 100644 WatchApp/he.lproj/Interface.strings delete mode 100644 WatchApp/hi.lproj/Interface.strings delete mode 100644 WatchApp/it.lproj/InfoPlist.strings delete mode 100644 WatchApp/it.lproj/Interface.strings delete mode 100644 WatchApp/ja.lproj/InfoPlist.strings delete mode 100644 WatchApp/ja.lproj/Interface.strings delete mode 100644 WatchApp/nb.lproj/InfoPlist.strings delete mode 100644 WatchApp/nb.lproj/Interface.strings delete mode 100644 WatchApp/nl.lproj/InfoPlist.strings delete mode 100644 WatchApp/nl.lproj/Interface.strings delete mode 100644 WatchApp/pl.lproj/InfoPlist.strings delete mode 100644 WatchApp/pl.lproj/Interface.strings delete mode 100644 WatchApp/pt-BR.lproj/InfoPlist.strings delete mode 100644 WatchApp/pt-BR.lproj/Interface.strings delete mode 100644 WatchApp/ro.lproj/InfoPlist.strings delete mode 100644 WatchApp/ro.lproj/Interface.strings delete mode 100644 WatchApp/ru.lproj/InfoPlist.strings delete mode 100644 WatchApp/ru.lproj/Interface.strings delete mode 100644 WatchApp/sk.lproj/InfoPlist.strings delete mode 100644 WatchApp/sk.lproj/Interface.strings delete mode 100644 WatchApp/sv.lproj/InfoPlist.strings delete mode 100644 WatchApp/sv.lproj/Interface.strings delete mode 100644 WatchApp/tr.lproj/InfoPlist.strings delete mode 100644 WatchApp/tr.lproj/Interface.strings delete mode 100644 WatchApp/vi.lproj/InfoPlist.strings delete mode 100644 WatchApp/vi.lproj/Interface.strings delete mode 100644 WatchApp/zh-Hans.lproj/Interface.strings diff --git a/Common/Extensions/UserDefaults+LoopIntents.swift b/Common/Extensions/UserDefaults+LoopIntents.swift index 07082d0615..7d04a47681 100644 --- a/Common/Extensions/UserDefaults+LoopIntents.swift +++ b/Common/Extensions/UserDefaults+LoopIntents.swift @@ -7,6 +7,7 @@ // import Foundation +import LoopCore extension UserDefaults { diff --git a/Common/Models/LoopSettingsUserInfo.swift b/Common/Models/LoopSettingsUserInfo.swift deleted file mode 100644 index 1f6e486711..0000000000 --- a/Common/Models/LoopSettingsUserInfo.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// LoopSettingsUserInfo.swift -// Loop -// -// Copyright © 2018 LoopKit Authors. All rights reserved. -// - -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: TemporaryPresetSettings(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 - } -} - - -extension LoopSettingsUserInfo: RawRepresentable { - typealias RawValue = [String: Any] - - static let name = "LoopSettingsUserInfo" - static let version = 1 - - init?(rawValue: RawValue) { - guard rawValue["v"] as? Int == LoopSettingsUserInfo.version, - rawValue["name"] as? String == LoopSettingsUserInfo.name, - let settingsRaw = rawValue["s"] as? LoopSettings.RawValue, - let loopSettings = LoopSettings(rawValue: settingsRaw) - else { - return nil - } - - self.loopSettings = loopSettings - - if let rawScheduleOverride = rawValue["o"] as? TemporaryScheduleOverride.RawValue { - self.scheduleOverride = TemporaryScheduleOverride(rawValue: rawScheduleOverride) - } else { - self.scheduleOverride = nil - } - - if let rawPreMealOverride = rawValue["p"] as? TemporaryScheduleOverride.RawValue { - self.preMealOverride = TemporaryScheduleOverride(rawValue: rawPreMealOverride) - } else { - self.preMealOverride = nil - } - } - - var rawValue: RawValue { - var raw: RawValue = [ - "v": LoopSettingsUserInfo.version, - "name": LoopSettingsUserInfo.name, - "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 2e59f87a31..24735c93c9 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C101947127DD473C004E7EB8 /* MockKitUI.framework */; }; 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142CB7582A60BF2E0075748A /* EditMode.swift */; }; @@ -30,7 +29,7 @@ 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */; }; 14B1735E28AED9EC006CCD7C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14B1735D28AED9EC006CCD7C /* WidgetKit.framework */; }; 14B1736028AED9EC006CCD7C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14B1735F28AED9EC006CCD7C /* SwiftUI.framework */; }; - 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 14B1737228AEDBF6006CCD7C /* BasalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1736E28AEDBF6006CCD7C /* BasalView.swift */; }; 14B1737328AEDBF6006CCD7C /* SystemStatusWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1736F28AEDBF6006CCD7C /* SystemStatusWidget.swift */; }; 14B1737428AEDBF6006CCD7C /* GlucoseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */; }; @@ -92,8 +91,6 @@ 4311FB9B1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */; }; 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */; }; 4326BA641F3A44D9007CCAD4 /* ChartLineModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326BA631F3A44D9007CCAD4 /* ChartLineModel.swift */; }; - 4328E01A1CFBE1DA00E199AA /* ActionHUDController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0151CFBE1DA00E199AA /* ActionHUDController.swift */; }; - 4328E01E1CFBE25F00E199AA /* CarbAndBolusFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E01D1CFBE25F00E199AA /* CarbAndBolusFlowController.swift */; }; 4328E0281CFBE2C500E199AA /* CLKComplicationTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */; }; 4328E02A1CFBE2C500E199AA /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0241CFBE2C500E199AA /* UIColor.swift */; }; 4328E02B1CFBE2C500E199AA /* WKAlertAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0251CFBE2C500E199AA /* WKAlertAction.swift */; }; @@ -107,23 +104,15 @@ 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4344629120A7C19800C4BE6F /* ButtonGroup.swift */; }; 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; 4345E3FC21F04911009E00E5 /* UIColor+HIG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */; }; - 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */; }; - 4345E40221F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */; }; - 4345E40421F68AD9009E00E5 /* TextRowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4345E40321F68AD9009E00E5 /* TextRowController.swift */; }; - 4345E40621F68E18009E00E5 /* CarbEntryListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4345E40521F68E18009E00E5 /* CarbEntryListController.swift */; }; + 4345E40121F67300009E00E5 /* GetBolusRecommendationUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* GetBolusRecommendationUserInfo.swift */; }; + 4345E40221F67300009E00E5 /* GetBolusRecommendationUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* GetBolusRecommendationUserInfo.swift */; }; 434FF1EE1CF27EEF000DB779 /* UITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434FF1ED1CF27EEF000DB779 /* UITableViewCell.swift */; }; - 43511CEE220FC61700566C63 /* HUDRowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43511CED220FC61700566C63 /* HUDRowController.swift */; }; 43517917230A0E1A0072ECC0 /* WKInterfaceLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43517916230A0E1A0072ECC0 /* WKInterfaceLabel.swift */; }; - 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; - 435400351C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; 436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436A0DA41D236A2A00104B24 /* LoopError.swift */; }; - 4372E484213A63FB0068E043 /* ChartHUDController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */; }; 4372E487213C86240068E043 /* SampleValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E486213C86240068E043 /* SampleValue.swift */; }; 4372E488213C862B0068E043 /* SampleValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E486213C86240068E043 /* SampleValue.swift */; }; 4372E48B213CB5F00068E043 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48A213CB5F00068E043 /* Double.swift */; }; 4372E48C213CB6750068E043 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48A213CB5F00068E043 /* Double.swift */; }; - 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */; }; - 4372E491213D05F90068E043 /* LoopSettingsUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */; }; 4372E492213D956C0068E043 /* GlucoseRangeSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */; }; 4372E496213DCDD30068E043 /* GlucoseChartValueHashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E495213DCDD30068E043 /* GlucoseChartValueHashable.swift */; }; 4374B5EF209D84BF00D17AA8 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; @@ -148,10 +137,7 @@ 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */; }; 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E201EB6DBDD000736CC /* LoopChartsTableViewController.swift */; }; 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A567681C94880B00334FAC /* LoopDataManager.swift */; }; - 43A943761B926B7B0051FA24 /* Interface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 43A943741B926B7B0051FA24 /* Interface.storyboard */; }; - 43A9437F1B926B7B0051FA24 /* WatchApp Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 43A943881B926B7B0051FA24 /* ExtensionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A943871B926B7B0051FA24 /* ExtensionDelegate.swift */; }; - 43A9438A1B926B7B0051FA24 /* NotificationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A943891B926B7B0051FA24 /* NotificationController.swift */; }; 43A9438E1B926B7B0051FA24 /* ComplicationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A9438D1B926B7B0051FA24 /* ComplicationController.swift */; }; 43A943901B926B7B0051FA24 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43A9438F1B926B7B0051FA24 /* Assets.xcassets */; }; 43A943941B926B7B0051FA24 /* WatchApp.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 43A943721B926B7B0051FA24 /* WatchApp.app */; }; @@ -201,9 +187,7 @@ 4B60626D287E286000BF8BBB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4B60626A287E286000BF8BBB /* Localizable.strings */; }; 4B67E2C8289B4EDB002D92AF /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4B67E2C6289B4EDB002D92AF /* InfoPlist.strings */; }; 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */; }; - 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */; }; 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */; }; - 4F11D3C320DD84DB006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */; }; 4F11D3C420DD881A006E072C /* WatchHistoricalGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */; }; 4F2C15741E0209F500E160D4 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 4F2C15811E0495B200E160D4 /* WatchContext+WatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2C15801E0495B200E160D4 /* WatchContext+WatchApp.swift */; }; @@ -227,14 +211,9 @@ 4F7528AA1DFE215100C322D6 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 4F75F00220FCFE8C00B5570E /* GlucoseChartScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F75F00120FCFE8C00B5570E /* GlucoseChartScene.swift */; }; 4F7E8AC520E2AB9600AEA65E /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC420E2AB9600AEA65E /* Date.swift */; }; - 4F7E8AC720E2AC0300AEA65E /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; - 4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; - 4F82655020E69F9A0031A8F5 /* HUDInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */; }; 4FC8C8011DEB93E400A1452E /* 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 */; }; 7D7076451FE06EE0004AC8EA /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076471FE06EE0004AC8EA /* InfoPlist.strings */; }; @@ -273,7 +252,6 @@ 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A62D09053A00CB271F /* StatusTableView.swift */; }; 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A82D09800700CB271F /* PresetDetentView.swift */; }; 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; - 84DEF35D2E566757006126F9 /* PresetSymbolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */; }; 84E8BBC42CC9B9890078E6CF /* AdjustedGlucoseRangeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */; }; 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */; }; 84E8BBC82CC9D34B0078E6CF /* TherapySettingsExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */; }; @@ -286,7 +264,6 @@ 892A5D59222F0A27008961AB /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */; }; 892FB4CD22040104005293EC /* OverridePresetRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FB4CC22040104005293EC /* OverridePresetRow.swift */; }; - 892FB4CF220402C0005293EC /* OverrideSelectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FB4CE220402C0005293EC /* OverrideSelectionController.swift */; }; 894F6DD3243BCBDB00CCE676 /* Environment+SizeClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894F6DD2243BCBDB00CCE676 /* Environment+SizeClass.swift */; }; 894F6DD7243C047300CCE676 /* View+Position.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894F6DD6243C047300CCE676 /* View+Position.swift */; }; 894F6DD9243C060600CCE676 /* ScalablePositionedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894F6DD8243C060600CCE676 /* ScalablePositionedText.swift */; }; @@ -307,8 +284,6 @@ 898ECA65218ABD9B001E9D35 /* CGRect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA64218ABD9A001E9D35 /* CGRect.swift */; }; 898ECA69218ABDA9001E9D35 /* CLKTextProvider+Compound.m in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */; }; 899433B823FE129800FA4BEA /* OverrideBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899433B723FE129700FA4BEA /* OverrideBadgeView.swift */; }; - 89A1B66E24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */; }; - 89A1B66F24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */; }; 89A605E324327DFE009C1096 /* CarbAmountInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605E224327DFE009C1096 /* CarbAmountInput.swift */; }; 89A605E524327F45009C1096 /* DoseVolumeInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605E424327F45009C1096 /* DoseVolumeInput.swift */; }; 89A605E72432860C009C1096 /* PeriodicPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605E62432860C009C1096 /* PeriodicPublisher.swift */; }; @@ -342,14 +317,11 @@ A91E4C2124F867A700BE9213 /* StoredAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91E4C2024F867A700BE9213 /* StoredAlertTests.swift */; }; A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */; }; A9347F2F24E7508A00C99C34 /* WatchHistoricalCarbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F2E24E7508A00C99C34 /* WatchHistoricalCarbs.swift */; }; - A9347F3124E7521800C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */; }; - A9347F3224E7522400C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */; }; A9347F3324E7522900C99C34 /* WatchHistoricalCarbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F2E24E7508A00C99C34 /* WatchHistoricalCarbs.swift */; }; A963B27A252CEBAE0062AA12 /* SetBolusUserInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */; }; A966152623EA5A26005D8B29 /* DefaultAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152423EA5A25005D8B29 /* DefaultAssets.xcassets */; }; A966152723EA5A26005D8B29 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152523EA5A25005D8B29 /* DerivedAssets.xcassets */; }; A966152A23EA5A37005D8B29 /* DefaultAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152823EA5A37005D8B29 /* DefaultAssets.xcassets */; }; - A966152B23EA5A37005D8B29 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */; }; A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = A967D94B24F99B9300CDDF8A /* OutputStream.swift */; }; A96DAC242838325900D94E38 /* DiagnosticLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A96DAC232838325900D94E38 /* DiagnosticLog.swift */; }; A96DAC2A2838EF8A00D94E38 /* DiagnosticLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A96DAC292838EF8A00D94E38 /* DiagnosticLogTests.swift */; }; @@ -389,7 +361,6 @@ A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F703762489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift */; }; A9FB75F1252BE320004C7D3F /* BolusDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */; }; B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4001CED28CBBC82002FB414 /* AlertManagementView.swift */; }; - B405E35924D2A75B00DD058D /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */; }; B405E35A24D2B1A400DD058D /* HUDAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */; }; B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */; }; B42C951424A3C76000857C73 /* CGMStatusHUDViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */; }; @@ -436,7 +407,15 @@ C10509752D8B309400118A37 /* Environment+SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509742D8B308E00118A37 /* Environment+SettingsManager.swift */; }; C10509772D8B591200118A37 /* StatusTableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509762D8B590D00118A37 /* StatusTableViewModel.swift */; }; C10509792D8B636900118A37 /* Environment+TemporaryPresetsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509782D8B635F00118A37 /* Environment+TemporaryPresetsManager.swift */; }; - C105097B2D8B947B00118A37 /* SelectablePreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105097A2D8B947700118A37 /* SelectablePreset.swift */; }; + C10C57E52E6F767A00A4825C /* CircleTintedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57E42E6F767500A4825C /* CircleTintedButton.swift */; }; + C10C57EC2E7070FF00A4825C /* PresetsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57EB2E7070FB00A4825C /* PresetsListView.swift */; }; + C10C57EE2E7081D200A4825C /* PresetDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57ED2E7081C900A4825C /* PresetDetailView.swift */; }; + C10C57F22E70851F00A4825C /* SelectablePreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105097A2D8B947700118A37 /* SelectablePreset.swift */; }; + C10C57F32E70851F00A4825C /* SelectablePreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105097A2D8B947700118A37 /* SelectablePreset.swift */; }; + C10C57F82E7085D600A4825C /* PresetSymbolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */; }; + C10C57FA2E708B2D00A4825C /* PresetWatchCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57F92E708B1D00A4825C /* PresetWatchCard.swift */; }; + C10C57FC2E70B8B900A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57FB2E70B87500A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift */; }; + C10C57FE2E71E87D00A4825C /* ActiveOverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57FD2E71E87400A4825C /* ActiveOverrideView.swift */; }; C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C110888C2A3913C600BA4898 /* BuildDetails.swift */; }; C11445B22DA98A3400034864 /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */; }; C11445B42DB2EBE400034864 /* ExistingPresetInsulinNeedsEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11445B32DB2EBDC00034864 /* ExistingPresetInsulinNeedsEdit.swift */; }; @@ -449,11 +428,19 @@ C11B9D64286779C000500CF8 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D61286779C000500CF8 /* MockKitUI.framework */; }; C11B9D65286779C000500CF8 /* MockKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D61286779C000500CF8 /* MockKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C11BD0552523CFED00236B08 /* SimpleBolusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */; }; - C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; - C1201E2D23ECDF3D002DA84A /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; C120CECC2D8CD6990050944B /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C120CECB2D8CD6970050944B /* Publisher.swift */; }; + C1275DD62E808E2F0013B99D /* LoopWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1275DD52E808E2C0013B99D /* LoopWatchApp.swift */; }; + C1275DD82E808E520013B99D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1275DD72E808E480013B99D /* ContentView.swift */; }; + C1275DDB2E8175B40013B99D /* PresetConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1275DDA2E8175AF0013B99D /* PresetConfirmationView.swift */; }; + C1275DDD2E8185990013B99D /* PresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1275DDC2E8185960013B99D /* PresetsView.swift */; }; + C1275DDE2E81FD470013B99D /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43F78D4B1C914197002152D1 /* LoopKit.framework */; }; + C1275DDF2E81FD470013B99D /* LoopKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 43F78D4B1C914197002152D1 /* LoopKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C1275DE22E81FD530013B99D /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1275DE12E81FD530013B99D /* LoopKit.framework */; }; + C1275E1C2E82269A0013B99D /* LoopKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C1275E1A2E82269A0013B99D /* LoopKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C1275E1F2E822CEE0013B99D /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */; }; C129BF4A2B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */; }; C129D3BF2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */; }; + C12D72402E4FBC5F00BD628A /* WatchActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12D723F2E4FBC5D00BD628A /* WatchActionsView.swift */; }; C13072BA2A76AF31009A7C58 /* live_capture_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */; }; C13255D6223E7BE2008AF50C /* BolusProgressTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */; }; C138C6F82C1B8A2C00F08F1A /* GlucoseCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = C138C6F72C1B8A2C00F08F1A /* GlucoseCondition.swift */; }; @@ -462,7 +449,7 @@ C14F68C92D4AC54300BC3B8D /* DurationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14F68C82D4AC54000BC3B8D /* DurationPickerView.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 */; }; + C1550B0C2E6F249A009369DC /* LoopCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1550B0B2E6F249A009369DC /* LoopCircleView.swift */; }; C15A8C492A7305B1009D736B /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; }; C1620D392DE0E5120033DEB5 /* NoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1620D382DE0E50D0033DEB5 /* NoticeView.swift */; }; C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */; }; @@ -484,6 +471,9 @@ C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824991E1999FA00D9D25C /* CaseCountable.swift */; }; C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */; }; C17824A51E1AD4D100D9D25C /* ManualBolusRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */; }; + C17D52022E7F03D0001D2AD2 /* LoopHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17D52012E7F03CF001D2AD2 /* LoopHeader.swift */; }; + C17D52042E7F0578001D2AD2 /* LabelValueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17D52032E7F0568001D2AD2 /* LabelValueRow.swift */; }; + C17D52082E7F0E1B001D2AD2 /* CarbList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17D52072E7F0E18001D2AD2 /* CarbList.swift */; }; C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; }; C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; }; C188599B2AF15E1B0010F21F /* DeviceDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */; }; @@ -504,12 +494,10 @@ C19C8BC428651EAE0056D5E4 /* LoopTestingKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C19C8BC228651EAE0056D5E4 /* LoopTestingKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C19C8BCE28651F520056D5E4 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; C19C8BCF28651F520056D5E4 /* LoopKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - C19C8C1E28663B040056D5E4 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628320A7A3BE00C4BE6F /* LoopKit.framework */; }; - C19C8C1F28663B040056D5E4 /* LoopKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628320A7A3BE00C4BE6F /* LoopKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - C19C8C21286776C20056D5E4 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C19C8C20286776C20056D5E4 /* LoopKit.framework */; }; + C19E23B42E8350D700C20D83 /* PresetActivateButtonConfirm.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E23B32E8350B900C20D83 /* PresetActivateButtonConfirm.swift */; }; + C19E23B62E83513400C20D83 /* PresetActivateCrownConfirm.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E23B52E83512A00C20D83 /* PresetActivateCrownConfirm.swift */; }; + C19E23B82E83566700C20D83 /* CircularProgressWithCheckmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E23B72E83566300C20D83 /* CircularProgressWithCheckmark.swift */; }; C19F48742560ABFB003632D7 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; - C1ABA1612E281D470049DF41 /* NotificationActionSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */; }; - C1ABA1622E281D470049DF41 /* NotificationActionSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */; }; C1AC03962D6E07D6004D4D2B /* CreatePresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC03952D6E07D3004D4D2B /* CreatePresetView.swift */; }; C1AC039A2D6E3C88004D4D2B /* InsulinScaleInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC03992D6E3C7B004D4D2B /* InsulinScaleInformationView.swift */; }; C1AC039C2D6E7551004D4D2B /* ExistingPresetRangeEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC039B2D6E751C004D4D2B /* ExistingPresetRangeEdit.swift */; }; @@ -519,7 +507,6 @@ 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 */; }; C1D0B6302986D4D90098D215 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0B62F2986D4D90098D215 /* LocalizedString.swift */; }; C1D0B6312986D4D90098D215 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0B62F2986D4D90098D215 /* LocalizedString.swift */; }; @@ -534,6 +521,37 @@ C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1E2773D224177C000354103 /* ClockKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */; }; C1E3DC4928595FAA00CA19FF /* SwiftCharts in Embed Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + C1ED6C612E79BBA5002F91C2 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C602E79BB9C002F91C2 /* NotificationManager.swift */; }; + C1ED6C622E79BBA5002F91C2 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C602E79BB9C002F91C2 /* NotificationManager.swift */; }; + C1ED6C642E7C6DB9002F91C2 /* SettingsRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57E82E705CCB00A4825C /* SettingsRequestUserInfo.swift */; }; + C1ED6C652E7C6E2F002F91C2 /* CarbBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */; }; + C1ED6C662E7C6E2F002F91C2 /* CarbBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */; }; + C1ED6C672E7C6E35002F91C2 /* SettingsRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57E82E705CCB00A4825C /* SettingsRequestUserInfo.swift */; }; + C1ED6C682E7C6E41002F91C2 /* GlucoseBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */; }; + C1ED6C692E7C6E41002F91C2 /* GlucoseBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */; }; + C1ED6C6A2E7C6E58002F91C2 /* LoopSettingsUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */; }; + C1ED6C6B2E7C6E58002F91C2 /* LoopSettingsUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */; }; + C1ED6C6C2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */; }; + C1ED6C6D2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */; }; + C1ED6C6E2E7C6EDE002F91C2 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; + C1ED6C6F2E7C6EDE002F91C2 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; + C1ED6C702E7C6F07002F91C2 /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; + C1ED6C712E7C6F07002F91C2 /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; + C1ED6C722E7C6F23002F91C2 /* SupportedBolusVolumesUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */; }; + C1ED6C732E7C6F23002F91C2 /* SupportedBolusVolumesUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */; }; + C1ED6C742E7C6F36002F91C2 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; + C1ED6C752E7C6F36002F91C2 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; + C1ED6C762E7C6F58002F91C2 /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; + C1ED6C772E7C6F58002F91C2 /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; + C1ED6C782E7C6FC7002F91C2 /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B455C7322BD14E25002B847E /* Comparable.swift */; }; + C1ED6C792E7C6FC7002F91C2 /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B455C7322BD14E25002B847E /* Comparable.swift */; }; + C1ED6C7A2E7C6FE5002F91C2 /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; + C1ED6C7B2E7C6FE6002F91C2 /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; + C1ED6C7D2E7C811A002F91C2 /* SetPresetUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C7C2E7C8113002F91C2 /* SetPresetUserInfo.swift */; }; + C1ED6C7E2E7C811A002F91C2 /* SetPresetUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C7C2E7C8113002F91C2 /* SetPresetUserInfo.swift */; }; + C1ED6C802E7C9C86002F91C2 /* PendingPresetReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C7F2E7C9C7A002F91C2 /* PendingPresetReminder.swift */; }; + C1ED6C822E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */; }; + C1ED6C832E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */; }; C1EE9E812A38D0FB0064784A /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = C1EE9E802A38D0FB0064784A /* BuildDetails.plist */; }; C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */; }; C1F00C60285A802A006302C5 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1F00C5F285A802A006302C5 /* SwiftCharts */; }; @@ -543,6 +561,7 @@ C1F2CAAC2B7A980600D7F581 /* BasalRelativeDose.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F2CAAB2B7A980600D7F581 /* BasalRelativeDose.swift */; }; C1F7822627CC056900C0919A /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F7822527CC056900C0919A /* SettingsManager.swift */; }; C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */; }; + C1FAD5192E7E0C3400F7FAD9 /* ChartPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FAD5182E7E0C3100F7FAD9 /* ChartPageView.swift */; }; C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428B217806A300FAB378 /* StateColorPalette.swift */; }; C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */; }; @@ -567,15 +586,10 @@ E98A55EF24EDD6E60008715D /* DosingDecisionStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55EE24EDD6E60008715D /* DosingDecisionStoreProtocol.swift */; }; E98A55F124EDD85E0008715D /* MockDosingDecisionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F024EDD85E0008715D /* MockDosingDecisionStore.swift */; }; E98A55F324EDD9530008715D /* MockSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F224EDD9530008715D /* MockSettingsStore.swift */; }; - E98A55F524EEE15A0008715D /* OnOffSelectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F424EEE15A0008715D /* OnOffSelectionController.swift */; }; - E98A55F724EEE1E10008715D /* OnOffSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F624EEE1E10008715D /* OnOffSelectionView.swift */; }; - E98A55F924EEFC200008715D /* OnOffSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F824EEFC200008715D /* OnOffSelectionViewModel.swift */; }; E9B07F7F253BBA6500BAD8F8 /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B07F7E253BBA6500BAD8F8 /* IntentHandler.swift */; }; - E9B07F94253BBA6500BAD8F8 /* Loop Intent Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + E9B07F94253BBA6500BAD8F8 /* Loop Intent Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; E9B07FEE253BBC7100BAD8F8 /* OverrideIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B07FED253BBC7100BAD8F8 /* OverrideIntentHandler.swift */; }; E9B08016253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */; }; - E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; - E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */; }; E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552129358C440076AB04 /* MealDetectionManager.swift */; }; E9B355292935919E0076AB04 /* MissedMealSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B35525293590980076AB04 /* MissedMealSettings.swift */; }; @@ -616,13 +630,6 @@ remoteGlobalIDString = 14B1735B28AED9EC006CCD7C; remoteInfo = SmallStatusWidgetExtension; }; - 43A943801B926B7B0051FA24 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 43776F841B8022E90074EA36 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 43A9437D1B926B7B0051FA24; - remoteInfo = "WatchApp Extension"; - }; 43A943921B926B7B0051FA24 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; @@ -651,7 +658,7 @@ remoteGlobalIDString = 4F75288A1DFE1DC600C322D6; remoteInfo = LoopUI; }; - C117ED70232EDB3200DA57CD /* PBXContainerItemProxy */ = { + C16E94F82E7DBBA600AA4E6E /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; proxyType = 1; @@ -675,17 +682,6 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 43A943981B926B7B0051FA24 /* Embed App Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - 43A9437F1B926B7B0051FA24 /* WatchApp Extension.appex in Embed App Extensions */, - ); - name = "Embed App Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; 43A9439C1B926B7B0051FA24 /* Embed Watch Content */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -723,21 +719,32 @@ dstSubfolderSpec = 10; files = ( 43C05CB221EBD88A006FB252 /* LoopCore.framework in Embed Frameworks */, - C19C8C1F28663B040056D5E4 /* LoopKit.framework in Embed Frameworks */, + C1275E1C2E82269A0013B99D /* LoopKit.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; - 4F70C1EC1DE8DCA8006380B7 /* Embed App Extensions */ = { + 4F70C1EC1DE8DCA8006380B7 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( - 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed App Extensions */, - E9B07F94253BBA6500BAD8F8 /* Loop Intent Extension.appex in Embed App Extensions */, + 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed Foundation Extensions */, + E9B07F94253BBA6500BAD8F8 /* Loop Intent Extension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + C1275DE02E81FD470013B99D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + C1275DDF2E81FD470013B99D /* LoopKit.framework in Embed Frameworks */, ); - name = "Embed App Extensions"; + name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; C1E3DC4828595FAA00CA19FF /* Embed Frameworks */ = { @@ -825,8 +832,6 @@ 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircleMaskView.swift; sourceTree = ""; }; 431E73471FF95A900069B5F7 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; 4326BA631F3A44D9007CCAD4 /* ChartLineModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartLineModel.swift; sourceTree = ""; }; - 4328E0151CFBE1DA00E199AA /* ActionHUDController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionHUDController.swift; sourceTree = ""; }; - 4328E01D1CFBE25F00E199AA /* CarbAndBolusFlowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlowController.swift; sourceTree = ""; }; 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CLKComplicationTemplate.swift; sourceTree = ""; }; 4328E0231CFBE2C500E199AA /* NSUserDefaults+WatchApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSUserDefaults+WatchApp.swift"; sourceTree = ""; }; 4328E0241CFBE2C500E199AA /* UIColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; @@ -844,12 +849,9 @@ 4345E3F721F03D2A009E00E5 /* DatesAndNumberCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatesAndNumberCell.swift; sourceTree = ""; }; 4345E3F921F0473B009E00E5 /* TextCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextCell.swift; sourceTree = ""; }; 4345E3FD21F04A50009E00E5 /* DateIntervalFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateIntervalFormatter.swift; sourceTree = ""; }; - 4345E40321F68AD9009E00E5 /* TextRowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRowController.swift; sourceTree = ""; }; - 4345E40521F68E18009E00E5 /* CarbEntryListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryListController.swift; sourceTree = ""; }; 434F54561D287FDB002A9274 /* NibLoadable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NibLoadable.swift; sourceTree = ""; }; 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentifiableClass.swift; sourceTree = ""; }; 434FF1ED1CF27EEF000DB779 /* UITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableViewCell.swift; sourceTree = ""; }; - 43511CED220FC61700566C63 /* HUDRowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDRowController.swift; sourceTree = ""; }; 43517916230A0E1A0072ECC0 /* WKInterfaceLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKInterfaceLabel.swift; sourceTree = ""; }; 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetBolusUserInfo.swift; sourceTree = ""; }; 436A0DA41D236A2A00104B24 /* LoopError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopError.swift; sourceTree = ""; }; @@ -891,14 +893,10 @@ 43A567681C94880B00334FAC /* LoopDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = LoopDataManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 43A8EC6E210E622600A81379 /* CGMBLEKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CGMBLEKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 43A943721B926B7B0051FA24 /* WatchApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WatchApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 43A943751B926B7B0051FA24 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Interface.storyboard; sourceTree = ""; }; - 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "WatchApp Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 43A943841B926B7B0051FA24 /* PushNotificationPayload.apns */ = {isa = PBXFileReference; lastKnownFileType = text; path = PushNotificationPayload.apns; sourceTree = ""; }; 43A943871B926B7B0051FA24 /* ExtensionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionDelegate.swift; sourceTree = ""; }; - 43A943891B926B7B0051FA24 /* NotificationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationController.swift; sourceTree = ""; }; 43A9438D1B926B7B0051FA24 /* ComplicationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationController.swift; sourceTree = ""; }; 43A9438F1B926B7B0051FA24 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 43A943911B926B7B0051FA24 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43B260481ED248FB008CAA77 /* CarbEntryTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEntryTableViewCell.swift; sourceTree = ""; }; 43B371851CE583890013C5A6 /* TreatmentArrowStateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TreatmentArrowStateView.swift; sourceTree = ""; }; 43B371871CE597D10013C5A6 /* ShareClient.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = ShareClient.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -945,7 +943,7 @@ 43D9FFD121EAE05D00AF44BF /* LoopCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LoopCore.h; sourceTree = ""; }; 43D9FFD221EAE05D00AF44BF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43DBF0521C93EC8200B3C386 /* DeviceDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = DeviceDataManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PotentialCarbEntryUserInfo.swift; sourceTree = ""; }; + 43DE92581C5479E4001FFDE1 /* GetBolusRecommendationUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetBolusRecommendationUserInfo.swift; sourceTree = ""; }; 43E2D90B1D20C581004DA55F /* LoopTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoopTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 43E2D90F1D20C581004DA55F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43E3449E1B9D68E900C85C07 /* StatusTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = StatusTableViewController.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; @@ -981,14 +979,11 @@ 4F75F00120FCFE8C00B5570E /* GlucoseChartScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartScene.swift; sourceTree = ""; }; 4F7E8AC420E2AB9600AEA65E /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchPredictedGlucose.swift; sourceTree = ""; }; - 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDInterfaceController.swift; sourceTree = ""; }; 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSUserDefaults+StatusExtension.swift"; sourceTree = ""; }; 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManager.swift; sourceTree = ""; }; 4FF4D0FF1E18374700846527 /* WatchContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchContext.swift; sourceTree = ""; }; - 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartHUDController.swift; sourceTree = ""; }; 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 = ""; }; - 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 = ""; }; 7D199D9A212A067600241026 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; @@ -998,46 +993,38 @@ 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 = ""; }; - 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 = ""; }; 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 = ""; }; - 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 = ""; }; 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 = ""; }; - 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 = ""; }; 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 = ""; }; 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 = ""; }; 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 = ""; }; - 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 = ""; }; 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 = ""; }; - 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 = ""; }; 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 = ""; }; - 7D68AAAC1FE2DB0A00522C49 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Interface.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 = ""; }; @@ -1048,7 +1035,6 @@ 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 = ""; }; 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 = ""; }; 7D9BEEE62335A6B3005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 7D9BEEE82335A6B9005DCFD6 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; @@ -1082,7 +1068,6 @@ 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 = ""; }; - 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 = ""; }; @@ -1092,7 +1077,6 @@ 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 = ""; }; - 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 = ""; }; @@ -1102,7 +1086,6 @@ 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 = ""; }; - 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 = ""; }; @@ -1111,7 +1094,6 @@ 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 = ""; }; - 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 = ""; }; @@ -1121,7 +1103,6 @@ 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 = ""; }; - 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 = ""; }; @@ -1130,7 +1111,6 @@ 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 = ""; }; - 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 = ""; }; @@ -1144,7 +1124,6 @@ 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 = ""; }; - 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 = ""; }; @@ -1153,7 +1132,6 @@ 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 = ""; }; - 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; }; @@ -1203,7 +1181,6 @@ 892A5D5A222F0D7C008961AB /* LoopTestingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = LoopTestingKit.framework; path = Carthage/Build/iOS/LoopTestingKit.framework; sourceTree = SOURCE_ROOT; }; 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeReplaceableCollection.swift; sourceTree = ""; }; 892FB4CC22040104005293EC /* OverridePresetRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetRow.swift; sourceTree = ""; }; - 892FB4CE220402C0005293EC /* OverrideSelectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideSelectionController.swift; sourceTree = ""; }; 894F6DD2243BCBDB00CCE676 /* Environment+SizeClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+SizeClass.swift"; sourceTree = ""; }; 894F6DD6243C047300CCE676 /* View+Position.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "View+Position.swift"; path = "WatchApp Extension/Views/View+Position.swift"; sourceTree = SOURCE_ROOT; }; 894F6DD8243C060600CCE676 /* ScalablePositionedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScalablePositionedText.swift; sourceTree = ""; }; @@ -1339,56 +1316,47 @@ 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 = ""; }; - C1004DFC2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DFD2981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; - C1004E042981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E052981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; - C1004E0C2981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; - C1004E142981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; - C1004E1C2981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; 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 = ""; }; 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 = ""; }; 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; }; C105095A2D78D32C00118A37 /* CreatePresetNameAndScheduledEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePresetNameAndScheduledEdit.swift; sourceTree = ""; }; C105095C2D7A1DB300118A37 /* CardSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardSection.swift; sourceTree = ""; }; @@ -1404,6 +1372,13 @@ C10509782D8B635F00118A37 /* Environment+TemporaryPresetsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+TemporaryPresetsManager.swift"; sourceTree = ""; }; C105097A2D8B947700118A37 /* SelectablePreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectablePreset.swift; sourceTree = ""; }; C1092BFD29F8116700AE3D1C /* apply-info-customizations.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "apply-info-customizations.sh"; sourceTree = ""; }; + C10C57E42E6F767500A4825C /* CircleTintedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleTintedButton.swift; sourceTree = ""; }; + C10C57E82E705CCB00A4825C /* SettingsRequestUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRequestUserInfo.swift; sourceTree = ""; }; + C10C57EB2E7070FB00A4825C /* PresetsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsListView.swift; sourceTree = ""; }; + C10C57ED2E7081C900A4825C /* PresetDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDetailView.swift; sourceTree = ""; }; + C10C57F92E708B1D00A4825C /* PresetWatchCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetWatchCard.swift; sourceTree = ""; }; + C10C57FB2E70B87500A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+GlucoseDisplayUnit.swift"; sourceTree = ""; }; + C10C57FD2E71E87400A4825C /* ActiveOverrideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveOverrideView.swift; sourceTree = ""; }; C110888C2A3913C600BA4898 /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = ""; }; C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; C11445B32DB2EBDC00034864 /* ExistingPresetInsulinNeedsEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExistingPresetInsulinNeedsEdit.swift; sourceTree = ""; }; @@ -1422,7 +1397,6 @@ C121D8CF29C7866D00DA0520 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Main.strings; sourceTree = ""; }; C121D8D029C7866D00DA0520 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; C121D8D129C7866D00DA0520 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; - C121D8D229C7866D00DA0520 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Interface.strings; sourceTree = ""; }; C122DEF829BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; C122DEF929BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; C122DEFA29BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; @@ -1431,7 +1405,14 @@ C122DEFD29BBFAAE00321F8D /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 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 = ""; }; + C1275DD52E808E2C0013B99D /* LoopWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWatchApp.swift; sourceTree = ""; }; + C1275DD72E808E480013B99D /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + C1275DDA2E8175AF0013B99D /* PresetConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetConfirmationView.swift; sourceTree = ""; }; + C1275DDC2E8185960013B99D /* PresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsView.swift; sourceTree = ""; }; + C1275DE12E81FD530013B99D /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C1275E1A2E82269A0013B99D /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C1275E1D2E8227260013B99D /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C1275E202E8235E90013B99D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/ckcomplication.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 = ""; }; @@ -1442,6 +1423,7 @@ C12CB9B423106A6100F84978 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Intents.strings; sourceTree = ""; }; C12CB9B623106A6200F84978 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Intents.strings; sourceTree = ""; }; C12CB9B823106A6300F84978 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Intents.strings; sourceTree = ""; }; + C12D723F2E4FBC5D00BD628A /* WatchActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchActionsView.swift; sourceTree = ""; }; C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_predicted_glucose.json; sourceTree = ""; }; C138C6F72C1B8A2C00F08F1A /* GlucoseCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseCondition.swift; sourceTree = ""; }; C13DA2AF24F6C7690098BB29 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; @@ -1450,6 +1432,7 @@ C14F68C82D4AC54000BC3B8D /* DurationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationPickerView.swift; sourceTree = ""; }; C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntry.swift; sourceTree = ""; }; C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntryTests.swift; sourceTree = ""; }; + C1550B0B2E6F249A009369DC /* LoopCircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCircleView.swift; sourceTree = ""; }; 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 = ""; }; @@ -1481,6 +1464,9 @@ C17824991E1999FA00D9D25C /* CaseCountable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaseCountable.swift; sourceTree = ""; }; C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseThresholdTableViewController.swift; sourceTree = ""; }; C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManualBolusRecommendation.swift; sourceTree = ""; }; + C17D52012E7F03CF001D2AD2 /* LoopHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopHeader.swift; sourceTree = ""; }; + C17D52032E7F0568001D2AD2 /* LabelValueRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelValueRow.swift; sourceTree = ""; }; + C17D52072E7F0E18001D2AD2 /* CarbList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbList.swift; sourceTree = ""; }; C1814B85225E507C008D2D8E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; 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 = ""; }; @@ -1507,12 +1493,15 @@ 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 = ""; }; C192C60329C78711001EFEA6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/InfoPlist.strings; sourceTree = ""; }; - C192C60429C78711001EFEA6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/InfoPlist.strings; sourceTree = ""; }; C19A2247298951AC000E4E71 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; C19C8BB928651DFB0056D5E4 /* TrueTime.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TrueTime.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C19C8BC228651EAE0056D5E4 /* LoopTestingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopTestingKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C19C8BC728651F0A0056D5E4 /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C19C8C20286776C20056D5E4 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C19E23B32E8350B900C20D83 /* PresetActivateButtonConfirm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetActivateButtonConfirm.swift; sourceTree = ""; }; + C19E23B52E83512A00C20D83 /* PresetActivateCrownConfirm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetActivateCrownConfirm.swift; sourceTree = ""; }; + C19E23B72E83566300C20D83 /* CircularProgressWithCheckmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressWithCheckmark.swift; sourceTree = ""; }; + C19E23B92E83607C00C20D83 /* DerivedAssetsBase.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssetsBase.xcassets; sourceTree = ""; }; 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 = ""; }; @@ -1534,7 +1523,6 @@ 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 = ""; }; C1B0CFD929C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; - C1B0CFDA29C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; C1B267992995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; C1B2679A2995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; C1B2679B2995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; @@ -1550,7 +1538,6 @@ C1BCB5B6298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; C1BCB5B7298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/ckcomplication.strings; sourceTree = ""; }; C1BCB5B8298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; - C1BCB5B9298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1C247882995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Intents.strings; sourceTree = ""; }; C1C247892995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; C1C2478B2995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1558,10 +1545,8 @@ 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 = ""; }; - 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 = ""; }; - C1C3127D297E4C0100296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; C1C3127E297E4C0100296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/ckcomplication.strings; sourceTree = ""; }; C1C3127F297E4C0400296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intents.strings; sourceTree = ""; }; C1C31280297E4C0400296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; @@ -1589,7 +1574,6 @@ 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 = ""; }; C1E693CF29C786E200410918 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = ""; }; - C1E693D029C786E200410918 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = ""; }; C1E9CB5A295101570022387B /* install-scenarios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "install-scenarios.sh"; sourceTree = ""; }; C1EB0D1D299581D900628475 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; C1EB0D1E299581D900628475 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; @@ -1597,6 +1581,10 @@ C1EB0D20299581D900628475 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; C1EB0D21299581D900628475 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; C1EB0D22299581D900628475 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/ckcomplication.strings; sourceTree = ""; }; + C1ED6C602E79BB9C002F91C2 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; + C1ED6C7C2E7C8113002F91C2 /* SetPresetUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPresetUserInfo.swift; sourceTree = ""; }; + C1ED6C7F2E7C9C7A002F91C2 /* PendingPresetReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingPresetReminder.swift; sourceTree = ""; }; + C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgeAlertUserInfo.swift; sourceTree = ""; }; C1EE9E802A38D0FB0064784A /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = ""; }; C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashRecoveryManager.swift; sourceTree = ""; }; C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppExpirationAlerter.swift; sourceTree = ""; }; @@ -1611,7 +1599,6 @@ C1F48FFD2995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; C1F48FFE2995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/ckcomplication.strings; sourceTree = ""; }; C1F48FFF2995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; - C1F490002995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; C1F4FD5929C7869800D7ACBC /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; C1F7822527CC056900C0919A /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressTableViewCell.swift; sourceTree = ""; }; @@ -1620,7 +1607,7 @@ C1FAB5BF29C786B000D25073 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/Main.strings; sourceTree = ""; }; C1FAB5C029C786B000D25073 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/Localizable.strings; sourceTree = ""; }; C1FAB5C129C786B000D25073 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/Localizable.strings; sourceTree = ""; }; - C1FAB5C229C786B000D25073 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/Interface.strings; sourceTree = ""; }; + C1FAD5182E7E0C3100F7FAD9 /* ChartPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartPageView.swift; sourceTree = ""; }; C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = ""; }; C1FDCBFC29C786F90056E652 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Main.strings; sourceTree = ""; }; @@ -1629,8 +1616,6 @@ C1FDCBFF29C786F90056E652 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; C1FDCC0029C786F90056E652 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; C1FDCC0129C786F90056E652 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; - 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 = ""; }; 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 = ""; }; @@ -1655,9 +1640,6 @@ E98A55EE24EDD6E60008715D /* DosingDecisionStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DosingDecisionStoreProtocol.swift; sourceTree = ""; }; E98A55F024EDD85E0008715D /* MockDosingDecisionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDosingDecisionStore.swift; sourceTree = ""; }; E98A55F224EDD9530008715D /* MockSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSettingsStore.swift; sourceTree = ""; }; - E98A55F424EEE15A0008715D /* OnOffSelectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnOffSelectionController.swift; sourceTree = ""; }; - E98A55F624EEE1E10008715D /* OnOffSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnOffSelectionView.swift; sourceTree = ""; }; - E98A55F824EEFC200008715D /* OnOffSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnOffSelectionViewModel.swift; sourceTree = ""; }; E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Loop Intent Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; E9B07F7E253BBA6500BAD8F8 /* IntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentHandler.swift; sourceTree = ""; }; E9B07F80253BBA6500BAD8F8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -1687,26 +1669,22 @@ 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 = ""; }; - 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 = ""; }; 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 = ""; }; F5D9C02527DABBE4002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; F5D9C02727DABBE4002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; F5E0BDD327E1D71C0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Intents.strings; sourceTree = ""; }; F5E0BDD527E1D71D0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Main.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 = ""; }; 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 = ""; }; F5E0BDE127E1D7230033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; F5E0BDE327E1D7230033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1721,7 +1699,6 @@ 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */, 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */, 1481F9BB28DA26F4004C5AEB /* LoopUI.framework in Frameworks */, - 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1729,6 +1706,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */, + 4344628220A7A37F00C4BE6F /* CoreBluetooth.framework in Frameworks */, + 43D9002F21EB234400AF44BF /* LoopCore.framework in Frameworks */, + 4396BD50225159C0005AA4D3 /* HealthKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1751,24 +1732,12 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 43A9437B1B926B7B0051FA24 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 43D9002F21EB234400AF44BF /* LoopCore.framework in Frameworks */, - C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */, - 4344628220A7A37F00C4BE6F /* CoreBluetooth.framework in Frameworks */, - C19C8C1E28663B040056D5E4 /* LoopKit.framework in Frameworks */, - 4396BD50225159C0005AA4D3 /* HealthKit.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 43D9002321EB209400AF44BF /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 43D9002D21EB225D00AF44BF /* HealthKit.framework in Frameworks */, - C159C82F286787EF00A86EC0 /* LoopKit.framework in Frameworks */, + C1275DE22E81FD530013B99D /* LoopKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1776,7 +1745,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - C19C8C21286776C20056D5E4 /* LoopKit.framework in Frameworks */, + C1275DDE2E81FD470013B99D /* LoopKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1896,26 +1865,10 @@ path = Alerts; sourceTree = ""; }; - 4328E0121CFBE1B700E199AA /* Controllers */ = { - isa = PBXGroup; - children = ( - 4328E0151CFBE1DA00E199AA /* ActionHUDController.swift */, - 4328E01D1CFBE25F00E199AA /* CarbAndBolusFlowController.swift */, - 4345E40521F68E18009E00E5 /* CarbEntryListController.swift */, - 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */, - 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */, - 43511CED220FC61700566C63 /* HUDRowController.swift */, - 43A943891B926B7B0051FA24 /* NotificationController.swift */, - 892FB4CE220402C0005293EC /* OverrideSelectionController.swift */, - 4345E40321F68AD9009E00E5 /* TextRowController.swift */, - E98A55F424EEE15A0008715D /* OnOffSelectionController.swift */, - ); - path = Controllers; - sourceTree = ""; - }; 4328E01F1CFBE2B100E199AA /* Extensions */ = { isa = PBXGroup; children = ( + C10C57FB2E70B87500A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift */, 4344629120A7C19800C4BE6F /* ButtonGroup.swift */, 898ECA64218ABD9A001E9D35 /* CGRect.swift */, 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */, @@ -1949,7 +1902,6 @@ 43757D131C06F26C00910CB9 /* Models */ = { isa = PBXGroup; children = ( - C105097A2D8B947700118A37 /* SelectablePreset.swift */, DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */, B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */, A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, @@ -2005,7 +1957,6 @@ children = ( 43776F8C1B8022E90074EA36 /* Loop.app */, 43A943721B926B7B0051FA24 /* WatchApp.app */, - 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */, 43E2D90B1D20C581004DA55F /* LoopTests.xctest */, 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */, 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */, @@ -2045,9 +1996,10 @@ 43A943731B926B7B0051FA24 /* WatchApp */ = { isa = PBXGroup; children = ( - C1C73F0F1DE3D0270022FC89 /* InfoPlist.strings */, + C19E23B92E83607C00C20D83 /* DerivedAssetsBase.xcassets */, + C1275DD72E808E480013B99D /* ContentView.swift */, + C1275DD52E808E2C0013B99D /* LoopWatchApp.swift */, 43F5C2D61B92A4DC003EB13D /* Info.plist */, - 43A943741B926B7B0051FA24 /* Interface.storyboard */, A966152823EA5A37005D8B29 /* DefaultAssets.xcassets */, A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */, ); @@ -2060,12 +2012,10 @@ 63F5E17A297DDF3900A62D4B /* ckcomplication.strings */, 7D7076601FE06EE3004AC8EA /* Localizable.strings */, 43D533BB1CFD1DD7009E3085 /* WatchApp Extension.entitlements */, - 43A943911B926B7B0051FA24 /* Info.plist */, 4B67E2C6289B4EDB002D92AF /* InfoPlist.strings */, 43A9438D1B926B7B0051FA24 /* ComplicationController.swift */, 43A943871B926B7B0051FA24 /* ExtensionDelegate.swift */, 43A9438F1B926B7B0051FA24 /* Assets.xcassets */, - 4328E0121CFBE1B700E199AA /* Controllers */, 4328E01F1CFBE2B100E199AA /* Extensions */, 4FE3475F20D5D7FA00A86D03 /* Managers */, 898ECA5D218ABD17001E9D35 /* Models */, @@ -2171,8 +2121,11 @@ 43D9FFD021EAE05D00AF44BF /* LoopCore */ = { isa = PBXGroup; children = ( + C1ED6C632E7C6DA6002F91C2 /* Models */, + C1ED6C602E79BB9C002F91C2 /* NotificationManager.swift */, + C105097A2D8B947700118A37 /* SelectablePreset.swift */, C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */, - 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, + 43DE92581C5479E4001FFDE1 /* GetBolusRecommendationUserInfo.swift */, 43C05CB721EBEA54006FB252 /* HKUnit.swift */, 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, @@ -2460,22 +2413,11 @@ isa = PBXGroup; children = ( C110888C2A3913C600BA4898 /* BuildDetails.swift */, - 89F9119324358E4500ECCAF3 /* CarbAbsorptionTime.swift */, - A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */, - 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */, - E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */, - 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */, - C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */, 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */, C1FB428E217921D600FAB378 /* PumpManagerUI.swift */, - 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */, 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */, - 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */, - 4FF4D0FF1E18374700846527 /* WatchContext.swift */, - C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */, A9347F2E24E7508A00C99C34 /* WatchHistoricalCarbs.swift */, 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */, - 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */, ); path = Models; sourceTree = ""; @@ -2606,6 +2548,7 @@ C16971F82D1231AB001B7DF6 /* PresetRangeEditor.swift */, 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */, 84E8BBC92CCA16290078E6CF /* PresetsView.swift */, + 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */, C105095E2D7A310B00118A37 /* ReviewNewPresetView.swift */, 84E8BBC22CC9B9780078E6CF /* Components */, 84E8BBB62CC990480078E6CF /* Training Content */, @@ -2639,7 +2582,6 @@ 84D1F1A82D09800700CB271F /* PresetDetentView.swift */, 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */, 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */, - 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */, 847F23422E4543140035C864 /* ActivePresetBanner.swift */, ); path = Components; @@ -2649,7 +2591,6 @@ isa = PBXGroup; children = ( 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */, - E98A55F824EEFC200008715D /* OnOffSelectionViewModel.swift */, ); path = "View Models"; sourceTree = ""; @@ -2657,15 +2598,30 @@ 895788A3242E6947002CB114 /* Views */ = { isa = PBXGroup; children = ( - 895788B5242E6A25002CB114 /* Carb Entry & Bolus */, - 895788B4242E69C8002CB114 /* Extensions */, 895788AB242E69A2002CB114 /* ActionButton.swift */, - 895788AA242E69A1002CB114 /* CircularAccessoryButtonStyle.swift */, + C10C57FD2E71E87400A4825C /* ActiveOverrideView.swift */, + 895788B5242E6A25002CB114 /* Carb Entry & Bolus */, + C17D52072E7F0E18001D2AD2 /* CarbList.swift */, + C1FAD5182E7E0C3100F7FAD9 /* ChartPageView.swift */, 89A605E824328862009C1096 /* Checkmark.swift */, + C10C57E42E6F767500A4825C /* CircleTintedButton.swift */, + 895788AA242E69A1002CB114 /* CircularAccessoryButtonStyle.swift */, + C19E23B72E83566300C20D83 /* CircularProgressWithCheckmark.swift */, 89A605EE2432925D009C1096 /* CompletionCheckmark.swift */, + 895788B4242E69C8002CB114 /* Extensions */, + C17D52032E7F0568001D2AD2 /* LabelValueRow.swift */, + C1550B0B2E6F249A009369DC /* LoopCircleView.swift */, + C17D52012E7F03CF001D2AD2 /* LoopHeader.swift */, + C19E23B32E8350B900C20D83 /* PresetActivateButtonConfirm.swift */, + C19E23B52E83512A00C20D83 /* PresetActivateCrownConfirm.swift */, + C1275DDA2E8175AF0013B99D /* PresetConfirmationView.swift */, + C10C57ED2E7081C900A4825C /* PresetDetailView.swift */, + C10C57EB2E7070FB00A4825C /* PresetsListView.swift */, + C1275DDC2E8185960013B99D /* PresetsView.swift */, + C10C57F92E708B1D00A4825C /* PresetWatchCard.swift */, 894F6DD8243C060600CCE676 /* ScalablePositionedText.swift */, 89A605EA243288E4009C1096 /* TopDownTriangle.swift */, - E98A55F624EEE1E10008715D /* OnOffSelectionView.swift */, + C12D723F2E4FBC5D00BD628A /* WatchActionsView.swift */, ); path = Views; sourceTree = ""; @@ -2726,6 +2682,8 @@ 898ECA5D218ABD17001E9D35 /* Models */ = { isa = PBXGroup; children = ( + C1ED6C7F2E7C9C7A002F91C2 /* PendingPresetReminder.swift */, + 89F9119324358E4500ECCAF3 /* CarbAbsorptionTime.swift */, 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */, 898ECA5F218ABD17001E9D35 /* GlucoseChartData.swift */, 892FB4CC22040104005293EC /* OverridePresetRow.swift */, @@ -2754,6 +2712,9 @@ 968DCD53F724DE56FFE51920 /* Frameworks */ = { isa = PBXGroup; children = ( + C1275E1D2E8227260013B99D /* LoopKit.framework */, + C1275E1A2E82269A0013B99D /* LoopKit.framework */, + C1275DE12E81FD530013B99D /* LoopKit.framework */, 840A2F0D2C0F978E003D5E90 /* LoopKitUI.framework */, C159C82E286787EF00A86EC0 /* LoopKit.framework */, C159C8212867859800A86EC0 /* MockKitUI.framework */, @@ -2885,6 +2846,26 @@ path = Scripts; sourceTree = ""; }; + C1ED6C632E7C6DA6002F91C2 /* Models */ = { + isa = PBXGroup; + children = ( + C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */, + A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */, + 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */, + E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */, + 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */, + C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */, + 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */, + C1ED6C7C2E7C8113002F91C2 /* SetPresetUserInfo.swift */, + C10C57E82E705CCB00A4825C /* SettingsRequestUserInfo.swift */, + 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */, + 4FF4D0FF1E18374700846527 /* WatchContext.swift */, + C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */, + 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */, + ); + path = Models; + sourceTree = ""; + }; E93E86AC24DDE02C00FF40C8 /* Mock Stores */ = { isa = PBXGroup; children = ( @@ -3024,7 +3005,7 @@ C16DA84322E8E5FF008624C2 /* Install Plugins */, C1D19800232CFA2A0096D646 /* Capture Build Details */, C1092BFE29F88F0600AE3D1C /* Apply Info Customizations */, - 4F70C1EC1DE8DCA8006380B7 /* Embed App Extensions */, + 4F70C1EC1DE8DCA8006380B7 /* Embed Foundation Extensions */, ); buildRules = ( ); @@ -3050,38 +3031,20 @@ buildConfigurationList = 43A943991B926B7B0051FA24 /* Build configuration list for PBXNativeTarget "WatchApp" */; buildPhases = ( 43A943701B926B7B0051FA24 /* Resources */, - 43A943981B926B7B0051FA24 /* Embed App Extensions */, 43105EF81BADC8F9009CD81E /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - 43A943811B926B7B0051FA24 /* PBXTargetDependency */, - ); - name = WatchApp; - productName = WatchApp; - productReference = 43A943721B926B7B0051FA24 /* WatchApp.app */; - productType = "com.apple.product-type.application.watchapp2"; - }; - 43A9437D1B926B7B0051FA24 /* WatchApp Extension */ = { - isa = PBXNativeTarget; - buildConfigurationList = 43A943951B926B7B0051FA24 /* Build configuration list for PBXNativeTarget "WatchApp Extension" */; - buildPhases = ( C1E9CB59294E67060022387B /* Build Derived Assets */, 43A9437A1B926B7B0051FA24 /* Sources */, - 43A9437B1B926B7B0051FA24 /* Frameworks */, - 43A9437C1B926B7B0051FA24 /* Resources */, 43C667D71C5577280050C674 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( - C117ED71232EDB3200DA57CD /* PBXTargetDependency */, + C16E94F92E7DBBA600AA4E6E /* PBXTargetDependency */, ); - name = "WatchApp Extension"; - productName = "WatchApp Extension"; - productReference = 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */; - productType = "com.apple.product-type.watchkit2-extension"; + name = WatchApp; + productName = WatchApp; + productReference = 43A943721B926B7B0051FA24 /* WatchApp.app */; + productType = "com.apple.product-type.application"; }; 43D9001A21EB209400AF44BF /* LoopCore-watchOS */ = { isa = PBXNativeTarget; @@ -3109,6 +3072,7 @@ 43D9FFCB21EAE05D00AF44BF /* Sources */, 43D9FFCC21EAE05D00AF44BF /* Frameworks */, 43D9FFCD21EAE05D00AF44BF /* Resources */, + C1275DE02E81FD470013B99D /* Embed Frameworks */, ); buildRules = ( ); @@ -3187,7 +3151,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1520; - LastUpgradeCheck = 1010; + LastUpgradeCheck = 2600; ORGANIZATIONNAME = "LoopKit Authors"; TargetAttributes = { 14B1735B28AED9EC006CCD7C = { @@ -3226,27 +3190,6 @@ }; }; }; - 43A9437D1B926B7B0051FA24 = { - CreatedOnToolsVersion = 7.0; - LastSwiftMigration = 1020; - SystemCapabilities = { - com.apple.ApplicationGroups.iOS = { - enabled = 0; - }; - com.apple.HealthKit = { - enabled = 0; - }; - com.apple.HealthKit.watchos = { - enabled = 1; - }; - com.apple.Keychain = { - enabled = 0; - }; - com.apple.Siri = { - enabled = 1; - }; - }; - }; 43D9001A21EB209400AF44BF = { LastSwiftMigration = 1020; ProvisioningStyle = Automatic; @@ -3311,7 +3254,6 @@ targets = ( 43776F8B1B8022E90074EA36 /* Loop */, 43A943711B926B7B0051FA24 /* WatchApp */, - 43A9437D1B926B7B0051FA24 /* WatchApp Extension */, 14B1735B28AED9EC006CCD7C /* Loop Widget Extension */, E9B07F7B253BBA6500BAD8F8 /* Loop Intent Extension */, 43D9FFCE21EAE05D00AF44BF /* LoopCore */, @@ -3352,25 +3294,15 @@ runOnlyForDeploymentPostprocessing = 0; }; 43A943701B926B7B0051FA24 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A966152B23EA5A37005D8B29 /* DerivedAssets.xcassets in Resources */, - C1C73F0D1DE3D0270022FC89 /* InfoPlist.strings in Resources */, - 43A943761B926B7B0051FA24 /* Interface.storyboard in Resources */, - A966152A23EA5A37005D8B29 /* DefaultAssets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 43A9437C1B926B7B0051FA24 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 7D70765E1FE06EE3004AC8EA /* Localizable.strings in Resources */, 4B67E2C8289B4EDB002D92AF /* InfoPlist.strings in Resources */, 43A943901B926B7B0051FA24 /* Assets.xcassets in Resources */, + C1275E1F2E822CEE0013B99D /* DerivedAssets.xcassets in Resources */, 63F5E17C297DDF3900A62D4B /* ckcomplication.strings in Resources */, - B405E35924D2A75B00DD058D /* DerivedAssets.xcassets in Resources */, + A966152A23EA5A37005D8B29 /* DefaultAssets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3596,7 +3528,6 @@ 4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */, A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */, 43C05CC521EC29E3006FB252 /* TextFieldTableViewCell.swift in Sources */, - 4FF4D1001E18374700846527 /* WatchContext.swift in Sources */, C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */, 4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */, 4311FB9B1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift in Sources */, @@ -3614,6 +3545,7 @@ A9B996F227238705002DC09C /* DosingDecisionStore.swift in Sources */, 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */, B4D904412AA8989100CBD826 /* StatefulPluginManager.swift in Sources */, + C10C57F82E7085D600A4825C /* PresetSymbolView.swift in Sources */, E9B355292935919E0076AB04 /* MissedMealSettings.swift in Sources */, 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, @@ -3646,7 +3578,6 @@ 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, 84C77CF22E6A054B00839FEC /* PlayMediaButton.swift in Sources */, - 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, 849E06232E5E41BA00A71614 /* PresetsTrainingView.swift in Sources */, 84F20DFD2D0B9C3A0089DF02 /* EditPresetDurationView.swift in Sources */, 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */, @@ -3682,7 +3613,6 @@ 4372E487213C86240068E043 /* SampleValue.swift in Sources */, 14C970682C5991CD00E8A01B /* LoopChartView.swift in Sources */, 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */, - C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */, 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */, 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */, 14BBB3B22C629DB100ECB800 /* Character+IsEmoji.swift in Sources */, @@ -3719,7 +3649,6 @@ B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */, 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */, DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, - A9347F3124E7521800C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */, C105095D2D7A1DB700118A37 /* CardSection.swift in Sources */, A9CBE458248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift in Sources */, 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */, @@ -3733,7 +3662,6 @@ 84BC5BDF2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift in Sources */, 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */, A9F703732489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift in Sources */, - C105097B2D8B947B00118A37 /* SelectablePreset.swift in Sources */, 4328E0351CFC0AE100E199AA /* WatchDataManager.swift in Sources */, 4345E3FC21F04911009E00E5 /* UIColor+HIG.swift in Sources */, 1D4A3E2E2478628500FD601B /* StoredAlert+CoreDataProperties.swift in Sources */, @@ -3771,14 +3699,12 @@ E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, C10509612D7B3DF400118A37 /* CardSectionScrollView.swift in Sources */, - C1ABA1622E281D470049DF41 /* NotificationActionSelection.swift in Sources */, C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, C10509772D8B591200118A37 /* StatusTableViewModel.swift in Sources */, 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */, 84213C752D932F0F00642E78 /* InsulinDeliveryLog.swift in Sources */, DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */, 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, - 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */, C105095F2D7A311200118A37 /* ReviewNewPresetView.swift in Sources */, 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */, @@ -3792,7 +3718,6 @@ 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */, 84C77CF42E6A17FB00839FEC /* IntensitySlider.swift in Sources */, - 84DEF35D2E566757006126F9 /* PresetSymbolView.swift in Sources */, 8443566B2E6F8325000EBD1A /* TintedContent.swift in Sources */, C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */, 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */, @@ -3802,16 +3727,13 @@ C1DE5D23251BFC4D00439E49 /* SimpleBolusView.swift in Sources */, 84213C8B2D94948900642E78 /* InsulinDeliveryOverview.swift in Sources */, 89ADE13B226BFA0F0067222B /* TestingScenariosManager.swift in Sources */, - 4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */, 436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */, 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */, - 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */, 14ED83F62C6421F9008B4A5C /* FavoriteFoodInsightsCardView.swift in Sources */, C10509652D7B6B1900118A37 /* CorrectionRangePreview.swift in Sources */, C174233C259BEB0F00399C9D /* ManualEntryDoseViewModel.swift in Sources */, 84E8BBC82CC9D34B0078E6CF /* TherapySettingsExampleView.swift in Sources */, 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, - E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */, 84475E102E5F870800FC5E7C /* PresetsTrainingCard.swift in Sources */, 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */, C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */, @@ -3845,7 +3767,6 @@ 84C170EF2CCA37680098E52F /* PresetCard.swift in Sources */, 1452F4A92A851C9400F8B9E4 /* FavoriteFoodAddEditViewModel.swift in Sources */, 438D42FB1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift in Sources */, - 89A1B66E24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */, C1C660D1252E4DD5009B5C32 /* LoopConstants.swift in Sources */, 432E73CB1D24B3D6009AD15D /* RemoteDataServicesManager.swift in Sources */, 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */, @@ -3860,23 +3781,23 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C1275DD62E808E2F0013B99D /* LoopWatchApp.swift in Sources */, 894F6DDD243C0A2300CCE676 /* CarbAmountLabel.swift in Sources */, B455C7352BD14E30002B847E /* Comparable.swift in Sources */, - C1ABA1612E281D470049DF41 /* NotificationActionSelection.swift in Sources */, 89A605E524327F45009C1096 /* DoseVolumeInput.swift in Sources */, 4372E488213C862B0068E043 /* SampleValue.swift in Sources */, + C1ED6C802E7C9C86002F91C2 /* PendingPresetReminder.swift in Sources */, + C10C57FA2E708B2D00A4825C /* PresetWatchCard.swift in Sources */, 89A605EB243288E4009C1096 /* TopDownTriangle.swift in Sources */, 4F2C15741E0209F500E160D4 /* NSTimeInterval.swift in Sources */, 89F9119224358E2B00ECCAF3 /* CarbEntryInputMode.swift in Sources */, - 4FF4D1011E18375000846527 /* WatchContext.swift in Sources */, 89FE21AD24AC57E30033F501 /* Collection.swift in Sources */, 898ECA63218ABD21001E9D35 /* ComplicationChartManager.swift in Sources */, - 43A9438A1B926B7B0051FA24 /* NotificationController.swift in Sources */, 439A7945211FE23A0041B75F /* NSUserActivity.swift in Sources */, 43A943881B926B7B0051FA24 /* ExtensionDelegate.swift in Sources */, - 43511CEE220FC61700566C63 /* HUDRowController.swift in Sources */, 1D3F0F7526D59B6C004A5960 /* Debug.swift in Sources */, 892FB4CD22040104005293EC /* OverridePresetRow.swift in Sources */, + C1550B0C2E6F249A009369DC /* LoopCircleView.swift in Sources */, 4F75F00220FCFE8C00B5570E /* GlucoseChartScene.swift in Sources */, 89E26800229267DF00A3F2AF /* Optional.swift in Sources */, 4328E02F1CFBF81800E199AA /* WKInterfaceImage.swift in Sources */, @@ -3886,48 +3807,54 @@ 89E08FC6242E7506000D719B /* CarbAndDateInput.swift in Sources */, 89E08FC8242E76E9000D719B /* AnyTransition.swift in Sources */, 89A605E324327DFE009C1096 /* CarbAmountInput.swift in Sources */, + C19E23B62E83513400C20D83 /* PresetActivateCrownConfirm.swift in Sources */, + C1275DD82E808E520013B99D /* ContentView.swift in Sources */, 898ECA61218ABD17001E9D35 /* GlucoseChartData.swift in Sources */, 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */, 4328E02A1CFBE2C500E199AA /* UIColor.swift in Sources */, - 4372E484213A63FB0068E043 /* ChartHUDController.swift in Sources */, + C19E23B42E8350D700C20D83 /* PresetActivateButtonConfirm.swift in Sources */, 895788AF242E69A2002CB114 /* BolusInput.swift in Sources */, 894F6DDB243C07CF00CCE676 /* GramLabel.swift in Sources */, - 4345E40621F68E18009E00E5 /* CarbEntryListController.swift in Sources */, 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */, 89E267FD2292456700A3F2AF /* FeatureFlags.swift in Sources */, 898ECA60218ABD17001E9D35 /* GlucoseChartScaler.swift in Sources */, 894F6DD9243C060600CCE676 /* ScalablePositionedText.swift in Sources */, + C19E23B82E83566700C20D83 /* CircularProgressWithCheckmark.swift in Sources */, + C1FAD5192E7E0C3400F7FAD9 /* ChartPageView.swift in Sources */, 89E08FC4242E73F0000D719B /* GramLabelPositionKey.swift in Sources */, - 4F82655020E69F9A0031A8F5 /* HUDInterfaceController.swift in Sources */, + C10C57FC2E70B8B900A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift in Sources */, 4372E492213D956C0068E043 /* GlucoseRangeSchedule.swift in Sources */, + C12D72402E4FBC5F00BD628A /* WatchActionsView.swift in Sources */, A9347F3324E7522900C99C34 /* WatchHistoricalCarbs.swift in Sources */, 895788AD242E69A2002CB114 /* AbsorptionTimeSelection.swift in Sources */, 89A605EF2432925D009C1096 /* CompletionCheckmark.swift in Sources */, C138C6F82C1B8A2C00F08F1A /* GlucoseCondition.swift in Sources */, 89F9119624358E6900ECCAF3 /* BolusPickerValues.swift in Sources */, 4328E02B1CFBE2C500E199AA /* WKAlertAction.swift in Sources */, - 4F7E8AC720E2AC0300AEA65E /* WatchPredictedGlucose.swift in Sources */, + C10C57EE2E7081D200A4825C /* PresetDetailView.swift in Sources */, + C10C57E52E6F767A00A4825C /* CircleTintedButton.swift in Sources */, 4F7E8AC520E2AB9600AEA65E /* Date.swift in Sources */, + C10C57FE2E71E87D00A4825C /* ActiveOverrideView.swift in Sources */, 89F9119424358E4500ECCAF3 /* CarbAbsorptionTime.swift in Sources */, 895788B1242E69A2002CB114 /* Color.swift in Sources */, 89E08FC2242E73DC000D719B /* CarbAmountPositionKey.swift in Sources */, 4F11D3C420DD881A006E072C /* WatchHistoricalGlucose.swift in Sources */, - E98A55F724EEE1E10008715D /* OnOffSelectionView.swift in Sources */, 89E08FCA242E7714000D719B /* UIFont.swift in Sources */, 4328E0281CFBE2C500E199AA /* CLKComplicationTemplate.swift in Sources */, - 4328E01E1CFBE25F00E199AA /* CarbAndBolusFlowController.swift in Sources */, 432CF87520D8AC950066B889 /* NSUserDefaults+WatchApp.swift in Sources */, - 89A1B66F24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */, + C10C57EC2E7070FF00A4825C /* PresetsListView.swift in Sources */, + C1275DDB2E8175B40013B99D /* PresetConfirmationView.swift in Sources */, 43027F0F1DFE0EC900C51989 /* HKUnit.swift in Sources */, 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */, + C1275DDD2E8185990013B99D /* PresetsView.swift in Sources */, + C17D52022E7F03D0001D2AD2 /* LoopHeader.swift in Sources */, 89A605E924328862009C1096 /* Checkmark.swift in Sources */, 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */, 894F6DD7243C047300CCE676 /* View+Position.swift in Sources */, 898ECA69218ABDA9001E9D35 /* CLKTextProvider+Compound.m in Sources */, 4372E48C213CB6750068E043 /* Double.swift in Sources */, + C17D52082E7F0E1B001D2AD2 /* CarbList.swift in Sources */, 89A605ED24328972009C1096 /* BolusArrow.swift in Sources */, - E98A55F924EEFC200008715D /* OnOffSelectionViewModel.swift in Sources */, - 892FB4CF220402C0005293EC /* OverrideSelectionController.swift in Sources */, 89E08FD0242E8B2B000D719B /* BolusConfirmationView.swift in Sources */, 43785E972120E4500057DED1 /* INRelevantShortcutStore+Loop.swift in Sources */, 89A605E72432860C009C1096 /* PeriodicPublisher.swift in Sources */, @@ -3935,19 +3862,12 @@ 89A605F12432BD18009C1096 /* BolusConfirmationVisual.swift in Sources */, 898ECA65218ABD9B001E9D35 /* CGRect.swift in Sources */, 43CB2B2B1D924D450079823D /* WCSession.swift in Sources */, - 4372E491213D05F90068E043 /* LoopSettingsUserInfo.swift in Sources */, - 4345E40421F68AD9009E00E5 /* TextRowController.swift in Sources */, 43BFF0B51E45C1E700FF19A9 /* NumberFormatter.swift in Sources */, - C1201E2D23ECDF3D002DA84A /* WatchContextRequestUserInfo.swift in Sources */, 43A9438E1B926B7B0051FA24 /* ComplicationController.swift in Sources */, 43517917230A0E1A0072ECC0 /* WKInterfaceLabel.swift in Sources */, - A9347F3224E7522400C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */, 895788B3242E69A2002CB114 /* ActionButton.swift in Sources */, 894F6DD3243BCBDB00CCE676 /* Environment+SizeClass.swift in Sources */, - E98A55F524EEE15A0008715D /* OnOffSelectionController.swift in Sources */, - 4328E01A1CFBE1DA00E199AA /* ActionHUDController.swift in Sources */, - 4F11D3C320DD84DB006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, - 435400351C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, + C17D52042E7F0578001D2AD2 /* LabelValueRow.swift in Sources */, 895788B2242E69A2002CB114 /* CircularAccessoryButtonStyle.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3956,18 +3876,34 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C1ED6C822E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */, E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */, + C1ED6C6D2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */, + C10C57F32E70851F00A4825C /* SelectablePreset.swift in Sources */, + C1ED6C772E7C6F58002F91C2 /* WatchPredictedGlucose.swift in Sources */, C1D0B6312986D4D90098D215 /* LocalizedString.swift in Sources */, + C1ED6C662E7C6E2F002F91C2 /* CarbBackfillRequestUserInfo.swift in Sources */, + C1ED6C732E7C6F23002F91C2 /* SupportedBolusVolumesUserInfo.swift in Sources */, 43C05CB821EBEA54006FB252 /* HKUnit.swift in Sources */, + C1ED6C792E7C6FC7002F91C2 /* Comparable.swift in Sources */, + C1ED6C622E79BBA5002F91C2 /* NotificationManager.swift in Sources */, + C1ED6C7B2E7C6FE6002F91C2 /* WatchContextRequestUserInfo.swift in Sources */, + C1ED6C6F2E7C6EDE002F91C2 /* IntentExtensionInfo.swift in Sources */, + C1ED6C752E7C6F36002F91C2 /* WatchContext.swift in Sources */, 43D9002021EB209400AF44BF /* NSTimeInterval.swift in Sources */, + C1ED6C712E7C6F07002F91C2 /* SetBolusUserInfo.swift in Sources */, C16575762539FEF3004AE16E /* LoopCoreConstants.swift in Sources */, C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */, 43C05CA921EB2B26006FB252 /* PersistenceController.swift in Sources */, A9CE912224CA032E00302A40 /* NSUserDefaults.swift in Sources */, 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */, 43C05CC721EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, + C1ED6C672E7C6E35002F91C2 /* SettingsRequestUserInfo.swift in Sources */, E9B3552B293591E70076AB04 /* MissedMealNotification.swift in Sources */, - 4345E40221F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, + C1ED6C6B2E7C6E58002F91C2 /* LoopSettingsUserInfo.swift in Sources */, + C1ED6C7D2E7C811A002F91C2 /* SetPresetUserInfo.swift in Sources */, + 4345E40221F67300009E00E5 /* GetBolusRecommendationUserInfo.swift in Sources */, + C1ED6C692E7C6E41002F91C2 /* GlucoseBackfillRequestUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3975,18 +3911,34 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C1ED6C832E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */, + C1ED6C652E7C6E2F002F91C2 /* CarbBackfillRequestUserInfo.swift in Sources */, + C1ED6C6C2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */, E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */, + C1ED6C762E7C6F58002F91C2 /* WatchPredictedGlucose.swift in Sources */, + C10C57F22E70851F00A4825C /* SelectablePreset.swift in Sources */, C1D0B6302986D4D90098D215 /* LocalizedString.swift in Sources */, + C1ED6C722E7C6F23002F91C2 /* SupportedBolusVolumesUserInfo.swift in Sources */, 43C05CB921EBEA54006FB252 /* HKUnit.swift in Sources */, + C1ED6C782E7C6FC7002F91C2 /* Comparable.swift in Sources */, + C1ED6C612E79BBA5002F91C2 /* NotificationManager.swift in Sources */, + C1ED6C7A2E7C6FE5002F91C2 /* WatchContextRequestUserInfo.swift in Sources */, + C1ED6C6E2E7C6EDE002F91C2 /* IntentExtensionInfo.swift in Sources */, + C1ED6C742E7C6F36002F91C2 /* WatchContext.swift in Sources */, 43D9FFFB21EAF3D300AF44BF /* NSTimeInterval.swift in Sources */, + C1ED6C702E7C6F07002F91C2 /* SetBolusUserInfo.swift in Sources */, C16575752539FD60004AE16E /* LoopCoreConstants.swift in Sources */, C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */, 43C05CA821EB2B26006FB252 /* PersistenceController.swift in Sources */, 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */, 43C05CC821EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */, + C1ED6C642E7C6DB9002F91C2 /* SettingsRequestUserInfo.swift in Sources */, E9B3552A293591E70076AB04 /* MissedMealNotification.swift in Sources */, - 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, + C1ED6C6A2E7C6E58002F91C2 /* LoopSettingsUserInfo.swift in Sources */, + C1ED6C7E2E7C811A002F91C2 /* SetPresetUserInfo.swift in Sources */, + 4345E40121F67300009E00E5 /* GetBolusRecommendationUserInfo.swift in Sources */, + C1ED6C682E7C6E41002F91C2 /* GlucoseBackfillRequestUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4092,7 +4044,6 @@ buildActionMask = 2147483647; files = ( E942DE96253BE68F00AC532D /* NSBundle.swift in Sources */, - E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */, E9B07FEE253BBC7100BAD8F8 /* OverrideIntentHandler.swift in Sources */, E942DE9F253BE6A900AC532D /* NSTimeInterval.swift in Sources */, E9B08016253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, @@ -4115,11 +4066,6 @@ target = 14B1735B28AED9EC006CCD7C /* Loop Widget Extension */; targetProxy = 14B1736728AED9EE006CCD7C /* PBXContainerItemProxy */; }; - 43A943811B926B7B0051FA24 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 43A9437D1B926B7B0051FA24 /* WatchApp Extension */; - targetProxy = 43A943801B926B7B0051FA24 /* PBXContainerItemProxy */; - }; 43A943931B926B7B0051FA24 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 43A943711B926B7B0051FA24 /* WatchApp */; @@ -4140,10 +4086,10 @@ target = 4F75288A1DFE1DC600C322D6 /* LoopUI */; targetProxy = 4F7528961DFE1ED400C322D6 /* PBXContainerItemProxy */; }; - C117ED71232EDB3200DA57CD /* PBXTargetDependency */ = { + C16E94F92E7DBBA600AA4E6E /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 43D9001A21EB209400AF44BF /* LoopCore-watchOS */; - targetProxy = C117ED70232EDB3200DA57CD /* PBXContainerItemProxy */; + targetProxy = C16E94F82E7DBBA600AA4E6E /* PBXContainerItemProxy */; }; C1CCF1152858FA900035389C /* PBXTargetDependency */ = { isa = PBXTargetDependency; @@ -4228,37 +4174,6 @@ name = Intents.intentdefinition; sourceTree = ""; }; - 43A943741B926B7B0051FA24 /* Interface.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 43A943751B926B7B0051FA24 /* Base */, - 7DD382791F8DBFC60071272B /* es */, - 7D68AAAC1FE2DB0A00522C49 /* ru */, - 7D23668721250D180028B67D /* fr */, - 7D23669721250D230028B67D /* de */, - 7D2366A721250D2C0028B67D /* zh-Hans */, - 7D2366B421250D350028B67D /* it */, - 7D2366C721250D3F0028B67D /* nl */, - 7D2366D721250D4A0028B67D /* nb */, - 7D199D95212A067600241026 /* pl */, - 7D9BEEDD2335A5CC005DCFD6 /* en */, - 7D9BEF172335EC4C005DCFD6 /* ja */, - 7D9BEF2D2335EC59005DCFD6 /* pt-BR */, - 7D9BEF432335EC62005DCFD6 /* vi */, - 7D9BEF592335EC6E005DCFD6 /* da */, - 7D9BEF6F2335EC7D005DCFD6 /* sv */, - 7D9BEF852335EC8B005DCFD6 /* fi */, - 7D9BF13D23370E8B005DCFD6 /* ro */, - F5D9C01B27DABBE1002E48F6 /* tr */, - F5E0BDD727E1D71E0033557E /* he */, - C1C31279297E4BFE00296DA4 /* ar */, - C121D8D229C7866D00DA0520 /* cs */, - C1FAB5C229C786B000D25073 /* hi */, - C1FDCC0329C786F90056E652 /* sk */, - ); - name = Interface.storyboard; - sourceTree = ""; - }; 43D9FFA821EA9A0C00AF44BF /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -4356,6 +4271,7 @@ C1B2679C2995824000BCB7C1 /* tr */, C1AD630029BBFAA80002685D /* ro */, C122DEFE29BBFAAE00321F8D /* ru */, + C1275E202E8235E90013B99D /* en */, ); name = ckcomplication.strings; sourceTree = ""; @@ -4674,33 +4590,6 @@ name = Localizable.strings; sourceTree = ""; }; - C1C73F0F1DE3D0270022FC89 /* InfoPlist.strings */ = { - isa = PBXVariantGroup; - children = ( - 7D23667E21250CAC0028B67D /* Base */, - F5D9C02427DABBE3002E48F6 /* tr */, - F5E0BDE027E1D7220033557E /* he */, - C1C3127D297E4C0100296DA4 /* ar */, - C1004DFC2981F5B700B8CF94 /* da */, - C1004E042981F67A00B8CF94 /* sv */, - C1004E0C2981F6A100B8CF94 /* ro */, - C1004E142981F6E200B8CF94 /* nl */, - C1004E1C2981F6F500B8CF94 /* nb */, - C1004E242981F72D00B8CF94 /* fr */, - C1004E2B2981F74300B8CF94 /* fi */, - C1004E2F2981F75B00B8CF94 /* es */, - C1004E352981F77B00B8CF94 /* de */, - C1BCB5B9298309C4001C50FF /* it */, - C1F490002995821600C8BD69 /* pl */, - C122DF0029BBFAAE00321F8D /* ru */, - C1B0CFDA29C786BF0045B04D /* ja */, - C1E693D029C786E200410918 /* pt-BR */, - C1FDCC0229C786F90056E652 /* sk */, - C192C60429C78711001EFEA6 /* vi */, - ); - name = InfoPlist.strings; - sourceTree = ""; - }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ @@ -4722,9 +4611,9 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CODE_SIGN_ENTITLEMENTS = "Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; GCC_DYNAMIC_NO_PIC = NO; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GENERATE_INFOPLIST_FILE = NO; @@ -5059,9 +4948,9 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -5080,73 +4969,20 @@ }; name = Release; }; - 43A943961B926B7B0051FA24 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; - CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - 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 = ""; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_INSTALL_OBJC_HEADER = NO; - SWIFT_OBJC_BRIDGING_HEADER = "WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h"; - SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; - TARGETED_DEVICE_FAMILY = 4; - }; - name = Debug; - }; - 43A943971B926B7B0051FA24 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; - CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - 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 = ""; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_INSTALL_OBJC_HEADER = NO; - SWIFT_OBJC_BRIDGING_HEADER = "WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h"; - SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; - TARGETED_DEVICE_FAMILY = 4; - }; - name = Release; - }; 43A9439A1B926B7B0051FA24 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = ""; - IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", "@executable_path/Frameworks", + "@executable_path/../../Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5161,15 +4997,16 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = ""; - IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", "@executable_path/Frameworks", + "@executable_path/../../Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5185,8 +5022,10 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5196,6 +5035,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; PRODUCT_NAME = LoopCore; SDKROOT = watchos; @@ -5210,8 +5050,10 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5221,6 +5063,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; PRODUCT_NAME = LoopCore; SDKROOT = watchos; @@ -5235,8 +5078,10 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5244,6 +5089,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; @@ -5256,8 +5102,10 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5265,6 +5113,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; @@ -5313,8 +5162,10 @@ isa = XCBuildConfiguration; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5322,6 +5173,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -5333,8 +5185,10 @@ isa = XCBuildConfiguration; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5342,6 +5196,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -5492,11 +5347,12 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; FRAMEWORK_SEARCH_PATHS = ""; - IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -5511,33 +5367,6 @@ }; name = Testflight; }; - B4E7CF952AD00A39009B4DF2 /* Testflight */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; - CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - 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 = ""; - 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 = { @@ -5556,9 +5385,9 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CODE_SIGN_ENTITLEMENTS = "Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; GCC_DYNAMIC_NO_PIC = NO; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GENERATE_INFOPLIST_FILE = NO; @@ -5590,9 +5419,9 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; @@ -5618,8 +5447,10 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5627,6 +5458,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; @@ -5639,8 +5471,10 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = LoopCore/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -5650,6 +5484,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; PRODUCT_NAME = LoopCore; SDKROOT = watchos; @@ -5663,8 +5498,10 @@ isa = XCBuildConfiguration; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -5672,6 +5509,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++17"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -5701,9 +5539,9 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; @@ -5728,9 +5566,9 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; - DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; @@ -5784,16 +5622,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 43A943951B926B7B0051FA24 /* Build configuration list for PBXNativeTarget "WatchApp Extension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 43A943961B926B7B0051FA24 /* Debug */, - B4E7CF952AD00A39009B4DF2 /* Testflight */, - 43A943971B926B7B0051FA24 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 43A943991B926B7B0051FA24 /* Build configuration list for PBXNativeTarget "WatchApp" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/DoseMathTests.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/DoseMathTests.xcscheme index f225f4098a..9fba088786 100644 --- a/Loop.xcodeproj/xcshareddata/xcschemes/DoseMathTests.xcscheme +++ b/Loop.xcodeproj/xcshareddata/xcschemes/DoseMathTests.xcscheme @@ -1,6 +1,6 @@ + BlueprintIdentifier = "A9E6758022713F4700E25293" + BuildableName = "LoopKit.framework" + BlueprintName = "LoopKit-watchOS" + ReferencedContainer = "container:../Common/LoopKit/LoopKit.xcodeproj"> @@ -42,9 +42,9 @@ buildForAnalyzing = "YES"> @@ -65,16 +65,6 @@ - - - - { @@ -53,7 +44,7 @@ extension NotificationManager { let yesStartPresetAction = UNNotificationAction( identifier: Action.startPreset.rawValue, - title: NSLocalizedString("Yes, Start Now", comment: "The title of the notification action to start a preset"), + title: NSLocalizedString("Start Preset", comment: "The title of the notification action to start a preset"), options: .foreground ) @@ -95,13 +86,19 @@ extension NotificationManager { } } } + } + + static func setNotificationCategories() { + let center = UNUserNotificationCenter.current() center.setNotificationCategories(notificationCategories) } - + + // MARK: - Notifications - - static func sendBolusFailureNotification(for error: PumpManagerError, units: Double, at startDate: Date, decisionId: UUID?, activationType: BolusActivationType) { + + @MainActor + static func sendBolusFailureNotification(for error: PumpManagerError, units: Double, at startDate: Date, decisionId: UUID?, activationType: BolusActivationType) async throws { let notification = UNMutableNotificationContent() notification.title = NSLocalizedString("Bolus Issue", comment: "The notification title for a bolus issue") @@ -138,7 +135,7 @@ extension NotificationManager { trigger: nil ) - UNUserNotificationCenter.current().add(request) + try await UNUserNotificationCenter.current().add(request) } static func sendRemoteBolusNotification(amount: Double) { diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index 6b5e0ed7c2..fd40cfb2af 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -10,6 +10,7 @@ import os.log import HealthKit import LoopKit import LoopKitUI +import LoopCore @MainActor class OnboardingManager { diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 26bbd36542..055b194d41 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -44,11 +44,6 @@ class TemporaryPresetsManager { _scheduleOverride = self.presetHistory.activeOverride(at: Date()) - if scheduleOverride?.context == .preMeal { - preMealOverride = scheduleOverride - scheduleOverride = nil - } - overrideIntentObserver = UserDefaults.appGroup?.observe( \.intentExtensionOverrideToSet, options: [.new], @@ -85,16 +80,16 @@ class TemporaryPresetsManager { presetActivationObservers.append(observer) } - public var scheduleOverride: TemporaryScheduleOverride? { + var preMealOverride: TemporaryScheduleOverride? { + scheduleOverride?.context == .preMeal ? scheduleOverride : nil + } + + var scheduleOverride: TemporaryScheduleOverride? { didSet { guard oldValue != scheduleOverride else { return } - if scheduleOverride != nil { - preMealOverride = nil - } - if scheduleOverride != oldValue { presetHistory.recordOverride(scheduleOverride) @@ -116,38 +111,9 @@ class TemporaryPresetsManager { } } - public var preMealOverride: TemporaryScheduleOverride? { - didSet { - guard oldValue != preMealOverride else { - return - } - - if let newValue = preMealOverride, newValue.context != .preMeal || newValue.settings.insulinNeedsScaleFactor != nil { - preconditionFailure("The `preMealOverride` field should be used only for a pre-meal target range override") - } - - if preMealOverride != nil { - scheduleOverride = nil - } - - presetHistory.recordOverride(preMealOverride) - - if let newPreset = preMealOverride { - for observer in self.presetActivationObservers { - observer.presetActivated(context: newPreset.context, duration: newPreset.duration) - } - - scheduleClearOverride(override: newPreset) - } - - notify(forChange: .preferences) - } - } - public var activeOverride: TemporaryScheduleOverride? { - let override = (preMealOverride ?? scheduleOverride) - if override?.isActive() == true { - return override + if scheduleOverride?.isActive() == true { + return scheduleOverride } else { return nil } @@ -209,8 +175,6 @@ class TemporaryPresetsManager { return presets } - - var clearOverrideTimer: Timer? public func scheduleClearOverride(override: TemporaryScheduleOverride) { clearOverrideTimer?.invalidate() @@ -228,8 +192,6 @@ class TemporaryPresetsManager { func endOverride(_ override: TemporaryScheduleOverride) { if override == scheduleOverride { clearOverride() - } else if override == preMealOverride { - clearOverride(matching: .preMeal) } } @@ -239,23 +201,13 @@ class TemporaryPresetsManager { 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 + var scheduleOverride = scheduleOverride + + if presumingMealEntry && scheduleOverride?.context == .preMeal { + scheduleOverride = nil } - if let effectiveOverride = currentEffectiveOverride { + if let effectiveOverride = scheduleOverride { return glucoseTargetRangeSchedule.applyingOverride(effectiveOverride) } else { return glucoseTargetRangeSchedule @@ -280,7 +232,7 @@ class TemporaryPresetsManager { } public func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { - preMealOverride = makePreMealOverride(beginningAt: date, for: duration) + scheduleOverride = makePreMealOverride(beginningAt: date, for: duration) } private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { @@ -307,44 +259,18 @@ class TemporaryPresetsManager { func startPreset(_ preset: SelectablePreset) { - switch preset { - case .custom(let temporaryScheduleOverridePreset): - scheduleOverride = temporaryScheduleOverridePreset.createOverride(enactTrigger: .local) - case .activity(let activity): - scheduleOverride = activity.preset.createOverride(enactTrigger: .local) - case .preMeal: - enablePreMealOverride(for: .hours(1)) - } + scheduleOverride = preset.createOverride() } - func endPreset() { - if activeOverride?.context == .preMeal { - clearOverride(matching: .preMeal) - } else { + public func endPreMealOverride() { + if let activeOverride = scheduleOverride, activeOverride.isActive(), activeOverride.context == .preMeal { + scheduleOverride?.scheduledEndDate = .now clearOverride() } } - public func endPreMealOverride() { - preMealOverride?.scheduledEndDate = .now - clearOverride(matching: .preMeal) - } - - 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 func clearOverride() { + self.scheduleOverride = nil } public var basalRateScheduleApplyingOverrideHistory: BasalRateSchedule? { @@ -462,7 +388,7 @@ class TemporaryPresetsManager { body: body, actions: actions) - let metadata: Alert.Metadata = ["presetId": Alert.MetadataValue(preset.id)] + let metadata: Alert.Metadata = [LoopNotificationUserInfoKey.presetId.rawValue: Alert.MetadataValue(preset.id)] let alert = Alert( identifier: nextScheduledPresetReminderIdentifier, @@ -490,6 +416,8 @@ extension TemporaryPresetsManager : AlertResponder { func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier) async throws { } func handleAlertAction(actionIdentifier: String, from alert: Alert) async throws { + if actionIdentifier == UNNotificationDismissActionIdentifier { return } + if actionIdentifier == NotificationManager.Action.startPreset.rawValue, let metdata = alert.metadata, let presetIdentifier = metdata["presetId"]?.wrapped as? String? diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 4be60fa3d4..abbaa25ed2 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -13,6 +13,12 @@ import LoopAlgorithm import LoopKit import LoopCore + +enum WatchDataManagerError: Error { + case decodingError + case expiredBolusRecommendation +} + @MainActor final class WatchDataManager: NSObject { @@ -148,8 +154,7 @@ final class WatchDataManager: NSObject { private func sendSettingsIfNeeded() { let userInfo = LoopSettingsUserInfo( loopSettings: settingsManager.loopSettings, - scheduleOverride: temporaryPresetsManager.scheduleOverride, - preMealOverride: temporaryPresetsManager.preMealOverride) + scheduleOverride: temporaryPresetsManager.scheduleOverride) guard let session = watchSession, session.isPaired, session.isWatchAppInstalled else { return @@ -216,8 +221,6 @@ final class WatchDataManager: NSObject { return } - log.default("*** sendWatchContextIfNeeded") - guard case .activated = session.activationState else { session.activate() return @@ -309,6 +312,7 @@ final class WatchDataManager: NSObject { dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate( recommendation: recommendedBolus, date: Date()) + log.debug("*** watch bolus recommended: %{public}@ (with carb entry: %{public}@", String(describing: recommendedBolus.amount), String(describing: potentialCarbEntry)) } var historicalGlucose: [HistoricalGlucoseValue]? @@ -380,13 +384,13 @@ final class WatchDataManager: NSObject { private func addCarbEntryAndBolusFromWatchMessage(_ message: [String: Any]) async throws { guard let bolus = SetBolusUserInfo(rawValue: message as SetBolusUserInfo.RawValue) else { log.error("Could not enact bolus from from unknown message: %{public}@", String(describing: message)) - return + throw WatchDataManagerError.decodingError } // Prevent any delayed messages from enacting. guard bolus.startDate.timeIntervalSinceNow > -30 else { log.error("Could not enact expired bolus from watch: %{public}@", String(describing: message)) - return + throw WatchDataManagerError.expiredBolusRecommendation } var dosingDecision: BolusDosingDecision @@ -407,48 +411,57 @@ final class WatchDataManager: NSObject { 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, decisionId: dosingDecision.id, 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() + try await deviceManager.enactBolus(units: bolus.value, decisionId: dosingDecision.id, activationType: bolus.activationType) + self.analyticsServicesManager?.didBolus(source: "Watch", units: bolus.value) } - func handleWatchMessage(_ message: [String: Any]) async -> [String: Any] { + func handleWatchMessage(_ message: [String: Any]) async throws -> [String: Any] { switch message["name"] as? String { - case PotentialCarbEntryUserInfo.name?: - if let potentialCarbEntry = PotentialCarbEntryUserInfo(rawValue: message)?.carbEntry { - let context = await createWatchContext(recommendingBolusFor: potentialCarbEntry) + case SettingsRequestUserInfo.name?: + let userInfo = LoopSettingsUserInfo( + loopSettings: settingsManager.loopSettings, + scheduleOverride: temporaryPresetsManager.scheduleOverride) + return userInfo.rawValue + case GetBolusRecommendationUserInfo.name?: + if let request = GetBolusRecommendationUserInfo(rawValue: message) { + let context = await createWatchContext(recommendingBolusFor: request.carbEntry) return context.rawValue } else { log.error("Could not recommend bolus from from unknown message: %{public}@", String(describing: message)) } case SetBolusUserInfo.name?: // Add carbs if applicable; start the bolus and reply when it's successfully requested - Task { - try await addCarbEntryAndBolusFromWatchMessage(message) - } - case LoopSettingsUserInfo.name?: - if let userInfo = LoopSettingsUserInfo(rawValue: message) { - // So far we only support watch changes of temporary schedule overrides - temporaryPresetsManager.preMealOverride = userInfo.preMealOverride - temporaryPresetsManager.scheduleOverride = userInfo.scheduleOverride + try await addCarbEntryAndBolusFromWatchMessage(message) + let updatedContext = await createWatchContext() + lastComplicationContext = updatedContext // Watch will use this to update context + return updatedContext.rawValue + + case SetPresetUserInfo.name?: + if let userInfo = SetPresetUserInfo(rawValue: message) { + if let presetIdentifier = userInfo.presetIdentifier { + temporaryPresetsManager.startPreset(withIdentifier: presetIdentifier) + } else { + temporaryPresetsManager.clearOverride() + } // Prevent re-sending these updated settings back to the watch - lastSentUserInfo?.preMealOverride = userInfo.preMealOverride - lastSentUserInfo?.scheduleOverride = userInfo.scheduleOverride + lastSentUserInfo?.scheduleOverride = temporaryPresetsManager.scheduleOverride + + if let alertIdentifier = userInfo.alertIdentifier { + let id = Alert.Identifier(managerIdentifier: temporaryPresetsManager.managerIdentifier, alertIdentifier: alertIdentifier) + try? await alertManager.acknowledgeAlert(identifier: id) + } + return [:] } let context = await createWatchContext() return context.rawValue + case AcknowledgeAlertUserInfo.name?: + if let userInfo = AcknowledgeAlertUserInfo(rawValue: message) { + let id = Alert.Identifier(managerIdentifier: userInfo.managerIdentifier, alertIdentifier: userInfo.alertIdentifier) + try? await alertManager.acknowledgeAlert(identifier: id) + } + return [:] case CarbBackfillRequestUserInfo.name?: if let userInfo = CarbBackfillRequestUserInfo(rawValue: message) { do { @@ -495,8 +508,11 @@ final class WatchDataManager: NSObject { extension WatchDataManager: WCSessionDelegate { nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { Task { @MainActor in - let reply = await handleWatchMessage(message) - replyHandler(reply) + do { + replyHandler(try await handleWatchMessage(message)) + } catch { + replyHandler(["error":String(describing: error)]) + } } } diff --git a/Loop/Models/WatchContext+LoopKit.swift b/Loop/Models/WatchContext+LoopKit.swift index e1374e4ed1..997124203d 100644 --- a/Loop/Models/WatchContext+LoopKit.swift +++ b/Loop/Models/WatchContext+LoopKit.swift @@ -9,6 +9,7 @@ import Foundation import LoopKit import LoopAlgorithm +import LoopCore extension WatchContext { convenience init(glucose: GlucoseSampleValue?, glucoseUnit: LoopUnit?) { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 2535b99738..0941b2f072 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -480,15 +480,6 @@ final class StatusTableViewController: LoopChartsTableViewController { lastLoopError = nil } - // 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)) - self.lastLoopError = lastLoopError if let automatedTreatmentState = loopManager.automatedTreatmentState { diff --git a/Loop/View Models/StatusTableViewModel.swift b/Loop/View Models/StatusTableViewModel.swift index 530089620d..62e3713575 100644 --- a/Loop/View Models/StatusTableViewModel.swift +++ b/Loop/View Models/StatusTableViewModel.swift @@ -7,6 +7,7 @@ // import LoopKit +import LoopCore @MainActor @Observable diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift index 265e5d7059..34d79ebbe3 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift @@ -9,6 +9,7 @@ import LoopAlgorithm import LoopKit import SwiftUI +import LoopCore struct DatedQuantity: Hashable { let date: Date diff --git a/Loop/Views/Presets/Components/EditPresetDurationView.swift b/Loop/Views/Presets/Components/EditPresetDurationView.swift index 890d1be170..12c659ca03 100644 --- a/Loop/Views/Presets/Components/EditPresetDurationView.swift +++ b/Loop/Views/Presets/Components/EditPresetDurationView.swift @@ -9,11 +9,11 @@ import LoopKit import LoopKitUI import SwiftUI +import LoopCore struct EditPresetDurationView: View { @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @Environment(\.settingsManager) private var settingsManager - @Environment(\.colorPalette) private var colorPalette @Environment(\.dismiss) private var dismiss @State var dateSelection: Date = Date() @@ -41,7 +41,7 @@ struct EditPresetDurationView: View { VStack(spacing: 0) { VStack(spacing: 24) { - preset?.title(font: .largeTitle, iconSize: 36, colorPalette: colorPalette) + preset?.title(font: .largeTitle, iconSize: 36) .fontWeight(.bold) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift index afb8d935bd..3560c1d4d8 100644 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ b/Loop/Views/Presets/Components/PresetCard.swift @@ -10,6 +10,7 @@ import LoopAlgorithm import LoopKitUI import SwiftUI import LoopKit +import LoopCore struct PresetCard: View { @@ -121,70 +122,6 @@ struct PresetCard: View { } } -extension PresetExpectedEndTime { - private static let timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - return formatter - }() - - var localizedTitle: String { - switch self { - case .untilCarbsEntered: - return NSLocalizedString("on until carbs added", comment: "Preset card pre-meal expected end time") - case .indefinite: - return NSLocalizedString("on until turned off", comment: "Preset card indefinite scheduled end time") - case .scheduled(let date): - return NSLocalizedString("on until \(Self.timeFormatter.string(from: date))", comment: "Presets card time duration accessibility label") - } - } - - var accessibilityLabel: String { - switch self { - case .untilCarbsEntered: - return NSLocalizedString("on until carbs added", comment: "Presets card pre-meal expected end time accessibility label") - case .indefinite: - return NSLocalizedString("on until turned off", comment: "Presets card indefinite duration accessibility label") - case .scheduled(let date): - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute] - formatter.unitsStyle = .spellOut - return NSLocalizedString("on until \(Self.timeFormatter.string(from: date))", comment: "Presets card time duration accessibility label") - } - } -} - -extension PresetDuration { - var localizedTitle: String { - switch self { - case .untilCarbsEntered: - return NSLocalizedString("until carbs added", comment: "Preset card pre-meal duration") - case .indefinite: - return NSLocalizedString("until turned off", comment: "Preset card indefinite duration") - case .duration(let duration): - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute] - formatter.unitsStyle = .short - return formatter.string(from: duration) ?? "" - - } - } - - var accessibilityLabel: String { - switch self { - case .untilCarbsEntered: - return NSLocalizedString("Active until carbs are added", comment: "Presets card pre-meal duration accessibility label") - case .indefinite: - return NSLocalizedString("Active until turned off", comment: "Presets card indefinite duration accessibility label") - case .duration(let duration): - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute] - formatter.unitsStyle = .spellOut - return NSLocalizedString("Active for \(formatter.string(from: duration) ?? "")", comment: "Presets card time duration accessibility label") - } - } -} - extension Color { init(presetSymbolTint: PresetSymbol.SymbolTint?, palette: LoopUIColorPalette) { guard let presetSymbolTint else { diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 45450c0479..4eb3e19f23 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -9,6 +9,7 @@ import LoopKit import LoopKitUI import SwiftUI +import LoopCore struct PresetDetentView: View { @@ -18,7 +19,6 @@ struct PresetDetentView: View { } @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference - @Environment(\.colorPalette) private var colorPalette @Environment(\.settingsManager) private var settingsManager @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @Environment(\.dismiss) private var dismiss @@ -83,7 +83,7 @@ struct PresetDetentView: View { .accessibilityIdentifier("button_startPreset") case .end: Button("End Preset") { - temporaryPresetsManager.endPreset() + temporaryPresetsManager.clearOverride() dismiss() } .buttonStyle(ActionButtonStyle(.destructive)) @@ -118,7 +118,7 @@ struct PresetDetentView: View { VStack(spacing: 24) { VStack(spacing: 16) { VStack(spacing: 4) { - preset.title(font: .title2, iconSize: 20, colorPalette: colorPalette) + preset.title(font: .title2, iconSize: 20) subtitle } @@ -166,3 +166,17 @@ struct PresetDetentView: View { .sheetDetent(height: sheetContentHeight) } } + +extension SelectablePreset { + func title(font: Font, iconSize: Double) -> some View { + HStack(spacing: 6) { + if let icon, !icon.isEmpty { + PresetSymbolView(icon) + } + + Text(name) + .font(font) + .fontWeight(.semibold) + } + } +} diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift index 356819889d..44d74b8941 100644 --- a/Loop/Views/Presets/CreatePresetView.swift +++ b/Loop/Views/Presets/CreatePresetView.swift @@ -125,7 +125,7 @@ struct CreatePresetView: View { } } if startPreset { - let temporaryScheduleOverride = temporaryPreset.createOverride(enactTrigger: .local) + let temporaryScheduleOverride = temporaryPreset.createOverride(enactTrigger: .local, isCustom: !preset.savePreset) temporaryPresetsManager.scheduleOverride = temporaryScheduleOverride } } diff --git a/Loop/Views/Presets/DurationPickerView.swift b/Loop/Views/Presets/DurationPickerView.swift index bdd732d8e6..93a89eba69 100644 --- a/Loop/Views/Presets/DurationPickerView.swift +++ b/Loop/Views/Presets/DurationPickerView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import LoopCore struct DurationPickerView: View { @Binding var durationType: PresetDuration diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index e41fe87223..484815b8c2 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -11,6 +11,7 @@ import LoopKit import SwiftUI import LoopKitUI import LoopAlgorithm +import LoopCore struct EditPresetView: View { @Environment(\.dismiss) private var dismiss diff --git a/Loop/Views/Presets/NewCustomPreset.swift b/Loop/Views/Presets/NewCustomPreset.swift index 4052d2df6c..16e1ccd81b 100644 --- a/Loop/Views/Presets/NewCustomPreset.swift +++ b/Loop/Views/Presets/NewCustomPreset.swift @@ -9,6 +9,7 @@ import LoopAlgorithm import UIKit import LoopKit +import LoopCore extension PresetScheduleRepeatOptions: @retroactive CustomStringConvertible { public var description: String { diff --git a/Loop/Views/Presets/Components/PresetSymbolView.swift b/Loop/Views/Presets/PresetSymbolView.swift similarity index 100% rename from Loop/Views/Presets/Components/PresetSymbolView.swift rename to Loop/Views/Presets/PresetSymbolView.swift diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index c187e0af10..fe0ee31b71 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -10,6 +10,7 @@ import LoopAlgorithm import LoopKit import LoopKitUI import SwiftUI +import LoopCore enum PresetSortOption: Int, CaseIterable { case name diff --git a/LoopCore/GetBolusRecommendationUserInfo.swift b/LoopCore/GetBolusRecommendationUserInfo.swift new file mode 100644 index 0000000000..63f45547ed --- /dev/null +++ b/LoopCore/GetBolusRecommendationUserInfo.swift @@ -0,0 +1,51 @@ +// +// GetBolusRecommendationUserInfo.swift +// Naterade +// +// Created by Nathan Racklyeft on 1/23/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import LoopKit + + +public struct GetBolusRecommendationUserInfo { + public let carbEntry: NewCarbEntry? + + public init(carbEntry: NewCarbEntry?) { + self.carbEntry = carbEntry + } +} + + +extension GetBolusRecommendationUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + static let version = 1 + public static let name = "GetBolusRecommendationUserInfo" + + public init?(rawValue: RawValue) { + guard rawValue["v"] as? Int == type(of: self).version && rawValue["name"] as? String == GetBolusRecommendationUserInfo.name + else { + return nil + } + + if let value = rawValue["ce"] as? NewCarbEntry.RawValue, + let carbEntry = NewCarbEntry(rawValue: value) + { + self.carbEntry = carbEntry + } else { + self.carbEntry = nil + } + } + + public var rawValue: RawValue { + var rval: RawValue = [ + "v": type(of: self).version, + "name": GetBolusRecommendationUserInfo.name, + ] + rval["ce"] = carbEntry?.rawValue + return rval + } +} diff --git a/LoopCore/Models/AcknowledgeAlertUserInfo.swift b/LoopCore/Models/AcknowledgeAlertUserInfo.swift new file mode 100644 index 0000000000..5d14be4f41 --- /dev/null +++ b/LoopCore/Models/AcknowledgeAlertUserInfo.swift @@ -0,0 +1,46 @@ +// +// AcknowledgeAlertUserInfo.swift +// Loop +// +// Created by Pete Schwamb on 9/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +public struct AcknowledgeAlertUserInfo { + let version = 1 + public let alertIdentifier: String + public let managerIdentifier: String + + public init(alertIdentifier: String, managerIdentifier: String) { + self.alertIdentifier = alertIdentifier + self.managerIdentifier = managerIdentifier + } +} + +extension AcknowledgeAlertUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + public static let name = "AcknowledgeAlertUserInfo" + + public init?(rawValue: RawValue) { + guard + rawValue["v"] as? Int == version, + let alertIdentifier = rawValue["ai"] as? String, + let managerIdentifier = rawValue["mi"] as? String + else { + return nil + } + + self.alertIdentifier = alertIdentifier + self.managerIdentifier = managerIdentifier + } + + public var rawValue: RawValue { + return [ + "v": version, + "name": AcknowledgeAlertUserInfo.name, + "ai": alertIdentifier, + "mi": managerIdentifier, + ] + } +} diff --git a/Common/Models/CarbBackfillRequestUserInfo.swift b/LoopCore/Models/CarbBackfillRequestUserInfo.swift similarity index 67% rename from Common/Models/CarbBackfillRequestUserInfo.swift rename to LoopCore/Models/CarbBackfillRequestUserInfo.swift index 83fce9c335..30c38cc012 100644 --- a/Common/Models/CarbBackfillRequestUserInfo.swift +++ b/LoopCore/Models/CarbBackfillRequestUserInfo.swift @@ -8,17 +8,21 @@ import Foundation -struct CarbBackfillRequestUserInfo { +public struct CarbBackfillRequestUserInfo { let version = 1 - let startDate: Date + public let startDate: Date + + public init(startDate: Date) { + self.startDate = startDate + } } extension CarbBackfillRequestUserInfo: RawRepresentable { - typealias RawValue = [String: Any] + public typealias RawValue = [String: Any] - static let name = "CarbBackfillRequestUserInfo" + public static let name = "CarbBackfillRequestUserInfo" - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { guard rawValue["v"] as? Int == version, rawValue["name"] as? String == CarbBackfillRequestUserInfo.name, @@ -30,7 +34,7 @@ extension CarbBackfillRequestUserInfo: RawRepresentable { self.startDate = startDate } - var rawValue: RawValue { + public var rawValue: RawValue { return [ "v": version, "name": CarbBackfillRequestUserInfo.name, diff --git a/Common/Models/GlucoseBackfillRequestUserInfo.swift b/LoopCore/Models/GlucoseBackfillRequestUserInfo.swift similarity index 67% rename from Common/Models/GlucoseBackfillRequestUserInfo.swift rename to LoopCore/Models/GlucoseBackfillRequestUserInfo.swift index 899a93434b..886129fe5d 100644 --- a/Common/Models/GlucoseBackfillRequestUserInfo.swift +++ b/LoopCore/Models/GlucoseBackfillRequestUserInfo.swift @@ -8,17 +8,21 @@ import Foundation -struct GlucoseBackfillRequestUserInfo { +public struct GlucoseBackfillRequestUserInfo { let version = 1 - let startDate: Date + public let startDate: Date + + public init(startDate: Date) { + self.startDate = startDate + } } extension GlucoseBackfillRequestUserInfo: RawRepresentable { - typealias RawValue = [String: Any] + public typealias RawValue = [String: Any] - static let name = "GlucoseBackfillRequestUserInfo" + public static let name = "GlucoseBackfillRequestUserInfo" - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { guard rawValue["v"] as? Int == version, rawValue["name"] as? String == GlucoseBackfillRequestUserInfo.name, @@ -30,7 +34,7 @@ extension GlucoseBackfillRequestUserInfo: RawRepresentable { self.startDate = startDate } - var rawValue: RawValue { + public var rawValue: RawValue { return [ "v": version, "name": GlucoseBackfillRequestUserInfo.name, diff --git a/Common/Models/IntentExtensionInfo.swift b/LoopCore/Models/IntentExtensionInfo.swift similarity index 64% rename from Common/Models/IntentExtensionInfo.swift rename to LoopCore/Models/IntentExtensionInfo.swift index 653fbe9afc..c3a6a507fb 100644 --- a/Common/Models/IntentExtensionInfo.swift +++ b/LoopCore/Models/IntentExtensionInfo.swift @@ -8,22 +8,22 @@ import Foundation -struct IntentExtensionInfo: RawRepresentable { - typealias RawValue = [String: Any] +public struct IntentExtensionInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + public var overridePresetNames: [String]? - var overridePresetNames: [String]? - init() { } - init(rawValue: RawValue) { + public init(rawValue: RawValue) { overridePresetNames = rawValue["overridePresetNames"] as? [String] } - init(overridePresetNames: [String]?) { + public init(overridePresetNames: [String]?) { self.overridePresetNames = overridePresetNames } - var rawValue: RawValue { + public var rawValue: RawValue { var raw: RawValue = [:] raw["overridePresetNames"] = overridePresetNames diff --git a/LoopCore/Models/LoopSettingsUserInfo.swift b/LoopCore/Models/LoopSettingsUserInfo.swift new file mode 100644 index 0000000000..860e31bdf8 --- /dev/null +++ b/LoopCore/Models/LoopSettingsUserInfo.swift @@ -0,0 +1,54 @@ +// +// LoopSettingsUserInfo.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import LoopKit + +public struct LoopSettingsUserInfo: Equatable { + public var loopSettings: LoopSettings + public var scheduleOverride: TemporaryScheduleOverride? + + public init(loopSettings: LoopSettings, scheduleOverride: TemporaryScheduleOverride? = nil) { + self.loopSettings = loopSettings + self.scheduleOverride = scheduleOverride + } +} + +extension LoopSettingsUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + public static let name = "LoopSettingsUserInfo" + static let version = 1 + + public init?(rawValue: RawValue) { + guard rawValue["v"] as? Int == LoopSettingsUserInfo.version, + rawValue["name"] as? String == LoopSettingsUserInfo.name, + let settingsRaw = rawValue["s"] as? LoopSettings.RawValue, + let loopSettings = LoopSettings(rawValue: settingsRaw) + else { + return nil + } + + self.loopSettings = loopSettings + + if let rawScheduleOverride = rawValue["o"] as? TemporaryScheduleOverride.RawValue { + self.scheduleOverride = TemporaryScheduleOverride(rawValue: rawScheduleOverride) + } else { + self.scheduleOverride = nil + } + } + + public var rawValue: RawValue { + var raw: RawValue = [ + "v": LoopSettingsUserInfo.version, + "name": LoopSettingsUserInfo.name, + "s": loopSettings.rawValue + ] + raw["o"] = scheduleOverride?.rawValue + + return raw + } +} diff --git a/Common/Models/NotificationActionSelection.swift b/LoopCore/Models/NotificationActionSelection.swift similarity index 64% rename from Common/Models/NotificationActionSelection.swift rename to LoopCore/Models/NotificationActionSelection.swift index 6edae29309..256e499c8f 100644 --- a/Common/Models/NotificationActionSelection.swift +++ b/LoopCore/Models/NotificationActionSelection.swift @@ -7,19 +7,25 @@ // -struct NotificationActionSelection { +public struct NotificationActionSelection { let version = 1 - let alertIdentifier: String - let managerIdentifier: String - let actionIdentifier: String + public let alertIdentifier: String + public let managerIdentifier: String + public let actionIdentifier: String + + public init(alertIdentifier: String, managerIdentifier: String, actionIdentifier: String) { + self.alertIdentifier = alertIdentifier + self.managerIdentifier = managerIdentifier + self.actionIdentifier = actionIdentifier + } } extension NotificationActionSelection: RawRepresentable { - typealias RawValue = [String: Any] + public typealias RawValue = [String: Any] - static let name = "NotificationActionSelection" + public static let name = "NotificationActionSelection" - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { guard rawValue["v"] as? Int == version, rawValue["name"] as? String == NotificationActionSelection.name, @@ -35,7 +41,7 @@ extension NotificationActionSelection: RawRepresentable { self.actionIdentifier = actionIdentifier } - var rawValue: RawValue { + public var rawValue: RawValue { return [ "v": version, "name": NotificationActionSelection.name, diff --git a/Common/Models/SetBolusUserInfo.swift b/LoopCore/Models/SetBolusUserInfo.swift similarity index 63% rename from Common/Models/SetBolusUserInfo.swift rename to LoopCore/Models/SetBolusUserInfo.swift index a12f7c2ef2..137e5659b8 100644 --- a/Common/Models/SetBolusUserInfo.swift +++ b/LoopCore/Models/SetBolusUserInfo.swift @@ -10,22 +10,30 @@ import Foundation import LoopKit -struct SetBolusUserInfo { - let value: Double - let startDate: Date - let contextDate: Date? - let carbEntry: NewCarbEntry? - let activationType: BolusActivationType +public struct SetBolusUserInfo { + public let value: Double + public let startDate: Date + public let contextDate: Date? + public let carbEntry: NewCarbEntry? + public let activationType: BolusActivationType + + public init(value: Double, startDate: Date, contextDate: Date?, carbEntry: NewCarbEntry?, activationType: BolusActivationType) { + self.value = value + self.startDate = startDate + self.contextDate = contextDate + self.carbEntry = carbEntry + self.activationType = activationType + } } extension SetBolusUserInfo: RawRepresentable { - typealias RawValue = [String: Any] + public typealias RawValue = [String: Any] - static let version = 1 - static let name = "SetBolusUserInfo" + public static let version = 1 + public static let name = "SetBolusUserInfo" - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { guard rawValue["v"] as? Int == type(of: self).version && rawValue["name"] as? String == SetBolusUserInfo.name, let value = rawValue["bv"] as? Double, @@ -43,7 +51,7 @@ extension SetBolusUserInfo: RawRepresentable { self.activationType = activationType } - var rawValue: RawValue { + public var rawValue: RawValue { var raw: RawValue = [ "v": type(of: self).version, "name": SetBolusUserInfo.name, diff --git a/LoopCore/Models/SetPresetUserInfo.swift b/LoopCore/Models/SetPresetUserInfo.swift new file mode 100644 index 0000000000..5a27b72ec0 --- /dev/null +++ b/LoopCore/Models/SetPresetUserInfo.swift @@ -0,0 +1,51 @@ +// +// SetPresetUserInfo.swift +// Loop +// +// Created by Pete Schwamb on 9/18/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +public struct SetPresetUserInfo { + let version = 1 + public let presetIdentifier: String? // nil = clear preset + public let alertIdentifier: String? // alertIdentifier to acknowledge, if any + + public init(presetIdentifier: String?, alertIdentifier: String? = nil) { + self.presetIdentifier = presetIdentifier + self.alertIdentifier = alertIdentifier + } +} + +extension SetPresetUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + public static let name = "SetPresetUserInfo" + + public init?(rawValue: RawValue) { + guard + rawValue["v"] as? Int == version, + rawValue["name"] as? String == SetPresetUserInfo.name + else { + return nil + } + + self.presetIdentifier = rawValue["pi"] as? String + self.alertIdentifier = rawValue["aa"] as? String + } + + public var rawValue: RawValue { + var rVal: RawValue = [ + "v": version, + "name": SetPresetUserInfo.name + ] + + rVal["pi"] = presetIdentifier + rVal["aa"] = alertIdentifier + + return rVal + } +} diff --git a/LoopCore/Models/SettingsRequestUserInfo.swift b/LoopCore/Models/SettingsRequestUserInfo.swift new file mode 100644 index 0000000000..47dc45a2f7 --- /dev/null +++ b/LoopCore/Models/SettingsRequestUserInfo.swift @@ -0,0 +1,37 @@ +// +// SettingsRequestUserInfo.swift +// Loop +// +// Created by Pete Schwamb on 9/9/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct SettingsRequestUserInfo { + let version = 1 + + public init() {} +} + +extension SettingsRequestUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + public static let name = "SettingsRequestUserInfo" + + public init?(rawValue: RawValue) { + guard + rawValue["v"] as? Int == version, + rawValue["name"] as? String == SettingsRequestUserInfo.name + else { + return nil + } + } + + public var rawValue: RawValue { + return [ + "v": version, + "name": SettingsRequestUserInfo.name, + ] + } +} diff --git a/Common/Models/SupportedBolusVolumesUserInfo.swift b/LoopCore/Models/SupportedBolusVolumesUserInfo.swift similarity index 70% rename from Common/Models/SupportedBolusVolumesUserInfo.swift rename to LoopCore/Models/SupportedBolusVolumesUserInfo.swift index 077540e534..b724a65e76 100644 --- a/Common/Models/SupportedBolusVolumesUserInfo.swift +++ b/LoopCore/Models/SupportedBolusVolumesUserInfo.swift @@ -6,12 +6,16 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // -struct SupportedBolusVolumesUserInfo { - var supportedBolusVolumes: [Double] +public struct SupportedBolusVolumesUserInfo { + public var supportedBolusVolumes: [Double] + + public init(supportedBolusVolumes: [Double]) { + self.supportedBolusVolumes = supportedBolusVolumes + } } extension SupportedBolusVolumesUserInfo: RawRepresentable { - typealias RawValue = [String: Any] + public typealias RawValue = [String: Any] private enum Key: String { case version = "v" @@ -19,10 +23,10 @@ extension SupportedBolusVolumesUserInfo: RawRepresentable { case supportedBolusVolumes = "sbv" } - static let name = "SupportedBolusVolumesUserInfo" + public static let name = "SupportedBolusVolumesUserInfo" static let version = 1 - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { guard rawValue[Key.version.rawValue] as? Int == Self.version, rawValue[Key.name.rawValue] as? String == Self.name, @@ -34,7 +38,7 @@ extension SupportedBolusVolumesUserInfo: RawRepresentable { self.init(supportedBolusVolumes: supportedBolusVolumes) } - var rawValue: RawValue { + public var rawValue: RawValue { [ Key.version.rawValue: Self.version, Key.name.rawValue: Self.name, diff --git a/Common/Models/WatchContext.swift b/LoopCore/Models/WatchContext.swift similarity index 58% rename from Common/Models/WatchContext.swift rename to LoopCore/Models/WatchContext.swift index cc32c4d20c..691b01d6cb 100644 --- a/Common/Models/WatchContext.swift +++ b/LoopCore/Models/WatchContext.swift @@ -11,49 +11,97 @@ import LoopKit import LoopAlgorithm -final class WatchContext: RawRepresentable { - typealias RawValue = [String: Any] +public final class WatchContext: RawRepresentable { + public typealias RawValue = [String: Any] private let version = 5 - var creationDate = Date() + public var creationDate = Date() - var displayGlucoseUnit: LoopUnit? + public var displayGlucoseUnit: LoopUnit? - var glucose: LoopQuantity? - var glucoseCondition: GlucoseCondition? - var glucoseTrend: GlucoseTrend? - var glucoseTrendRate: LoopQuantity? - var glucoseDate: Date? - var glucoseIsDisplayOnly: Bool? - var glucoseWasUserEntered: Bool? - var glucoseSyncIdentifier: String? + public var glucose: LoopQuantity? + public var glucoseCondition: GlucoseCondition? + public var glucoseTrend: GlucoseTrend? + public var glucoseTrendRate: LoopQuantity? + public var glucoseDate: Date? + public var glucoseIsDisplayOnly: Bool? + public var glucoseWasUserEntered: Bool? + public var glucoseSyncIdentifier: String? - var predictedGlucose: WatchPredictedGlucose? - var eventualGlucose: LoopQuantity? { + public var predictedGlucose: WatchPredictedGlucose? + public var eventualGlucose: LoopQuantity? { return predictedGlucose?.values.last?.quantity } - var loopLastRunDate: Date? - var lastNetTempBasalDose: Double? - var lastNetTempBasalDate: Date? - var recommendedBolusDose: Double? - - var potentialCarbEntry: NewCarbEntry? - - var cob: Double? - var iob: Double? - var reservoir: Double? - var reservoirPercentage: Double? - var batteryPercentage: Double? - - var cgmManagerState: CGMManager.RawStateValue? - - var isClosedLoop: Bool? - - init() {} + public var loopLastRunDate: Date? + public var lastNetTempBasalDose: Double? + public var lastNetTempBasalDate: Date? + public var recommendedBolusDose: Double? + + public var potentialCarbEntry: NewCarbEntry? + + public var cob: Double? + public var iob: Double? + public var reservoir: Double? + public var reservoirPercentage: Double? + public var batteryPercentage: Double? + + public var cgmManagerState: CGMManager.RawStateValue? + + public var isClosedLoop: Bool? + + public init( + creationDate: Date = Date(), + glucose: LoopQuantity? = nil, + displayGlucoseUnit: LoopUnit? = nil, + glucoseCondition: GlucoseCondition? = nil, + glucoseTrend: GlucoseTrend? = nil, + glucoseTrendRate: LoopQuantity? = nil, + glucoseDate: Date? = nil, + glucoseIsDisplayOnly: Bool? = nil, + glucoseWasUserEntered: Bool? = nil, + glucoseSyncIdentifier: String? = nil, + predictedGlucose: WatchPredictedGlucose? = nil, + loopLastRunDate: Date? = nil, + lastNetTempBasalDose: Double? = nil, + lastNetTempBasalDate: Date? = nil, + recommendedBolusDose: Double? = nil, + potentialCarbEntry: NewCarbEntry? = nil, + cob: Double? = nil, + iob: Double? = nil, + reservoir: Double? = nil, + reservoirPercentage: Double? = nil, + batteryPercentage: Double? = nil, + cgmManagerState: CGMManager.RawStateValue? = nil, + isClosedLoop: Bool? = nil + ) { + self.creationDate = creationDate + self.displayGlucoseUnit = displayGlucoseUnit + self.glucose = glucose + self.glucoseCondition = glucoseCondition + self.glucoseTrend = glucoseTrend + self.glucoseTrendRate = glucoseTrendRate + self.glucoseDate = glucoseDate + self.glucoseIsDisplayOnly = glucoseIsDisplayOnly + self.glucoseWasUserEntered = glucoseWasUserEntered + self.glucoseSyncIdentifier = glucoseSyncIdentifier + self.predictedGlucose = predictedGlucose + self.loopLastRunDate = loopLastRunDate + self.lastNetTempBasalDose = lastNetTempBasalDose + self.lastNetTempBasalDate = lastNetTempBasalDate + self.recommendedBolusDose = recommendedBolusDose + self.potentialCarbEntry = potentialCarbEntry + self.cob = cob + self.iob = iob + self.reservoir = reservoir + self.reservoirPercentage = reservoirPercentage + self.batteryPercentage = batteryPercentage + self.cgmManagerState = cgmManagerState + self.isClosedLoop = isClosedLoop + } - required init?(rawValue: RawValue) { + public required init?(rawValue: RawValue) { guard rawValue["v"] as? Int == version, let creationDate = rawValue["cd"] as? Date else { return nil } @@ -103,7 +151,7 @@ final class WatchContext: RawRepresentable { } } - var rawValue: RawValue { + public var rawValue: RawValue { var raw: [String: Any] = [ "v": version, "cd": creationDate @@ -148,7 +196,7 @@ final class WatchContext: RawRepresentable { extension WatchContext { - func shouldReplace(_ other: WatchContext) -> Bool { + public func shouldReplace(_ other: WatchContext) -> Bool { if let date = self.glucoseDate, let otherDate = other.glucoseDate { return date >= otherDate } else { @@ -158,7 +206,7 @@ extension WatchContext { } extension WatchContext { - var newGlucoseSample: NewGlucoseSample? { + public var newGlucoseSample: NewGlucoseSample? { if let quantity = glucose, let date = glucoseDate, let syncIdentifier = glucoseSyncIdentifier { return NewGlucoseSample(date: date, quantity: quantity, diff --git a/Common/Models/WatchContextRequestUserInfo.swift b/LoopCore/Models/WatchContextRequestUserInfo.swift similarity index 90% rename from Common/Models/WatchContextRequestUserInfo.swift rename to LoopCore/Models/WatchContextRequestUserInfo.swift index 6f7797ce9d..cb38408d17 100644 --- a/Common/Models/WatchContextRequestUserInfo.swift +++ b/LoopCore/Models/WatchContextRequestUserInfo.swift @@ -9,7 +9,9 @@ import Foundation -struct WatchContextRequestUserInfo { } +public struct WatchContextRequestUserInfo { + public init() {} +} extension WatchContextRequestUserInfo: RawRepresentable { public typealias RawValue = [String: Any] diff --git a/Common/Models/WatchPredictedGlucose.swift b/LoopCore/Models/WatchPredictedGlucose.swift similarity index 81% rename from Common/Models/WatchPredictedGlucose.swift rename to LoopCore/Models/WatchPredictedGlucose.swift index d697418e43..b17ca89c76 100644 --- a/Common/Models/WatchPredictedGlucose.swift +++ b/LoopCore/Models/WatchPredictedGlucose.swift @@ -10,11 +10,10 @@ import Foundation import LoopKit import LoopAlgorithm +public struct WatchPredictedGlucose: Equatable { + public let values: [PredictedGlucoseValue] -struct WatchPredictedGlucose: Equatable { - let values: [PredictedGlucoseValue] - - init?(values: [PredictedGlucoseValue]) { + public init?(values: [PredictedGlucoseValue]) { guard values.count > 1 else { return nil } @@ -24,9 +23,9 @@ struct WatchPredictedGlucose: Equatable { extension WatchPredictedGlucose: RawRepresentable { - typealias RawValue = [String: Any] + public typealias RawValue = [String: Any] - var rawValue: RawValue { + public var rawValue: RawValue { return [ "v": values.map { Int16($0.quantity.doubleValue(for: .milligramsPerDeciliter).clamped(to: Double(Int16.min)...Double(Int16.max))) }, @@ -35,7 +34,7 @@ extension WatchPredictedGlucose: RawRepresentable { ] } - init?(rawValue: RawValue) { + public init?(rawValue: RawValue) { guard let values = rawValue["v"] as? [Int16], let firstDate = rawValue["d"] as? Date, diff --git a/LoopCore/NotificationManager.swift b/LoopCore/NotificationManager.swift new file mode 100644 index 0000000000..acee5ed57c --- /dev/null +++ b/LoopCore/NotificationManager.swift @@ -0,0 +1,16 @@ +// +// NotificationManager.swift +// Loop +// +// Created by Pete Schwamb on 9/16/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +public enum NotificationManager { + + public enum Action: String { + case retryBolus + case acknowledgeAlert + case startPreset + } +} diff --git a/LoopCore/PotentialCarbEntryUserInfo.swift b/LoopCore/PotentialCarbEntryUserInfo.swift deleted file mode 100644 index 701ee028f9..0000000000 --- a/LoopCore/PotentialCarbEntryUserInfo.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// PotentialCarbEntryUserInfo.swift -// Naterade -// -// Created by Nathan Racklyeft on 1/23/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import LoopKit - - -public struct PotentialCarbEntryUserInfo { - public let carbEntry: NewCarbEntry - - public init(carbEntry: NewCarbEntry) { - self.carbEntry = carbEntry - } -} - - -extension PotentialCarbEntryUserInfo: RawRepresentable { - public typealias RawValue = [String: Any] - - static let version = 1 - public static let name = "PotentialCarbEntryUserInfo" - - public init?(rawValue: RawValue) { - guard rawValue["v"] as? Int == type(of: self).version && rawValue["name"] as? String == PotentialCarbEntryUserInfo.name, - let value = rawValue["ce"] as? NewCarbEntry.RawValue, - let carbEntry = NewCarbEntry(rawValue: value) - else { - return nil - } - - self.carbEntry = carbEntry - } - - public var rawValue: RawValue { - return [ - "v": type(of: self).version, - "name": PotentialCarbEntryUserInfo.name, - "ce": carbEntry.rawValue, - ] - } -} diff --git a/Loop/Models/SelectablePreset.swift b/LoopCore/SelectablePreset.swift similarity index 64% rename from Loop/Models/SelectablePreset.swift rename to LoopCore/SelectablePreset.swift index 57c1be6a2c..1b1a0d27d3 100644 --- a/Loop/Models/SelectablePreset.swift +++ b/LoopCore/SelectablePreset.swift @@ -9,14 +9,13 @@ import LoopKit import SwiftUI import LoopAlgorithm -import LoopKitUI -enum PresetDuration: Equatable { +public enum PresetDuration: Equatable { case untilCarbsEntered case duration(TimeInterval) case indefinite - var presetDuration: TemporaryScheduleOverride.Duration { + public var presetDuration: TemporaryScheduleOverride.Duration { switch self { case .indefinite: return .indefinite case .duration(let duration): return .finite(duration) @@ -25,14 +24,14 @@ enum PresetDuration: Equatable { } } -enum PresetExpectedEndTime { +public enum PresetExpectedEndTime { case untilCarbsEntered case scheduled(Date) case indefinite } extension TemporaryScheduleOverride.Duration { - var presetDurationType: PresetDuration { + public var presetDurationType: PresetDuration { switch self { case .finite(let interval): return .duration(interval) @@ -43,7 +42,7 @@ extension TemporaryScheduleOverride.Duration { } extension TemporaryScheduleOverride { - var expectedEndTime: PresetExpectedEndTime? { + public var expectedEndTime: PresetExpectedEndTime? { switch context { case .preMeal: return .untilCarbsEntered case .activity, .custom, .preset: @@ -54,20 +53,24 @@ extension TemporaryScheduleOverride { } } - var presetId: String { + public var presetId: String { switch context { case .preMeal: return "preMeal" - case .activity: return preset.id + case .activity(let activity): return activity.presetId case .custom: return self.syncIdentifier.uuidString case .preset(let preset): return preset.id } } } -typealias RangeSafetyClassification = (lower: SafetyClassification, upper: SafetyClassification) +extension ActivityPreset { + var presetId: String { + "activity-\(id)" + } +} extension PresetDuration: Hashable { - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { switch self { case .indefinite: hasher.combine("indefinite") @@ -80,13 +83,13 @@ extension PresetDuration: Hashable { } } -enum SelectablePreset: Hashable, Identifiable { +public enum SelectablePreset: Hashable, Identifiable { case custom(TemporaryPreset) case preMeal(range: ClosedRange) case activity(ActivityPreset) - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { switch self { case .custom(let preset): hasher.combine(preset) @@ -98,7 +101,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - static func == (lhs: SelectablePreset, rhs: SelectablePreset) -> Bool { + public static func == (lhs: SelectablePreset, rhs: SelectablePreset) -> Bool { switch (lhs, rhs) { case (.custom(let lhsPreset), .custom(let rhsPreset)): return lhsPreset == rhsPreset @@ -111,15 +114,15 @@ enum SelectablePreset: Hashable, Identifiable { } } - var id: String { + public var id: String { switch self { case .custom(let preset): return preset.id - case .activity(let activity): return "activity-\(activity.id)" + case .activity(let activity): return activity.presetId case .preMeal: return "preMeal" } } - var icon: PresetSymbol? { + public var icon: PresetSymbol? { switch self { case .custom(let preset): return preset.symbol case .preMeal: return .image("Pre-Meal-symbol", tint: .preMeal) @@ -127,7 +130,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var duration: PresetDuration { + public var duration: PresetDuration { get { switch self { case .custom(let preset): @@ -177,11 +180,11 @@ enum SelectablePreset: Hashable, Identifiable { } } - var isScheduled: Bool { + public var isScheduled: Bool { return nextScheduledStartAfter(Date()) != nil } - func nextScheduledStartAfter(_ date: Date) -> Date? { + public func nextScheduledStartAfter(_ date: Date) -> Date? { switch self { case .custom(let preset): return preset.nextScheduledStartAfter(date) @@ -190,7 +193,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var scheduleStartDate: Date? { + public var scheduleStartDate: Date? { get { switch self { case .custom(let preset): @@ -210,7 +213,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var repeatOptions: PresetScheduleRepeatOptions { + public var repeatOptions: PresetScheduleRepeatOptions { get { switch self { case .custom(let preset): @@ -231,7 +234,7 @@ enum SelectablePreset: Hashable, Identifiable { } - var name: String { + public var name: String { get { switch self { case .custom(let preset): return preset.name @@ -247,7 +250,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var correctionRange: ClosedRange? { + public var correctionRange: ClosedRange? { get { switch self { case .custom(let preset): return preset.settings.targetRange @@ -270,7 +273,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var insulinSensitivityMultiplier: Double? { + public var insulinSensitivityMultiplier: Double? { if case .custom(let preset) = self { return preset.settings.insulinSensitivityMultiplier } else if case .activity(let activity) = self { @@ -280,7 +283,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var insulinNeedsScaleFactor: Double { + public var insulinNeedsScaleFactor: Double { get { if case .custom(let preset) = self { return 1.0 / (preset.settings.insulinSensitivityMultiplier ?? 1) @@ -301,7 +304,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var canAdjustSensitivity: Bool { + public var canAdjustSensitivity: Bool { switch self { case .custom, .activity: return true @@ -310,7 +313,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var allowsIndefiniteDuration: Bool { + public var allowsIndefiniteDuration: Bool { switch self { case .custom: return true @@ -319,7 +322,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var canAdjustDuration: Bool { + public var canAdjustDuration: Bool { switch self { case .custom, .activity: return true @@ -328,7 +331,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var canChangeName: Bool { + public var canChangeName: Bool { switch self { case .custom: return true @@ -337,7 +340,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var allowsScheduling: Bool { + public var allowsScheduling: Bool { switch self { case .custom: return true @@ -346,7 +349,7 @@ enum SelectablePreset: Hashable, Identifiable { } } - var canBeDeleted: Bool { + public var canBeDeleted: Bool { switch self { case .custom: return true @@ -355,14 +358,14 @@ enum SelectablePreset: Hashable, Identifiable { } } - var isPreMeal: Bool { + public var isPreMeal: Bool { if case .preMeal = self { return true } return false } - var dateCreated: Date { + public var dateCreated: Date { switch self { case .custom: return .distantPast // TODO @@ -372,16 +375,89 @@ enum SelectablePreset: Hashable, Identifiable { return .distantPast } } +} - func title(font: Font, iconSize: Double, colorPalette: LoopUIColorPalette) -> some View { - HStack(spacing: 6) { - if let icon, !icon.isEmpty { - PresetSymbolView(icon) - } +extension SelectablePreset { + public func createOverride(beginningAt: Date = Date()) -> TemporaryScheduleOverride { + switch self { + case .custom(let temporaryScheduleOverridePreset): + return temporaryScheduleOverridePreset.createOverride(enactTrigger: .local, beginningAt: beginningAt) + case .activity(let activity): + return activity.preset.createOverride(enactTrigger: .local, beginningAt: beginningAt) + case .preMeal(let targetRange): + return TemporaryScheduleOverride( + context: .preMeal, + settings: TemporaryPresetSettings(targetRange: targetRange), + startDate: beginningAt, + duration: .finite(.hours(1)), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + } +} + +extension PresetExpectedEndTime { + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + return formatter + }() + + public var localizedTitle: String { + switch self { + case .untilCarbsEntered: + return NSLocalizedString("on until carbs added", comment: "Preset card pre-meal expected end time") + case .indefinite: + return NSLocalizedString("on until turned off", comment: "Preset card indefinite scheduled end time") + case .scheduled(let date): + return NSLocalizedString("on until \(Self.timeFormatter.string(from: date))", comment: "Presets card time duration accessibility label") + } + } + + public var accessibilityLabel: String { + switch self { + case .untilCarbsEntered: + return NSLocalizedString("on until carbs added", comment: "Presets card pre-meal expected end time accessibility label") + case .indefinite: + return NSLocalizedString("on until turned off", comment: "Presets card indefinite duration accessibility label") + case .scheduled(let date): + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .spellOut + return NSLocalizedString("on until \(Self.timeFormatter.string(from: date))", comment: "Presets card time duration accessibility label") + } + } +} + +extension PresetDuration { + public var localizedTitle: String { + switch self { + case .untilCarbsEntered: + return NSLocalizedString("until carbs added", comment: "Preset card pre-meal duration") + case .indefinite: + return NSLocalizedString("until turned off", comment: "Preset card indefinite duration") + case .duration(let duration): + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .short + return formatter.string(from: duration) ?? "" - Text(name) - .font(font) - .fontWeight(.semibold) + } + } + + public var accessibilityLabel: String { + switch self { + case .untilCarbsEntered: + return NSLocalizedString("Active until carbs are added", comment: "Presets card pre-meal duration accessibility label") + case .indefinite: + return NSLocalizedString("Active until turned off", comment: "Presets card indefinite duration accessibility label") + case .duration(let duration): + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .spellOut + return NSLocalizedString("Active for \(formatter.string(from: duration) ?? "")", comment: "Presets card time duration accessibility label") } } } diff --git a/LoopTests/Managers/TemporaryPresetsManagerTests.swift b/LoopTests/Managers/TemporaryPresetsManagerTests.swift index 57a97be7a5..3b022f74b9 100644 --- a/LoopTests/Managers/TemporaryPresetsManagerTests.swift +++ b/LoopTests/Managers/TemporaryPresetsManagerTests.swift @@ -67,7 +67,7 @@ class TemporaryPresetsManagerTests: XCTestCase { } func testScheduleOverrideWithExpiredPreMealOverride() { - manager.preMealOverride = TemporaryScheduleOverride( + manager.scheduleOverride = TemporaryScheduleOverride( context: .preMeal, settings: TemporaryPresetSettings(targetRange: preMealRange), startDate: Date(timeIntervalSinceNow: -2 /* hours */ * 60 * 60), diff --git a/LoopTests/Models/CarbBackfillRequestUserInfoTests.swift b/LoopTests/Models/CarbBackfillRequestUserInfoTests.swift index e794194333..13bce59548 100644 --- a/LoopTests/Models/CarbBackfillRequestUserInfoTests.swift +++ b/LoopTests/Models/CarbBackfillRequestUserInfoTests.swift @@ -7,7 +7,7 @@ // import XCTest - +@testable import LoopCore @testable import Loop class CarbBackfillRequestUserInfoTests: XCTestCase { diff --git a/LoopTests/Models/SetBolusUserInfoTests.swift b/LoopTests/Models/SetBolusUserInfoTests.swift index 2dda80dc9d..05c9d51838 100644 --- a/LoopTests/Models/SetBolusUserInfoTests.swift +++ b/LoopTests/Models/SetBolusUserInfoTests.swift @@ -9,6 +9,7 @@ import XCTest import LoopAlgorithm import LoopKit +import LoopCore @testable import Loop class SetBolusUserInfoTests: XCTestCase { diff --git a/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index a79ec10924..3bacfd933e 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -18,12 +18,8 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { // MARK: - Timeline Configuration - func getSupportedTimeTravelDirections(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimeTravelDirections) -> Void) { - handler([.backward]) - } - func getTimelineStartDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - if let date = ExtensionDelegate.shared().loopManager.activeContext?.glucoseDate { + if let date = LoopDataManager.shared.activeContext?.glucoseDate { handler(date) } else { handler(nil) @@ -31,13 +27,28 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { } func getTimelineEndDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - if let date = ExtensionDelegate.shared().loopManager.activeContext?.glucoseDate { + if let date = LoopDataManager.shared.activeContext?.glucoseDate { handler(date) } else { handler(nil) } } - + + func complicationDescriptors() async -> [CLKComplicationDescriptor] { + return [ + CLKComplicationDescriptor( + identifier: "glucosegraph", + displayName: "Glucose Graph", + supportedFamilies: [.graphicRectangular, .graphicExtraLarge,] + ), + CLKComplicationDescriptor( + identifier: "glucosegraph", + displayName: "Loop Status", + supportedFamilies: [.circularSmall, .extraLarge, .graphicBezel, .graphicCircular, .graphicCorner, .modularLarge, .modularSmall, .utilitarianLarge, .utilitarianSmall, .utilitarianSmallFlat] + ) + ] + } + func getPrivacyBehavior(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void) { handler(.hideOnLockScreen) } @@ -56,7 +67,8 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { return } - ExtensionDelegate.shared().loopManager.generateChartData { chartData in + Task { @MainActor in + let chartData = await LoopDataManager.shared.generateChartData() self.chartManager.data = chartData completion() } @@ -85,7 +97,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { self.log.default("Updating current complication timeline entry") - if let context = ExtensionDelegate.shared().loopManager.activeContext, + if let context = LoopDataManager.shared.activeContext, let template = CLKComplicationTemplate.templateForFamily(complication.family, from: context, at: timelineDate, @@ -111,7 +123,7 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { updateChartManagerIfNeeded { let entries: [CLKComplicationTimelineEntry]? - guard let context = ExtensionDelegate.shared().loopManager.activeContext, + guard let context = LoopDataManager.shared.activeContext, let glucoseDate = context.glucoseDate else { handler(nil) diff --git a/WatchApp Extension/Controllers/ActionHUDController.swift b/WatchApp Extension/Controllers/ActionHUDController.swift deleted file mode 100644 index bb7c13a140..0000000000 --- a/WatchApp Extension/Controllers/ActionHUDController.swift +++ /dev/null @@ -1,264 +0,0 @@ -// -// ActionHUDController.swift -// Loop -// -// Created by Nathan Racklyeft on 5/29/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import WatchKit -import WatchConnectivity -import LoopAlgorithm -import LoopKit -import LoopCore -import SwiftUI - - -final class ActionHUDController: HUDInterfaceController { - @IBOutlet var preMealButton: WKInterfaceButton! - @IBOutlet var preMealButtonImage: WKInterfaceImage! - @IBOutlet var preMealButtonBackground: WKInterfaceGroup! - @IBOutlet var overrideButton: WKInterfaceButton! - @IBOutlet var overrideButtonImage: WKInterfaceImage! - @IBOutlet var overrideButtonBackground: WKInterfaceGroup! - @IBOutlet var carbsButton: WKInterfaceButton! - @IBOutlet var carbsButtonImage: WKInterfaceImage! - @IBOutlet var carbsButtonBackground: WKInterfaceGroup! - @IBOutlet var bolusButton: WKInterfaceButton! - @IBOutlet var bolusButtonImage: WKInterfaceImage! - @IBOutlet var bolusButtonBackground: WKInterfaceGroup! - - private lazy var preMealButtonGroup = ButtonGroup(button: preMealButton, image: preMealButtonImage, background: preMealButtonBackground, onBackgroundColor: .carbsColor, offBackgroundColor: .darkCarbsColor, onIconColor: .darkCarbsColor, offIconColor: .carbsColor) - - private lazy var overrideButtonGroup = ButtonGroup(button: overrideButton, image: overrideButtonImage, background: overrideButtonBackground, onBackgroundColor: .overrideColor, offBackgroundColor: .darkOverrideColor, onIconColor: .darkOverrideColor, offIconColor: .overrideColor) - - private lazy var carbsButtonGroup = ButtonGroup(button: carbsButton, image: carbsButtonImage, background: carbsButtonBackground, onBackgroundColor: .carbsColor, offBackgroundColor: .darkCarbsColor, onIconColor: .darkCarbsColor, offIconColor: .carbsColor) - - private lazy var bolusButtonGroup = ButtonGroup(button: bolusButton, image: bolusButtonImage, background: bolusButtonBackground, onBackgroundColor: .insulin, offBackgroundColor: .darkInsulin, onIconColor: .darkInsulin, offIconColor: .insulin) - - @IBOutlet var overrideButtonLabel: WKInterfaceLabel? - - override func willActivate() { - super.willActivate() - - // Update the override button description based on the feature flag; this cannot be done earlier than `-willActivate` (e.g. didSet on the IBOutlet is too soon) - overrideButtonLabel?.setText(NSLocalizedString("Preset", comment: "The text for the Watch button for enabling a custom preset")) - - let userActivity = NSUserActivity.forViewLoopStatus() - if #available(watchOSApplicationExtension 5.0, *) { - update(userActivity) - } else { - updateUserActivity(userActivity.activityType, userInfo: userActivity.userInfo, webpageURL: nil) - } - } - - override func update() { - super.update() - - let activeOverrideContext: TemporaryScheduleOverride.Context? - if let override = loopManager.watchInfo.scheduleOverride, override.isActive() { - activeOverrideContext = override.context - } else { - activeOverrideContext = nil - } - - updateForPreMeal(enabled: loopManager.watchInfo.preMealOverride?.isActive() == true) - updateForOverrideContext(activeOverrideContext) - - let isClosedLoop = loopManager.activeContext?.isClosedLoop ?? false - - if !isClosedLoop && FeatureFlags.simpleBolusCalculatorEnabled { - preMealButtonGroup.state = .disabled - overrideButtonGroup.state = .disabled - carbsButtonGroup.state = .disabled - bolusButtonGroup.state = .disabled - } else { - carbsButtonGroup.state = .off - bolusButtonGroup.state = .off - - if loopManager.watchInfo.loopSettings.preMealTargetRange == nil { - preMealButtonGroup.state = .disabled - } else if preMealButtonGroup.state == .disabled { - preMealButtonGroup.state = .off - } - - if !canEnableOverride { - overrideButtonGroup.state = .disabled - } else if overrideButtonGroup.state == .disabled { - overrideButtonGroup.state = .off - } - } - - glucoseFormatter.updateUnit(to: loopManager.displayGlucoseUnit) - } - - private var canEnableOverride: Bool { - !loopManager.watchInfo.loopSettings.overridePresets.isEmpty - } - - private func updateForPreMeal(enabled: Bool) { - if enabled { - preMealButtonGroup.state = .on - } else { - preMealButtonGroup.turnOff() - } - } - - private func updateForOverrideContext(_ context: TemporaryScheduleOverride.Context?) { - switch context { - case nil: - overrideButtonGroup.turnOff() - case .preset?, .custom?: - overrideButtonGroup.state = .on - case .activity: - preMealButtonGroup.turnOff() - overrideButtonGroup.state = .on - case .preMeal?: - assertionFailure() - } - } - - // MARK: - Menu Items - - private var pendingMessageResponses = 0 - - private let glucoseFormatter = QuantityFormatter(for: .milligramsPerDeciliter) - - @IBAction func togglePreMealMode() { - guard let range = loopManager.watchInfo.loopSettings.preMealTargetRange else { - return - } - - 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), - onSelection: setPreMealEnabled, - selectedButton: buttonToSelect, - selectedButtonTint: .carbsColor) - - presentController(withName: OnOffSelectionController.className, context: viewModel) - } - - func setPreMealEnabled(_ isPreMealEnabled: Bool) { - updateForPreMeal(enabled: isPreMealEnabled) - pendingMessageResponses += 1 - - var watchInfo = loopManager.watchInfo - let overrideContext = watchInfo.scheduleOverride?.context - if isPreMealEnabled { - watchInfo.enablePreMealOverride(for: .hours(1)) - } else { - watchInfo.clearOverride(matching: .preMeal) - } - - do { - 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.watchInfo.preMealOverride = watchInfo.preMealOverride - self.loopManager.watchInfo.scheduleOverride = watchInfo.scheduleOverride - } - - ExtensionDelegate.shared().loopManager.updateContext(context) - case .failure(let error): - if self.pendingMessageResponses == 0 { - ExtensionDelegate.shared().present(error) - self.updateForPreMeal(enabled: isPreMealEnabled) - self.updateForOverrideContext(overrideContext) - } - } - } - }) - } catch { - pendingMessageResponses -= 1 - if pendingMessageResponses == 0 { - updateForPreMeal(enabled: isPreMealEnabled) - updateForOverrideContext(overrideContext) - presentAlert( - withTitle: NSLocalizedString("Send Failed", comment: "The title of the alert controller displayed after a glucose range override send attempt fails"), - message: NSLocalizedString("Make sure your iPhone is nearby and try again", comment: "The recovery message displayed after a glucose range override send attempt fails"), - preferredStyle: .alert, - actions: [.dismissAction()] - ) - } - } - } - - @IBAction func toggleOverride() { - overrideButtonGroup.state == .on - ? sendOverride(nil) - : presentController(withName: OverrideSelectionController.className, context: self as OverrideSelectionControllerDelegate) - } - - private func formattedGlucoseRangeString(from range: ClosedRange) -> String { - let unit = loopManager.displayGlucoseUnit - glucoseFormatter.updateUnit(to: unit) - let rangeDouble = range.doubleRange(for: unit) - return String( - format: NSLocalizedString( - "%1$@ – %2$@ %3$@", - comment: "Format string for glucose range (1: lower bound)(2: upper bound)(3: unit)" - ), - glucoseFormatter.numberFormatter.string(from: rangeDouble.minValue) ?? String(rangeDouble.minValue), - glucoseFormatter.numberFormatter.string(from: rangeDouble.maxValue) ?? String(rangeDouble.maxValue), - glucoseFormatter.localizedUnitStringWithPlurality() - ) - } - - private func sendOverride(_ override: TemporaryScheduleOverride?) { - updateForOverrideContext(override?.context) - pendingMessageResponses += 1 - - var watchInfo = loopManager.watchInfo - let isPreMealEnabled = watchInfo.preMealOverride?.isActive() == true - watchInfo.scheduleOverride = override - - do { - 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.watchInfo.scheduleOverride = override - self.loopManager.watchInfo.preMealOverride = watchInfo.preMealOverride - } - - ExtensionDelegate.shared().loopManager.updateContext(context) - case .failure(let error): - if self.pendingMessageResponses == 0 { - ExtensionDelegate.shared().present(error) - self.updateForOverrideContext(override?.context) - self.updateForPreMeal(enabled: isPreMealEnabled) - } - } - } - }) - } catch { - pendingMessageResponses -= 1 - if pendingMessageResponses == 0 { - updateForOverrideContext(override?.context) - updateForPreMeal(enabled: isPreMealEnabled) - presentAlert( - withTitle: NSLocalizedString("Send Failed", comment: "The title of the alert controller displayed after a glucose range override send attempt fails"), - message: NSLocalizedString("Make sure your iPhone is nearby and try again", comment: "The recovery message displayed after a glucose range override send attempt fails"), - preferredStyle: .alert, - actions: [.dismissAction()] - ) - } - } - } -} - -extension ActionHUDController: OverrideSelectionControllerDelegate { - func overrideSelectionController(_ controller: OverrideSelectionController, didSelectPreset preset: TemporaryPreset) { - let override = preset.createOverride(enactTrigger: .local) - sendOverride(override) - } -} diff --git a/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift b/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift deleted file mode 100644 index aeea4066a7..0000000000 --- a/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// CarbAndBolusFlowController.swift -// WatchApp Extension -// -// Created by Michael Pangburn on 4/7/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import WatchKit -import SwiftUI -import LoopCore -import LoopKit - - -final class CarbAndBolusFlowController: WKHostingController, IdentifiableClass { - private lazy var viewModel = { - CarbAndBolusFlowViewModel( - configuration: configuration, - dismiss: { [weak self] in - guard let self = self else { return } - self.willDeactivateObserver = nil - self.dismiss() - } - ) - }() - - private var configuration: CarbAndBolusFlow.Configuration = .carbEntry(nil) - - override var body: CarbAndBolusFlow { - CarbAndBolusFlow(viewModel: viewModel) - } - - private var willDeactivateObserver: AnyObject? { - didSet { - if let oldValue = oldValue { - NotificationCenter.default.removeObserver(oldValue) - } - } - } - - override func awake(withContext context: Any?) { - if let configuration = context as? CarbAndBolusFlow.Configuration { - self.configuration = configuration - } - } - - override func didAppear() { - super.didAppear() - - updateNewCarbEntryUserActivity() - - // If the screen turns off, the screen should be dismissed for safety reasons - willDeactivateObserver = NotificationCenter.default.addObserver(forName: ExtensionDelegate.willResignActiveNotification, object: ExtensionDelegate.shared(), queue: nil, using: { [weak self] (_) in - if let self = self { - WKInterfaceDevice.current().play(.failure) - self.dismiss() - } - }) - } - - override func didDeactivate() { - super.didDeactivate() - - willDeactivateObserver = nil - } -} - -extension CarbAndBolusFlowController: NSUserActivityDelegate { - func updateNewCarbEntryUserActivity() { - update(.forDidAddCarbEntryOnWatch()) - } -} diff --git a/WatchApp Extension/Controllers/CarbEntryListController.swift b/WatchApp Extension/Controllers/CarbEntryListController.swift deleted file mode 100644 index ab02242643..0000000000 --- a/WatchApp Extension/Controllers/CarbEntryListController.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// CarbEntryListController.swift -// WatchApp Extension -// -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import LoopCore -import LoopKit -import os.log -import WatchKit -import LoopAlgorithm - -class CarbEntryListController: WKInterfaceController, IdentifiableClass { - @IBOutlet private var table: WKInterfaceTable! - - @IBOutlet private var cobLabel: WKInterfaceLabel! - - @IBOutlet var totalLabel: WKInterfaceLabel! - - @IBOutlet var headerGroup: WKInterfaceGroup! - - private let log = OSLog(category: "CarbEntryListController") - - private lazy var loopManager = ExtensionDelegate.shared().loopManager - - private lazy var carbFormatter: QuantityFormatter = { - let formatter = QuantityFormatter(for: .gram) - formatter.numberFormatter.numberStyle = .none - return formatter - }() - - private var observers: [Any] = [] { - didSet { - for observer in oldValue { - NotificationCenter.default.removeObserver(observer) - } - } - } - - override func awake(withContext context: Any?) { - table.setNumberOfRows(0, withRowType: TextRowController.className) - loopManager.requestCarbBackfill() - reloadCarbEntries() - updateActiveCarbs() - } - - override func willActivate() { - observers = [ - NotificationCenter.default.addObserver(forName: CarbStore.carbEntriesDidChange, object: loopManager.carbStore, queue: nil) { [weak self] (note) in - self?.log.default("Received carbEntriesDidChange notification: %{public}@. Updating list", String(describing: note.userInfo ?? [:])) - - DispatchQueue.main.async { - self?.reloadCarbEntries() - } - }, - NotificationCenter.default.addObserver(forName: LoopDataManager.didUpdateContextNotification, object: loopManager, queue: nil) { [weak self] (note) in - DispatchQueue.main.async { - self?.updateActiveCarbs() - self?.loopManager.requestCarbBackfill() - } - } - ] - } - - override func didDeactivate() { - observers = [] - } -} - - -extension CarbEntryListController { - private func updateActiveCarbs() { - guard let activeCarbohydrates = loopManager.activeContext?.activeCarbohydrates else { - return - } - - cobLabel.setText(carbFormatter.string(from: activeCarbohydrates)) - } - - private func reloadCarbEntries() { - let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -CarbMath.maximumAbsorptionTimeInterval)) - - loopManager.carbStore.getCarbEntries(start: start) { (result) in - switch result { - case .success(let entries): - DispatchQueue.main.async { - self.setCarbEntries(entries) - } - case .failure(let error): - self.log.error("Failed to fetch carb entries: %{public}@", String(describing: error)) - } - } - } - - private func setCarbEntries(_ entries: [StoredCarbEntry]) { - dispatchPrecondition(condition: .onQueue(.main)) - - table.setNumberOfRows(entries.count, withRowType: TextRowController.className) - - var total = 0.0 - - let timeFormatter = DateFormatter() - timeFormatter.dateStyle = .none - timeFormatter.timeStyle = .short - - let unit = loopManager.carbStore.preferredUnit ?? .gram - - for (index, entry) in entries.reversed().enumerated() { - guard let row = table.rowController(at: index) as? TextRowController else { - continue - } - - total += entry.quantity.doubleValue(for: unit) - - row.textLabel.setText(timeFormatter.string(from: entry.startDate)) - row.detailTextLabel.setText(carbFormatter.string(from: entry.quantity)) - } - - totalLabel.setText(carbFormatter.string(from: LoopQuantity(unit: unit, doubleValue: total))) - } -} diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift deleted file mode 100644 index b9e9292b95..0000000000 --- a/WatchApp Extension/Controllers/ChartHUDController.swift +++ /dev/null @@ -1,212 +0,0 @@ -// -// ChartHUDController.swift -// Loop -// -// Created by Bharat Mediratta on 6/26/18. -// Copyright © 2018 LoopKit Authors. All rights reserved. -// - -import WatchKit -import WatchConnectivity -import LoopKit -import SpriteKit -import os.log -import LoopCore -import LoopAlgorithm - -final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { - private enum TableRow: Int, CaseIterable { - case iob - case cob - case netBasal - case reservoirVolume - - var title: String { - switch self { - case .iob: - return NSLocalizedString("Active Insulin", comment: "HUD row title for IOB") - case .cob: - return NSLocalizedString("Active Carbs", comment: "HUD row title for COB") - case .netBasal: - return NSLocalizedString("Net Basal Rate", comment: "HUD row title for Net Basal Rate") - case .reservoirVolume: - return NSLocalizedString("Reservoir Volume", comment: "HUD row title for remaining reservoir volume") - } - } - - var isLast: Bool { - return self == TableRow.allCases.last - } - } - - @IBOutlet private weak var table: WKInterfaceTable! - - @IBOutlet private weak var glucoseScene: WKInterfaceSKScene! - private let scene = GlucoseChartScene() - private var timer: Timer? { - didSet { - oldValue?.invalidate() - } - } - private let log = OSLog(category: "ChartHUDController") - private var hasInitialActivation = false - - private var observers: [Any] = [] { - didSet { - for observer in oldValue { - NotificationCenter.default.removeObserver(observer) - } - } - } - - override init() { - super.init() - - glucoseScene.presentScene(scene) - } - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - - table.setNumberOfRows(TableRow.allCases.count, withRowType: HUDRowController.className) - } - - override func didAppear() { - super.didAppear() - - if glucoseScene.isPaused { - log.default("didAppear() unpausing") - glucoseScene.isPaused = false - } else { - log.default("didAppear() not paused") - glucoseScene.isPaused = false - } - - // Force an update when our pixels need to move - let pixelsWide = scene.size.width * WKInterfaceDevice.current().screenScale - let pixelInterval = scene.visibleDuration / TimeInterval(pixelsWide) - - timer = Timer.scheduledTimer(withTimeInterval: pixelInterval, repeats: true) { [weak self] _ in - self?.log.default("Timer fired, triggering update") - self?.scene.setNeedsUpdate() - } - - // These margins are only available after we appear (sadly) - - scene.textInsets.left = max(scene.textInsets.left, systemMinimumLayoutMargins.leading) - scene.textInsets.right = max(scene.textInsets.right, systemMinimumLayoutMargins.trailing) - - for row in TableRow.allCases { - let cell = table.rowController(at: row.rawValue) as! HUDRowController - cell.setContentInset(systemMinimumLayoutMargins) - } - } - - override func willDisappear() { - super.willDisappear() - - log.default("willDisappear") - - timer = nil - } - - override func willActivate() { - super.willActivate() - - observers = [ - NotificationCenter.default.addObserver(forName: GlucoseStore.glucoseSamplesDidChange, object: loopManager.glucoseStore, queue: nil) { [weak self] (note) in - self?.log.default("Received GlucoseSamplesDidChange notification: %{public}@. Updating chart", String(describing: note.userInfo ?? [:])) - - DispatchQueue.main.async { - self?.updateGlucoseChart() - } - } - ] - - if glucoseScene.isPaused { - log.default("willActivate() unpausing") - glucoseScene.isPaused = false - } else { - log.default("willActivate()") - } - - if !hasInitialActivation && UserDefaults.standard.startOnChartPage { - log.default("Switching to startOnChartPage") - becomeCurrentPage() - } - - hasInitialActivation = true - - loopManager.requestGlucoseBackfillIfNecessary() - } - - override func didDeactivate() { - super.didDeactivate() - - observers = [] - - log.default("didDeactivate() pausing") - glucoseScene.isPaused = true - } - - override func update() { - super.update() - - guard let activeContext = loopManager.activeContext else { - return - } - - for row in TableRow.allCases { - let cell = table.rowController(at: row.rawValue) as! HUDRowController - cell.setTitle(row.title) - cell.setIsLastRow(row.isLast) - cell.setContentInset(systemMinimumLayoutMargins) - - let isActiveContextStale = Date().timeIntervalSince(activeContext.creationDate) > LoopAlgorithm.inputDataRecencyInterval - - switch row { - case .iob: - cell.setActiveInsulin(isActiveContextStale ? nil : activeContext.activeInsulin) - case .cob: - cell.setActiveCarbohydrates(isActiveContextStale ? nil : activeContext.activeCarbohydrates) - case .netBasal: - cell.setNetTempBasalDose(isActiveContextStale ? nil : activeContext.lastNetTempBasalDose) - case .reservoirVolume: - cell.setReservoirVolume(isActiveContextStale ? nil : activeContext.reservoirVolume) - } - } - - if glucoseScene.isPaused { - log.default("update() unpausing") - glucoseScene.isPaused = false - } - - updateGlucoseChart() - } - - private func updateGlucoseChart() { - loopManager.generateChartData { chartData in - DispatchQueue.main.async { - self.scene.data = chartData - self.scene.setNeedsUpdate() - } - } - } - - override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) { - guard table == self.table, case .cob? = TableRow(rawValue: rowIndex) else { - return - } - - presentController(withName: CarbEntryListController.className, context: nil) - } - - @IBAction func didTapOnChart(_ sender: Any) { - scene.decreaseVisibleDuration() - } - - @IBAction func didDoubleTapOnChart(_ sender: Any) { - scene.increaseVisibleDuration() - } - -} diff --git a/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift deleted file mode 100644 index eca7b0424a..0000000000 --- a/WatchApp Extension/Controllers/HUDInterfaceController.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// HUDInterfaceController.swift -// WatchApp Extension -// -// Created by Bharat Mediratta on 6/29/18. -// Copyright © 2018 LoopKit Authors. All rights reserved. -// - -import WatchKit -import LoopCore -import LoopKit -import LoopAlgorithm - -class HUDInterfaceController: WKInterfaceController { - private var activeContextObserver: NSObjectProtocol? - - @IBOutlet weak var loopHUDImage: WKInterfaceImage! - @IBOutlet weak var glucoseLabel: WKInterfaceLabel! - @IBOutlet weak var eventualGlucoseLabel: WKInterfaceLabel! - - var loopManager = ExtensionDelegate.shared().loopManager - - override func willActivate() { - super.willActivate() - - update() - - if activeContextObserver == nil { - activeContextObserver = NotificationCenter.default.addObserver(forName: LoopDataManager.didUpdateContextNotification, object: loopManager, queue: nil) { [weak self] _ in - DispatchQueue.main.async { - self?.update() - } - } - } - - loopManager.requestContextUpdate(completion: { - self.loopManager.requestGlucoseBackfillIfNecessary() - }) - } - - override func didDeactivate() { - super.didDeactivate() - - if let observer = activeContextObserver { - NotificationCenter.default.removeObserver(observer) - } - activeContextObserver = nil - } - - func update() { - guard let activeContext = loopManager.activeContext else { - loopHUDImage.setHidden(true) - return - } - loopHUDImage.setHidden(false) - - let date = activeContext.loopLastRunDate - let isClosedLoop = activeContext.isClosedLoop ?? false - loopHUDImage.setLoopImage(isClosedLoop: isClosedLoop, { - if let date = date { - switch date.timeIntervalSinceNow { - case let t where t > .minutes(-6): - return .fresh - case let t where t > .minutes(-20): - return .aging - default: - return .stale - } - } else { - return .unknown - } - }()) - - if date != nil { - glucoseLabel.setText(NSLocalizedString("– – –", comment: "No glucose value representation (3 dashes for mg/dL)")) - glucoseLabel.setHidden(false) - - let showEventualGlucose = FeatureFlags.showEventualBloodGlucoseOnWatchEnabled - if showEventualGlucose { - eventualGlucoseLabel.setHidden(true) - } - - if let glucose = activeContext.glucose, let glucoseDate = activeContext.glucoseDate, let unit = activeContext.displayGlucoseUnit, glucoseDate.timeIntervalSinceNow > -LoopAlgorithm.inputDataRecencyInterval { - let formatter = NumberFormatter.glucoseFormatter(for: unit) - - var glucoseValue: String? - - if let glucoseCondition = activeContext.glucoseCondition { - glucoseValue = glucoseCondition.localizedDescription - } else { - glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) - } - - 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) - } - } - } - - } - - @IBAction func addCarbs() { - presentController(withName: CarbAndBolusFlowController.className, context: CarbAndBolusFlow.Configuration.carbEntry(nil)) - } - - func addCarbs(initialEntry: NewCarbEntry) { - presentController(withName: CarbAndBolusFlowController.className, context: CarbAndBolusFlow.Configuration.carbEntry(initialEntry)) - } - - @IBAction func setBolus() { - presentController(withName: CarbAndBolusFlowController.className, context: CarbAndBolusFlow.Configuration.manualBolus) - } - -} diff --git a/WatchApp Extension/Controllers/HUDRowController.swift b/WatchApp Extension/Controllers/HUDRowController.swift deleted file mode 100644 index c45dd12fcb..0000000000 --- a/WatchApp Extension/Controllers/HUDRowController.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// HUDRowController.swift -// WatchApp Extension -// -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import LoopAlgorithm -import LoopCore -import LoopKit -import WatchKit - -class HUDRowController: NSObject, IdentifiableClass { - @IBOutlet private var textLabel: WKInterfaceLabel! - @IBOutlet private var detailTextLabel: WKInterfaceLabel! - @IBOutlet private var outerGroup: WKInterfaceGroup! - @IBOutlet private var bottomSeparator: WKInterfaceSeparator! -} - -extension HUDRowController { - func setTitle(_ title: String) { - textLabel.setText(title.localizedUppercase) - } - - func setDetail(_ detail: String?) { - detailTextLabel.setText(detail ?? "–") - } - - func setContentInset(_ inset: NSDirectionalEdgeInsets) { - outerGroup.setContentInset(inset.deviceInsets) - } - - func setIsLastRow(_ isLastRow: Bool) { - bottomSeparator.setHidden(isLastRow) - } -} - -extension HUDRowController { - func setActiveInsulin(_ activeInsulin: LoopQuantity?) { - guard let activeInsulin = activeInsulin else { - setDetail(nil) - return - } - - let insulinFormatter: QuantityFormatter = { - let insulinFormatter = QuantityFormatter(for: .internationalUnit) - insulinFormatter.numberFormatter.minimumFractionDigits = 1 - insulinFormatter.numberFormatter.maximumFractionDigits = 1 - - return insulinFormatter - }() - - setDetail(insulinFormatter.string(from: activeInsulin)) - } - - func setActiveCarbohydrates(_ activeCarbohydrates: LoopQuantity?) { - guard let activeCarbohydrates = activeCarbohydrates else { - setDetail(nil) - return - } - - let carbFormatter = QuantityFormatter(for: .gram) - carbFormatter.numberFormatter.maximumFractionDigits = 0 - - setDetail(carbFormatter.string(from: activeCarbohydrates)) - } - - func setNetTempBasalDose(_ tempBasal: Double?) { - guard let tempBasal = tempBasal else { - setDetail(nil) - return - } - - let basalFormatter = NumberFormatter() - basalFormatter.numberStyle = .decimal - basalFormatter.minimumFractionDigits = 1 - basalFormatter.maximumFractionDigits = 3 - basalFormatter.positivePrefix = basalFormatter.plusSign - - let unit = NSLocalizedString( - "U/hr", - comment: "The short unit display string for international units of insulin delivery per hour" - ) - - setDetail(basalFormatter.string(from: tempBasal, unit: unit)) - } - - func setReservoirVolume(_ reservoirVolume: LoopQuantity?) { - guard let reservoirVolume = reservoirVolume else { - setDetail(nil) - return - } - - let insulinFormatter: QuantityFormatter = { - let insulinFormatter = QuantityFormatter(for: .internationalUnit) - insulinFormatter.unitStyle = .long - insulinFormatter.numberFormatter.minimumFractionDigits = 0 - insulinFormatter.numberFormatter.maximumFractionDigits = 0 - - return insulinFormatter - }() - - setDetail(insulinFormatter.string(from: reservoirVolume)) - } -} - - -fileprivate extension NSDirectionalEdgeInsets { - var deviceInsets: UIEdgeInsets { - let left: CGFloat - let right: CGFloat - - switch WKInterfaceDevice.current().layoutDirection { - case .rightToLeft: - right = leading - left = trailing - case .leftToRight: - fallthrough - @unknown default: - left = leading - right = trailing - } - - return UIEdgeInsets(top: top, left: left, bottom: bottom, right: right) - } -} diff --git a/WatchApp Extension/Controllers/NotificationController.swift b/WatchApp Extension/Controllers/NotificationController.swift deleted file mode 100644 index 9b2aaa71da..0000000000 --- a/WatchApp Extension/Controllers/NotificationController.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// NotificationController.swift -// WatchApp Extension -// -// Created by Nathan Racklyeft on 8/29/15. -// Copyright © 2015 Nathan Racklyeft. All rights reserved. -// - -import WatchKit -import Foundation -import UserNotifications - - -final class NotificationController: WKUserNotificationInterfaceController { - - override init() { - super.init() - } - - override func willActivate() { - super.willActivate() - } - - override func didDeactivate() { - super.didDeactivate() - } - - override func didReceive(_ notification: UNNotification, withCompletion completionHandler: @escaping (WKUserNotificationInterfaceType) -> Void) { - completionHandler(.default) - } - -} diff --git a/WatchApp Extension/Controllers/OnOffSelectionController.swift b/WatchApp Extension/Controllers/OnOffSelectionController.swift deleted file mode 100644 index 40d5881079..0000000000 --- a/WatchApp Extension/Controllers/OnOffSelectionController.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// OnOffSelectionController.swift -// WatchApp Extension -// -// Created by Anna Quinlan on 8/20/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopCore - - -final class OnOffSelectionController: WKHostingController, IdentifiableClass { - - private var viewModel: OnOffSelectionViewModel = OnOffSelectionViewModel(title: "", message: "", onSelection: {_ in }) - - override func awake(withContext context: Any?) { - guard let model = context as? OnOffSelectionViewModel else { - fatalError("OnOffSelectionController invoked without proper context") - } - - model.dismiss = { self.dismiss() } - self.viewModel = model - } - - override var body: OnOffSelectionView { - OnOffSelectionView(viewModel: viewModel) - } -} diff --git a/WatchApp Extension/Controllers/OverrideSelectionController.swift b/WatchApp Extension/Controllers/OverrideSelectionController.swift deleted file mode 100644 index 3175ddce5f..0000000000 --- a/WatchApp Extension/Controllers/OverrideSelectionController.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// OverrideSelectionController.swift -// WatchApp Extension -// -// Created by Michael Pangburn on 1/31/19. -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import Foundation -import WatchKit -import LoopKit -import LoopCore -import WatchConnectivity - - -protocol OverrideSelectionControllerDelegate: AnyObject { - func overrideSelectionController(_ controller: OverrideSelectionController, didSelectPreset preset: TemporaryPreset) -} - - -final class OverrideSelectionController: WKInterfaceController, IdentifiableClass { - - @IBOutlet private var table: WKInterfaceTable! - - private let loopManager = ExtensionDelegate.shared().loopManager - private lazy var presets = loopManager.watchInfo.loopSettings.overridePresets - - weak var delegate: OverrideSelectionControllerDelegate? - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - delegate = context as? OverrideSelectionControllerDelegate - - guard !presets.isEmpty else { - assertionFailure("Instantiating override selection controller without configured presets") - return - } - - configureTable() - } - - private func configureTable() { - table.setRowTypes([OverridePresetRow.className]) - table.setNumberOfRows(presets.count, withRowType: OverridePresetRow.className) - for index in presets.indices { - let row = table.rowController(at: index) as! OverridePresetRow - let preset = presets[index] - if let symbol = preset.symbol?.textualRepresentation { - row.symbolLabel.setText(symbol) - row.symbolLabel.setHidden(false) - } else { - row.symbolLabel.setHidden(true) - } - row.nameLabel.setText(preset.name) - } - } - - override func willActivate() { - super.willActivate() - } - - override func didDeactivate() { - super.didDeactivate() - } - - override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) { - let preset = presets[rowIndex] - delegate?.overrideSelectionController(self, didSelectPreset: preset) - dismiss() - } -} diff --git a/WatchApp Extension/Controllers/PresetConfirmHostingController.swift b/WatchApp Extension/Controllers/PresetConfirmHostingController.swift new file mode 100644 index 0000000000..a2a4428f37 --- /dev/null +++ b/WatchApp Extension/Controllers/PresetConfirmHostingController.swift @@ -0,0 +1,17 @@ +// +// PresetConfirmHostingController.swift +// Loop +// +// Created by Pete Schwamb on 9/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import WatchKit +import SwiftUI +import LoopCore + +class PresetConfirmHostingController: WKHostingController { + override var body: PresetDetailView { + return PresetDetailView(preset: ExtensionDelegate.shared().loopManager.pendingPreset) + } +} diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 0479890319..7d2d9b00f3 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -15,10 +15,10 @@ import os import os.log import UserNotifications import LoopKit +import LoopCore +import ClockKit - -final class ExtensionDelegate: NSObject, WKExtensionDelegate { - private(set) lazy var loopManager = LoopDataManager() +class ExtensionDelegate: NSObject, WKApplicationDelegate { private let log = OSLog(category: "ExtensionDelegate") @@ -26,9 +26,11 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { private var notifications: [NSObjectProtocol] = [] static func shared() -> ExtensionDelegate { - return WKExtension.shared().extensionDelegate + return WKApplication.shared().extensionDelegate } + let loopManager = LoopDataManager.shared + override init() { super.init() @@ -41,6 +43,9 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { observers.append(session.observe(\WCSession.activationState) { [weak self] (session, change) in self?.log.default("WCSession.applicationState did change to %d", session.activationState.rawValue) + self?.log.default("WCSession.applicationState did change rootInterfaceController = %{public}@", String(describing: WKApplication.shared().rootInterfaceController)) + self?.log.default("WCSession.applicationState did change visibleInterfaceController = %{public}@", String(describing: WKApplication.shared().visibleInterfaceController)) + DispatchQueue.main.async { self?.completePendingConnectivityTasksIfNeeded() } @@ -80,14 +85,9 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { if WCSession.default.activationState != .activated { WCSession.default.activate() } - - NotificationCenter.default.post(name: type(of: self).didBecomeActiveNotification, object: self) } func applicationWillResignActive() { - UserDefaults.standard.startOnChartPage = (WKExtension.shared().visibleInterfaceController as? ChartHUDController) != nil - - NotificationCenter.default.post(name: type(of: self).willResignActiveNotification, object: self) } // Presumably the main thread? @@ -145,15 +145,11 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { } func handle(_ userActivity: NSUserActivity) { - if #available(watchOSApplicationExtension 5.0, *) { - switch userActivity.activityType { - case NSUserActivity.newCarbEntryActivityType, NSUserActivity.didAddCarbEntryOnWatchActivityType: - if let statusController = WKExtension.shared().visibleInterfaceController as? HUDInterfaceController { - statusController.addCarbs() - } - default: - break - } + switch userActivity.activityType { + case NSUserActivity.newCarbEntryActivityType, NSUserActivity.didAddCarbEntryOnWatchActivityType: + loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .carbEntry(nil)) + default: + break } } @@ -189,8 +185,8 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { private func loopManagerDidUpdateContext() { dispatchPrecondition(condition: .onQueue(.main)) - if WKExtension.shared().applicationState != .active { - WKExtension.shared().scheduleSnapshotRefresh(withPreferredDate: Date(), userInfo: nil) { (error) in + if WKApplication.shared().applicationState != .active { + WKApplication.shared().scheduleSnapshotRefresh(withPreferredDate: Date(), userInfo: nil) { (error) in if let error = error { self.log.error("scheduleSnapshotRefresh error: %{public}@", String(describing: error)) } @@ -209,8 +205,16 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { extension ExtensionDelegate: WCSessionDelegate { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + log.default("activationDidCompleteWith %{public}@", String(describing: activationState)) + + log.default("activationDidCompleteWith rootInterfaceController = %{public}@", String(describing: WKApplication.shared().rootInterfaceController)) + log.default("activationDidCompleteWith visibleInterfaceController = %{public}@", String(describing: WKApplication.shared().visibleInterfaceController)) + if activationState == .activated { updateContext(session.receivedApplicationContext) + Task { + await loopManager.requestSettingsUpdate() + } } } @@ -252,15 +256,15 @@ extension ExtensionDelegate: WCSessionDelegate { } } - extension ExtensionDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + + log.default("UNNotificationResponse rootInterfaceController = %{public}@", String(describing: WKApplication.shared().rootInterfaceController)) + log.default("UNNotificationResponse visibleInterfaceController = %{public}@", String(describing: WKApplication.shared().visibleInterfaceController)) + switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier: - guard - response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue, - let statusController = WKExtension.shared().visibleInterfaceController as? HUDInterfaceController - else { + guard response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue else { break } @@ -275,11 +279,41 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { startDate: mealTime, foodType: nil, absorptionTime: nil) - statusController.addCarbs(initialEntry: missedEntry) + loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .carbEntry(missedEntry)) // Otherwise, just provide the ability to add carbs } else { - statusController.addCarbs() + loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .carbEntry(nil)) } + case NotificationManager.Action.startPreset.rawValue: + // Response contains the preset id and alert id + let userInfo = response.notification.request.content.userInfo + guard let presetIdentifier = userInfo[LoopNotificationUserInfoKey.presetId.rawValue] as? String, + let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? LoopKit.Alert.AlertIdentifier, + let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String + else { + log.default("Unable to find keys in userInfo: %{public}@", String(describing: userInfo)) + return + } + log.default("Setting up PendingPresetReminder(presetIdentifier: %{public}@, alertIdentifier: %{public}@), managerIdentifier: %{public}@", presetIdentifier, alertIdentifier, managerIdentifier) + + loopManager.pendingPresetReminder = PendingPresetReminder( + presetIdentifier: presetIdentifier, + alertIdentifier: alertIdentifier, + managerIdentifier: managerIdentifier + ) + + guard let visibleVC = WKApplication.shared().visibleInterfaceController else { + log.error("no visible interface controller for presenting preset reminder!") + return + } + + guard let preset = loopManager.selectablePresets.first(where: { $0.id == presetIdentifier }) else { + log.error("Unable to find preset %{public}@", presetIdentifier) + return + } + + visibleVC.presentController(withName: "PresetConfirmHostingController", context: preset) + default: let userInfo = response.notification.request.content.userInfo if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? LoopKit.Alert.AlertIdentifier, @@ -298,22 +332,18 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { extension ExtensionDelegate { - static let didBecomeActiveNotification = Notification.Name("com.loopkit.Loop.LoopWatch.didBecomeActive") - - static let willResignActiveNotification = Notification.Name("com.loopkit.Loop.LoopWatch.willResignActive") - /// Global shortcut to present an alert for a specific error out-of-context with a specific interface controller. /// /// - parameter error: The error whose contents to display func present(_ error: Error) { dispatchPrecondition(condition: .onQueue(.main)) - WKExtension.shared().rootInterfaceController?.presentAlert(withTitle: error.localizedDescription, message: (error as NSError).localizedRecoverySuggestion ?? (error as NSError).localizedFailureReason, preferredStyle: .alert, actions: [WKAlertAction.dismissAction()]) + WKApplication.shared().rootInterfaceController?.presentAlert(withTitle: error.localizedDescription, message: (error as NSError).localizedRecoverySuggestion ?? (error as NSError).localizedFailureReason, preferredStyle: .alert, actions: [WKAlertAction.dismissAction()]) } } -fileprivate extension WKExtension { +fileprivate extension WKApplication { var extensionDelegate: ExtensionDelegate! { return delegate as? ExtensionDelegate } diff --git a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift index dee7c6c6d8..d2b9933a1a 100644 --- a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift +++ b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift @@ -140,6 +140,7 @@ extension CLKComplicationTemplate { eventualGlucoseText = eventualGlucoseString } + // 106↗108 8:47 PM let format = NSLocalizedString("UtilitarianLargeFlat", tableName: "ckcomplication", comment: "Utilitarian large flat format string (1: Glucose & Trend symbol) (2: Eventual Glucose) (3: Time)") return CLKComplicationTemplateUtilitarianLargeFlat( @@ -190,7 +191,7 @@ extension CLKComplicationTemplate { case .graphicRectangular: if #available(watchOSApplicationExtension 5.0, *) { return CLKComplicationTemplateGraphicRectangularLargeImage( - textProvider: CLKTextProvider(byJoining: [glucoseAndTrendText, timeText], separator: " "), + textProvider: CLKTextProvider(format: "%@ %@", glucoseAndTrendText, timeText), imageProvider: CLKFullColorImageProvider(fullColorImage: makeChart() ?? UIImage()) ) } else { diff --git a/WatchApp Extension/Extensions/EnvironmentValues+GlucoseDisplayUnit.swift b/WatchApp Extension/Extensions/EnvironmentValues+GlucoseDisplayUnit.swift new file mode 100644 index 0000000000..f7a035aab1 --- /dev/null +++ b/WatchApp Extension/Extensions/EnvironmentValues+GlucoseDisplayUnit.swift @@ -0,0 +1,22 @@ +// +// Env.swift +// Loop +// +// Created by Pete Schwamb on 9/9/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopAlgorithm + +@MainActor +private struct GlucoseDisplayUnitKey: @preconcurrency EnvironmentKey { + static let defaultValue: LoopUnit = .milligramsPerDeciliter +} + +extension EnvironmentValues { + var glucoseDisplayUnit: LoopUnit { + get { self[GlucoseDisplayUnitKey.self] } + set { self[GlucoseDisplayUnitKey.self] = newValue } + } +} diff --git a/WatchApp Extension/Extensions/UIColor.swift b/WatchApp Extension/Extensions/UIColor.swift index 8805741357..8670fdcdb0 100644 --- a/WatchApp Extension/Extensions/UIColor.swift +++ b/WatchApp Extension/Extensions/UIColor.swift @@ -28,6 +28,11 @@ extension UIColor { static let overrideColor = UIColor(named: "workout")! + static let presets = UIColor(named: "presets")! + + static let darkPresets = UIColor(named: "presets-dark")! + + // Equivalent to workoutColor with alpha 0.14 on a black background static let darkOverrideColor = UIColor(named: "workout-dark")! @@ -42,7 +47,9 @@ extension UIColor { static let chartPlatter = HIGWhiteColorDark() static let agingColor = UIColor(named: "warning") ?? HIGYellowColor() - + + static let fresh = UIColor(named: "fresh") ?? .purple + static let staleColor = HIGRedColor() // MARK: - HIG colors diff --git a/WatchApp Extension/Extensions/WCSession.swift b/WatchApp Extension/Extensions/WCSession.swift index 95318339e0..3e5f65c018 100644 --- a/WatchApp Extension/Extensions/WCSession.swift +++ b/WatchApp Extension/Extensions/WCSession.swift @@ -8,6 +8,7 @@ import LoopCore import WatchConnectivity +import LoopKit import os.log @@ -26,71 +27,67 @@ enum WCSessionMessageResult { private let log = OSLog(category: "WCSession Extension") extension WCSession { - func sendPotentialCarbEntryMessage(_ carbEntry: PotentialCarbEntryUserInfo, replyHandler: @escaping (WatchContext) -> Void, errorHandler: @escaping (Error) -> Void) throws { - guard activationState == .activated else { - throw MessageError.activation - } - guard isReachable else { - log.default("sendPotentialCarbEntryMessage: Phone is unreachable, taking no action") - return - } - - sendMessage(carbEntry.rawValue, - replyHandler: { reply in - guard let context = WatchContext(rawValue: reply as WatchContext.RawValue) else { - log.error("sendPotentialCarbEntryMessage: could not decode reply: %{public}@", reply) - errorHandler(MessageError.decoding) + func fetchSettings() async throws -> LoopSettingsUserInfo { + try await withCheckedThrowingContinuation { continuation in + sendMessage(SettingsRequestUserInfo().rawValue) { reply in + guard let settings = LoopSettingsUserInfo(rawValue: reply as LoopSettingsUserInfo.RawValue) else { + log.error("fetchSettings: could not decode reply: %{public}@", reply) + continuation.resume(throwing: MessageError.decoding) return } - - replyHandler(context) - }, - errorHandler: { error in - log.error("sendPotentialCarbEntryMessage: message send failed with error: %{public}@", String(describing: error)) - errorHandler(error) + continuation.resume(returning: settings) } - ) + } } - func sendBolusMessage(_ userInfo: SetBolusUserInfo, completionHandler: @escaping (Error?) -> Void) throws { - guard activationState == .activated else { - throw MessageError.activation + func fetchBolusRecommendation(_ carbEntry: NewCarbEntry?) async throws -> WatchContext { + let request = GetBolusRecommendationUserInfo(carbEntry: carbEntry) + let reply = try await sendMessage(request.rawValue) + log.debug("Requesting bolus recommendation with carbEntry: %{public}@", String(describing: carbEntry)) + + guard let context = WatchContext(rawValue: reply as WatchContext.RawValue) else { + log.error("fetchBolusRecommendation: could not decode reply: %{public}@", reply) + throw MessageError.decoding } + log.debug("fetchBolusRecommendation: recommendedBolusDose: %{public}@", String(describing: context.recommendedBolusDose)) - guard isReachable else { - throw MessageError.reachability + return context + } + + func sendBolusMessage(_ userInfo: SetBolusUserInfo) async throws -> WatchContext { + let reply = try await sendMessage(userInfo.rawValue) + guard let context = WatchContext(rawValue: reply as WatchContext.RawValue) else { + log.error("sendBolusMessage: could not decode reply: %{public}@", reply) + throw MessageError.decoding } + return context + } - sendMessage(userInfo.rawValue, - replyHandler: { reply in - completionHandler(nil) - }, - errorHandler: { error in - log.info("sendBolusMessage failure: %{public}@", error.localizedDescription) - completionHandler(error) - } - ) + func sendSetPreset(presetIdentifier: String?, alertIdentifier: String?) async throws { + let _ = try await sendMessage(SetPresetUserInfo(presetIdentifier: presetIdentifier, alertIdentifier: alertIdentifier).rawValue) + } + + func sendAcknowledgeAlert(alertIdentifier: String, managerIdentifier: String) async throws { + let _ = try await sendMessage(AcknowledgeAlertUserInfo(alertIdentifier: alertIdentifier, managerIdentifier: managerIdentifier).rawValue) } - func sendSettingsUpdateMessage(_ userInfo: LoopSettingsUserInfo, completionHandler: @escaping (Result) -> Void) throws { + func sendMessage(_ msg: [String : Any]) async throws -> [String : Any] { guard activationState == .activated else { throw MessageError.activation } - + guard isReachable else { throw MessageError.reachability } - sendMessage(userInfo.rawValue, replyHandler: { (reply) in - if let context = WatchContext(rawValue: reply) { - completionHandler(.success(context)) - } else { - completionHandler(.failure(MessageError.decoding)) - } - }, errorHandler: { (error) in - completionHandler(.failure(error)) - }) + return try await withCheckedThrowingContinuation { continuation in + sendMessage(msg, replyHandler: { result in + continuation.resume(returning: result) + }, errorHandler: { error in + continuation.resume(throwing: error) + }) + } } func sendUserSelectedNotificationActionMessage(alertIdentifier: String, managerIdentifier: String, actionIdentifier: String) async { diff --git a/WatchApp Extension/Extensions/WatchContext+WatchApp.swift b/WatchApp Extension/Extensions/WatchContext+WatchApp.swift index 41253d309a..0f3b85fb7d 100644 --- a/WatchApp Extension/Extensions/WatchContext+WatchApp.swift +++ b/WatchApp Extension/Extensions/WatchContext+WatchApp.swift @@ -9,6 +9,7 @@ import Foundation import LoopAlgorithm import LoopKit +import LoopCore extension WatchContext { var activeInsulin: LoopQuantity? { diff --git a/WatchApp Extension/Info.plist b/WatchApp Extension/Info.plist deleted file mode 100644 index e0a8a9a98f..0000000000 --- a/WatchApp Extension/Info.plist +++ /dev/null @@ -1,61 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - WatchApp Extension - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleShortVersionString - $(LOOP_MARKETING_VERSION) - CFBundleSignature - ???? - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - CLKComplicationPrincipalClass - $(PRODUCT_MODULE_NAME).ComplicationController - CLKComplicationSupportedFamilies - - CLKComplicationFamilyCircularSmall - CLKComplicationFamilyExtraLarge - CLKComplicationFamilyGraphicBezel - CLKComplicationFamilyGraphicCircular - CLKComplicationFamilyGraphicCorner - CLKComplicationFamilyGraphicExtraLarge - CLKComplicationFamilyGraphicRectangular - CLKComplicationFamilyModularLarge - CLKComplicationFamilyModularSmall - CLKComplicationFamilyUtilitarianLarge - CLKComplicationFamilyUtilitarianSmall - CLKComplicationFamilyUtilitarianSmallFlat - - NSExtension - - NSExtensionAttributes - - WKAppBundleIdentifier - $(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch - - NSExtensionPointIdentifier - com.apple.watchkit - - NSHealthShareUsageDescription - Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake. - NSHealthUpdateUsageDescription - Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit. - RemoteInterfacePrincipalClass - $(PRODUCT_MODULE_NAME).StatusInterfaceController - WKExtensionDelegateClassName - $(PRODUCT_MODULE_NAME).ExtensionDelegate - - diff --git a/WatchApp Extension/Managers/ComplicationChartManager.swift b/WatchApp Extension/Managers/ComplicationChartManager.swift index 17b87c4c36..3d4268afa8 100644 --- a/WatchApp Extension/Managers/ComplicationChartManager.swift +++ b/WatchApp Extension/Managers/ComplicationChartManager.swift @@ -123,19 +123,8 @@ final class ComplicationChartManager { let spannedInterval = scaler.dates func drawOverride( - _ override: TemporaryScheduleOverride, - pushingStartTo startDate: Date? = nil, - extendingToChartEnd shouldExtendToChartEnd: Bool + _ override: TemporaryScheduleOverride ) { - var override = override - if let startDate = startDate { - guard startDate < override.scheduledEndDate else { - return - } - - override.scheduledInterval = DateInterval(start: startDate, end: override.scheduledEndDate) - } - guard let overrideHashable = TemporaryScheduleOverrideHashable(override) else { return } @@ -144,7 +133,7 @@ final class ComplicationChartManager { let overrideRect = scaler.rect(for: overrideHashable, unit: unit) context.fill(overrideRect) - if spannedInterval.end > override.scheduledEndDate, shouldExtendToChartEnd { + if spannedInterval.end > override.scheduledEndDate { var extendedOverride = override extendedOverride.duration = .finite(spannedInterval.end.timeIntervalSince(override.startDate)) // Target range already known to be non-nil @@ -155,12 +144,8 @@ final class ComplicationChartManager { } } - if let preMealOverride = data?.activePreMealOverride { - drawOverride(preMealOverride, extendingToChartEnd: true) - } - if let override = data?.activeScheduleOverride { - drawOverride(override, pushingStartTo: data?.activePreMealOverride?.scheduledEndDate, extendingToChartEnd: data?.activePreMealOverride == nil) + drawOverride(override) } } diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index ea94e5eca3..3123648eba 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -13,16 +13,26 @@ import LoopCore import WatchConnectivity import os.log import LoopAlgorithm +import UserNotifications +import WatchKit - +@MainActor +@Observable class LoopDataManager { + static let shared = LoopDataManager() + let carbStore: CarbStore - var glucoseStore: GlucoseStore! + var glucoseStore: GlucoseStore? + @ObservationIgnored @PersistedProperty(key: "Settings") private var rawWatchInfo: LoopSettingsUserInfo.RawValue? + @ObservationIgnored + @PersistedProperty(key: "WatchContext") + private var rawWatchContext: WatchContext.RawValue? + // Main queue only var watchInfo: LoopSettingsUserInfo { didSet { @@ -32,6 +42,32 @@ class LoopDataManager { } } + var pendingPresetReminder: PendingPresetReminder? + + var pendingPreset: SelectablePreset? { + if let presetIdentifier = pendingPresetReminder?.presetIdentifier { + return selectablePresets.first(where: { $0.id == presetIdentifier })! + } else { + return nil + } + } + + var activePreset: SelectablePreset? { + guard let presetId = watchInfo.scheduleOverride?.presetId else { + return nil + } + return selectablePresets.first(where: { $0.id == presetId }) + } + + var glucoseChartScene: GlucoseChartScene = { + let s = GlucoseChartScene() + s.size = WKInterfaceDevice.current().screenBounds.size + return s + }() + + // When set, user will be navigated to carbs/bolus flow + var bolusViewModel: CarbAndBolusFlowViewModel? + // Main queue only var supportedBolusVolumes = UserDefaults.standard.supportedBolusVolumes { didSet { @@ -46,6 +82,7 @@ class LoopDataManager { // Main queue only private(set) var activeContext: WatchContext? { didSet { + rawWatchContext = activeContext?.rawValue needsDidUpdateContextNotification = true sendDidUpdateContextNotificationIfNecessary() } @@ -72,8 +109,7 @@ class LoopDataManager { self.watchInfo = LoopSettingsUserInfo( loopSettings: LoopSettings(), - scheduleOverride: nil, - preMealOverride: nil + scheduleOverride: nil ) Task { @@ -83,9 +119,13 @@ class LoopDataManager { ) } - if let rawWatchInfo = rawWatchInfo, let watchInfo = LoopSettingsUserInfo(rawValue: rawWatchInfo) { + if let rawWatchInfo, let watchInfo = LoopSettingsUserInfo(rawValue: rawWatchInfo) { self.watchInfo = watchInfo } + + if let rawWatchContext, let watchContext = WatchContext(rawValue: rawWatchContext) { + self.activeContext = watchContext + } } } @@ -100,7 +140,7 @@ extension LoopDataManager { if activeContext == nil || context.shouldReplace(activeContext!) { if let newGlucoseSample = context.newGlucoseSample { Task { - try? await self.glucoseStore.addGlucoseSamples([newGlucoseSample]) + try? await self.glucoseStore?.addGlucoseSamples([newGlucoseSample]) } } activeContext = context @@ -168,7 +208,7 @@ extension LoopDataManager { case .success(let context): Task { do { - try await self.glucoseStore.setSyncGlucoseSamples(context.samples) + try await self.glucoseStore?.setSyncGlucoseSamples(context.samples) } catch { self.log.error("Failure setting sync glucose samples: %{public}@", String(describing: error)) } @@ -185,6 +225,12 @@ extension LoopDataManager { return true } + func requestSettingsUpdate() async { + if let settings = try? await WCSession.default.fetchSettings() { + self.watchInfo = settings + } + } + func requestContextUpdate(completion: @escaping () -> Void = { }) { try? WCSession.default.sendContextRequestMessage(WatchContextRequestUserInfo(), completionHandler: { (result) in DispatchQueue.main.async { @@ -198,6 +244,73 @@ extension LoopDataManager { } }) } + + func clearOverride() async throws { + var watchInfoUpdate = self.watchInfo + watchInfoUpdate.scheduleOverride = nil + try await WCSession.default.sendSetPreset(presetIdentifier: nil, alertIdentifier: nil) + watchInfo = watchInfoUpdate + } + + func activateOverride(_ override: TemporaryScheduleOverride, alertIdentifierToAcknowledge: String? = nil) async throws { + var watchInfoUpdate = self.watchInfo + watchInfoUpdate.scheduleOverride = override + try await WCSession.default.sendSetPreset(presetIdentifier: override.presetId, alertIdentifier: alertIdentifierToAcknowledge) + watchInfo = watchInfoUpdate + } + + func acknowledgeAlert(alertIdentifier: String, managerIdentifier: String) async throws { + self.log.default("Acknowledging alert %{public}@ : %{public}@", alertIdentifier, managerIdentifier) + try await WCSession.default.sendAcknowledgeAlert(alertIdentifier: alertIdentifier, managerIdentifier: managerIdentifier) + } + + var selectablePresets: [SelectablePreset] { + var presets: [SelectablePreset] = [] + + let settings = watchInfo.loopSettings + + if let preMealTargetRange = settings.preMealTargetRange { + presets.append(.preMeal(range: preMealTargetRange)) + } + + presets.append(contentsOf: settings.overridePresets.map { override in + if override.id.hasPrefix("activity-"), let activityPreset = ActivityPreset(preset: override) { + return .activity(activityPreset) + } else { + return .custom(override) + } + }) + + ActivityPreset.ActivityType.allCases.forEach { activityType in + if !settings.overridePresets.contains(where: { $0.id == activityType.id }) { + presets.append(.activity(ActivityPreset(activityType: activityType, preset: activityType.defaultPreset(duration: .finite(.minutes(90)))))) + } + } + + return presets + } + + var glucoseValue: String { + guard let activeContext = activeContext, + let glucose = activeContext.glucose, + let unit = activeContext.displayGlucoseUnit else + { + return "- - -" + } + + let formatter = NumberFormatter.glucoseFormatter(for: unit) + + var glucoseValue: String + + if let glucoseCondition = activeContext.glucoseCondition { + glucoseValue = glucoseCondition.localizedDescription + } else { + glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) ?? "???" + } + + let trend = activeContext.glucoseTrend?.symbol ?? "" + return glucoseValue + trend + } } extension LoopDataManager { @@ -207,28 +320,25 @@ extension LoopDataManager { } extension LoopDataManager { - func generateChartData(completion: @escaping (GlucoseChartData?) -> Void) { + + func generateChartData() async -> GlucoseChartData? { guard let activeContext = activeContext else { - completion(nil) - return + return nil } - Task { - var historicalGlucose: [StoredGlucoseSample]? - do { - historicalGlucose = try await glucoseStore.getGlucoseSamples(start: .earliestGlucoseCutoff) - } catch { - self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) - } - let chartData = GlucoseChartData( - unit: activeContext.displayGlucoseUnit, - correctionRange: self.watchInfo.loopSettings.glucoseTargetRangeSchedule, - preMealOverride: self.watchInfo.preMealOverride, - scheduleOverride: self.watchInfo.scheduleOverride, - historicalGlucose: historicalGlucose, - predictedGlucose: (activeContext.isClosedLoop ?? false) ? activeContext.predictedGlucose?.values : nil - ) - completion(chartData) + var historicalGlucose: [StoredGlucoseSample]? + do { + historicalGlucose = try await glucoseStore?.getGlucoseSamples(start: .earliestGlucoseCutoff) + } catch { + self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) } + let chartData = GlucoseChartData( + unit: activeContext.displayGlucoseUnit, + correctionRange: self.watchInfo.loopSettings.glucoseTargetRangeSchedule, + scheduleOverride: self.watchInfo.scheduleOverride, + historicalGlucose: historicalGlucose, + predictedGlucose: (activeContext.isClosedLoop ?? false) ? activeContext.predictedGlucose?.values : nil + ) + return chartData } } diff --git a/Common/Models/CarbAbsorptionTime.swift b/WatchApp Extension/Models/CarbAbsorptionTime.swift similarity index 100% rename from Common/Models/CarbAbsorptionTime.swift rename to WatchApp Extension/Models/CarbAbsorptionTime.swift diff --git a/WatchApp Extension/Models/GlucoseChartData.swift b/WatchApp Extension/Models/GlucoseChartData.swift index 868b53121a..27903af5b6 100644 --- a/WatchApp Extension/Models/GlucoseChartData.swift +++ b/WatchApp Extension/Models/GlucoseChartData.swift @@ -16,8 +16,6 @@ struct GlucoseChartData { var correctionRange: GlucoseRangeSchedule? - var preMealOverride: TemporaryScheduleOverride? - var scheduleOverride: TemporaryScheduleOverride? var historicalGlucose: [SampleValue]? { @@ -36,10 +34,9 @@ struct GlucoseChartData { private(set) var predictedGlucoseRange: ClosedRange? - init(unit: LoopUnit?, correctionRange: GlucoseRangeSchedule?, preMealOverride: TemporaryScheduleOverride?, scheduleOverride: TemporaryScheduleOverride?, historicalGlucose: [SampleValue]?, predictedGlucose: [SampleValue]?) { + init(unit: LoopUnit?, correctionRange: GlucoseRangeSchedule?, scheduleOverride: TemporaryScheduleOverride?, historicalGlucose: [SampleValue]?, predictedGlucose: [SampleValue]?) { self.unit = unit self.correctionRange = correctionRange - self.preMealOverride = preMealOverride self.scheduleOverride = scheduleOverride self.historicalGlucose = historicalGlucose self.historicalGlucoseRange = historicalGlucose?.quantityRange @@ -59,11 +56,6 @@ struct GlucoseChartData { max = Swift.max(max, correction.value.upperBound.doubleValue(for: unit)) } - if let override = activePreMealOverride?.settings.targetRange { - min = Swift.min(min, override.lowerBound.doubleValue(for: unit)) - max = Swift.max(max, override.upperBound.doubleValue(for: unit)) - } - if let override = activeScheduleOverride?.settings.targetRange { min = Swift.min(min, override.lowerBound.doubleValue(for: unit)) max = Swift.max(max, override.upperBound.doubleValue(for: unit)) @@ -96,13 +88,6 @@ struct GlucoseChartData { } return override } - - var activePreMealOverride: TemporaryScheduleOverride? { - guard let override = preMealOverride, override.isActive() else { - return nil - } - return override - } } private extension LoopUnit { diff --git a/WatchApp Extension/Models/PendingPresetReminder.swift b/WatchApp Extension/Models/PendingPresetReminder.swift new file mode 100644 index 0000000000..bd796285e1 --- /dev/null +++ b/WatchApp Extension/Models/PendingPresetReminder.swift @@ -0,0 +1,14 @@ +// +// PendingPresetReminder.swift +// Loop +// +// Created by Pete Schwamb on 9/18/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +struct PendingPresetReminder: Equatable { + var presetIdentifier: String + var alertIdentifier: String + var managerIdentifier: String +} diff --git a/WatchApp Extension/Scenes/GlucoseChartScene.swift b/WatchApp Extension/Scenes/GlucoseChartScene.swift index 010022908c..0c7c6473ac 100644 --- a/WatchApp Extension/Scenes/GlucoseChartScene.swift +++ b/WatchApp Extension/Scenes/GlucoseChartScene.swift @@ -237,7 +237,7 @@ class GlucoseChartScene: SKScene { // Keep track of the nodes we started this pass with so we can expire obsolete nodes at the end var inactiveNodes = nodes - let isOverrideActive = data.activePreMealOverride != nil || data.activeScheduleOverride != nil + let isOverrideActive = data.activeScheduleOverride != nil data.correctionRange?.quantityBetween(start: spannedInterval.start, end: spannedInterval.end).forEach { range in let (sprite, created) = getSprite(forHash: range.chartHashValue) sprite.color = UIColor.glucose.withAlphaComponent(isOverrideActive ? 0.2 : 0.3) @@ -251,8 +251,7 @@ class GlucoseChartScene: SKScene { // extends to the end of the visible window. func plotOverride( _ override: TemporaryScheduleOverride, - pushingStartTo startDate: Date? = nil, - extendingToChartEnd shouldExtendToChartEnd: Bool + pushingStartTo startDate: Date? = nil ) { var override = override if let startDate = startDate { @@ -273,7 +272,7 @@ class GlucoseChartScene: SKScene { sprite1.move(to: scaler.rect(for: overrideHashable, unit: unit), animated: !created) inactiveNodes.removeValue(forKey: overrideHashable.chartHashValue) - if override.scheduledEndDate < spannedInterval.end, shouldExtendToChartEnd { + if override.scheduledEndDate < spannedInterval.end { var extendedOverride = override extendedOverride.duration = .finite(spannedInterval.end.timeIntervalSince(overrideHashable.start)) // Target range already known to be non-nil @@ -286,12 +285,8 @@ class GlucoseChartScene: SKScene { } } - if let preMealOverride = data.activePreMealOverride { - plotOverride(preMealOverride, extendingToChartEnd: true) - } - if let override = data.activeScheduleOverride { - plotOverride(override, pushingStartTo: data.activePreMealOverride?.scheduledEndDate, extendingToChartEnd: data.activePreMealOverride == nil) + plotOverride(override) } data.historicalGlucose?.filter { scaler.dates.contains($0.startDate) }.forEach { diff --git a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift index d264f04992..290b9dfa7b 100644 --- a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift +++ b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift @@ -14,7 +14,7 @@ import WatchConnectivity import LoopKit import LoopCore - +@MainActor final class CarbAndBolusFlowViewModel: ObservableObject { enum Error: Swift.Error { case potentialCarbEntryMessageSendFailure @@ -31,7 +31,6 @@ final class CarbAndBolusFlowViewModel: ObservableObject { let interactionStartDate = Date() private var carbEntryUnderConsideration: NewCarbEntry? private var contextUpdateObservation: AnyObject? - private var hasSentConfirmationMessage = false private var contextDate: Date? // MARK: - Constants @@ -40,21 +39,12 @@ final class CarbAndBolusFlowViewModel: ObservableObject { // MARK: - Initialization let configuration: CarbAndBolusFlow.Configuration - private let dismiss: () -> Void init( - configuration: CarbAndBolusFlow.Configuration, - dismiss: @escaping () -> Void + configuration: CarbAndBolusFlow.Configuration ) { - let loopManager = ExtensionDelegate.shared().loopManager - switch configuration { - case .carbEntry: - break - case .manualBolus: - let activeContext = loopManager.activeContext - self.contextDate = activeContext?.creationDate - self._recommendedBolusAmount = Published(initialValue: activeContext?.recommendedBolusDose) - } + let loopManager = LoopDataManager.shared + self.configuration = configuration self._bolusPickerValues = Published( initialValue: BolusPickerValues( @@ -63,41 +53,50 @@ final class CarbAndBolusFlowViewModel: ObservableObject { ) ) - self.configuration = configuration - self.dismiss = dismiss + switch configuration { + case .carbEntry: + break + case .manualBolus: + // If we start out on the manual bolus screen, fetch a fresh recommendation immediately + Task { @MainActor in + await recommendBolus() + } + } contextUpdateObservation = NotificationCenter.default.addObserver( forName: LoopDataManager.didUpdateContextNotification, object: loopManager, queue: nil ) { [weak self] _ in - guard - let self = self, - !self.hasSentConfirmationMessage - else { - return + Task { @MainActor in + self?.handleContextUpdate(loopManager: loopManager) } - - self.bolusPickerValues = BolusPickerValues( - supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, - maxBolus: loopManager.watchInfo.loopSettings.maximumBolus ?? Self.defaultMaxBolus - ) + } + } - switch self.configuration { - case .carbEntry: - // If this new context wasn't generated in response to a potential carb entry message, - // recompute the recommended bolus for the carb entry under consideration. - let wasContextGeneratedFromPotentialCarbEntryMessage = loopManager.activeContext?.potentialCarbEntry != nil - if !wasContextGeneratedFromPotentialCarbEntryMessage, let entry = self.carbEntryUnderConsideration { - self.recommendBolus(for: entry) - } - case .manualBolus: - let activeContext = loopManager.activeContext - self.contextDate = activeContext?.creationDate - if self.recommendedBolusAmount != activeContext?.recommendedBolusDose { - self.recommendedBolusAmount = activeContext?.recommendedBolusDose + func handleContextUpdate(loopManager: LoopDataManager) { + + self.bolusPickerValues = BolusPickerValues( + supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, + maxBolus: loopManager.watchInfo.loopSettings.maximumBolus ?? Self.defaultMaxBolus + ) + + switch self.configuration { + case .carbEntry: + // If this new context wasn't generated in response to a potential carb entry message, + // recompute the recommended bolus for the carb entry under consideration. + let wasContextGeneratedFromPotentialCarbEntryMessage = loopManager.activeContext?.potentialCarbEntry != nil + if !wasContextGeneratedFromPotentialCarbEntryMessage, let entry = self.carbEntryUnderConsideration { + Task { @MainActor in + await self.recommendBolus(with: entry) } } + case .manualBolus: + let activeContext = loopManager.activeContext + self.contextDate = activeContext?.creationDate + if self.recommendedBolusAmount != activeContext?.recommendedBolusDose { + self.recommendedBolusAmount = activeContext?.recommendedBolusDose + } } } @@ -112,7 +111,7 @@ final class CarbAndBolusFlowViewModel: ObservableObject { recommendedBolusAmount = nil } - func recommendBolus(forGrams grams: Int, eatenAt carbEntryDate: Date, absorptionTime carbAbsorptionTime: CarbAbsorptionTime, lastEntryDate: Date) { + func recommendBolus(forGrams grams: Int, eatenAt carbEntryDate: Date, absorptionTime carbAbsorptionTime: CarbAbsorptionTime, lastEntryDate: Date) async { let entry = NewCarbEntry( date: lastEntryDate, quantity: LoopQuantity(unit: .gram, doubleValue: Double(grams)), @@ -126,52 +125,34 @@ final class CarbAndBolusFlowViewModel: ObservableObject { } carbEntryUnderConsideration = entry - recommendBolus(for: entry) + await recommendBolus(with: entry) } - private func recommendBolus(for entry: NewCarbEntry) { - let potentialEntry = PotentialCarbEntryUserInfo(carbEntry: entry) + private func recommendBolus(with entry: NewCarbEntry? = nil) async { do { isComputingRecommendedBolus = true - try WCSession.default.sendPotentialCarbEntryMessage(potentialEntry, - replyHandler: { [weak self] context in - DispatchQueue.main.async { - let loopManager = ExtensionDelegate.shared().loopManager - loopManager.updateContext(context) - - guard let self = self else { - return - } - - // Only update if this recommendation corresponds to the current carb entry under consideration. - guard context.potentialCarbEntry == self.carbEntryUnderConsideration else { - return - } - - defer { - self.isComputingRecommendedBolus = false - } - - self.contextDate = context.creationDate - - // Don't publish a new value if the recommendation has not changed. - guard self.recommendedBolusAmount != context.recommendedBolusDose else { - return - } - - self.recommendedBolusAmount = context.recommendedBolusDose - } - }, - errorHandler: { error in - DispatchQueue.main.async { [weak self] in - self?.isComputingRecommendedBolus = false - WKInterfaceDevice.current().play(.failure) - ExtensionDelegate.shared().present(error) - } - } - ) + let context = try await WCSession.default.fetchBolusRecommendation(entry) + + // Only update if this recommendation corresponds to the current carb entry under consideration. + guard context.potentialCarbEntry == self.carbEntryUnderConsideration else { + return + } + + defer { + self.isComputingRecommendedBolus = false + } + + self.contextDate = context.creationDate + + // Don't publish a new value if the recommendation has not changed. + guard self.recommendedBolusAmount != context.recommendedBolusDose else { + return + } + + self.recommendedBolusAmount = context.recommendedBolusDose } catch { isComputingRecommendedBolus = false + WKInterfaceDevice.current().play(.failure) self.error = .potentialCarbEntryMessageSendFailure } } @@ -189,49 +170,29 @@ final class CarbAndBolusFlowViewModel: ObservableObject { } } - func addCarbsWithoutBolusing() { + func addCarbsWithoutBolusing() async throws { guard let carbEntry = carbEntryUnderConsideration else { assertionFailure("Attempting to add carbs without a carb entry") return } - sendSetBolusUserInfo(carbEntry: carbEntry, bolus: 0) + try await sendSetBolusUserInfo(carbEntry: carbEntry, bolus: 0) } - func addCarbsAndDeliverBolus(_ bolusAmount: Double) { - sendSetBolusUserInfo(carbEntry: carbEntryUnderConsideration, bolus: bolusAmount) + func addCarbsAndDeliverBolus(_ bolusAmount: Double) async throws { + try await sendSetBolusUserInfo(carbEntry: carbEntryUnderConsideration, bolus: bolusAmount) } - private func sendSetBolusUserInfo(carbEntry: NewCarbEntry?, bolus: Double) { - guard !hasSentConfirmationMessage else { - return - } - self.hasSentConfirmationMessage = true - + private func sendSetBolusUserInfo(carbEntry: NewCarbEntry?, bolus: Double) async throws { let bolus = SetBolusUserInfo(value: bolus, startDate: Date(), contextDate: self.contextDate, carbEntry: carbEntry, activationType: .activationTypeFor(recommendedAmount: recommendedBolusAmount, bolusAmount: bolus)) - do { - try WCSession.default.sendBolusMessage(bolus) { [weak self] (error) in - DispatchQueue.main.async { - if let error = error { - ExtensionDelegate.shared().present(error) - self?.hasSentConfirmationMessage = false - } else { - if bolus.carbEntry != nil { - if bolus.value == 0 { - // Notify for a successful carb entry (sans bolus) - WKInterfaceDevice.current().play(.success) - } - } - } - } + let updatedContext = try await WCSession.default.sendBolusMessage(bolus) + if bolus.carbEntry != nil { + if bolus.value == 0 { + // Notify for a successful carb entry (sans bolus) + WKInterfaceDevice.current().play(.success) } - - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { - self.dismiss() - } - } catch { - self.error = .bolusMessageSendFailure } + LoopDataManager.shared.updateContext(updatedContext) } } diff --git a/WatchApp Extension/View Models/OnOffSelectionViewModel.swift b/WatchApp Extension/View Models/OnOffSelectionViewModel.swift deleted file mode 100644 index c188e59492..0000000000 --- a/WatchApp Extension/View Models/OnOffSelectionViewModel.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// OnOffSelectionViewModel.swift -// WatchApp Extension -// -// Created by Anna Quinlan on 8/20/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -enum SelectedButton { - case on - case off -} - -class OnOffSelectionViewModel: ObservableObject { - var title: String - var message: String - var onSelection: (Bool) -> Void - var dismiss: (() -> Void)? - var selectedButton: SelectedButton - var selectedButtonTint: UIColor - - init( - title: String, - message: String, - onSelection: @escaping (Bool) -> Void, - dismiss: (() -> Void)? = nil, - selectedButton: SelectedButton = .off, - selectedButtonTint: UIColor = .tintColor - ) { - self.title = title - self.message = message - self.onSelection = onSelection - self.dismiss = dismiss - self.selectedButton = selectedButton - self.selectedButtonTint = selectedButtonTint - } -} diff --git a/WatchApp Extension/Views/ActionButton.swift b/WatchApp Extension/Views/ActionButton.swift index e3b2e67bd9..e926a23904 100644 --- a/WatchApp Extension/Views/ActionButton.swift +++ b/WatchApp Extension/Views/ActionButton.swift @@ -21,7 +21,6 @@ struct ActionButton: View { .animation(nil) }) .buttonStyle(ActionButtonStyle(color: color)) - .animation(.default) .frame(height: 40) } } diff --git a/WatchApp Extension/Views/ActiveOverrideView.swift b/WatchApp Extension/Views/ActiveOverrideView.swift new file mode 100644 index 0000000000..840fa12f3d --- /dev/null +++ b/WatchApp Extension/Views/ActiveOverrideView.swift @@ -0,0 +1,167 @@ +// +// ActivePresetView.swift +// Loop +// +// Created by Pete Schwamb on 9/10/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct ActiveOverrideView: View { + @Environment(LoopDataManager.self) var loopManager + @Environment(\.glucoseDisplayUnit) private var glucoseDisplayUnit + + @State private var crownValue: CGFloat = 0 // Tracks Digital Crown rotation + @State private var endingPreset: Bool = false + @State private var lastInteractionTime: Date? // Tracks last crown interaction + + private let threshold: CGFloat = 20 // Rotation threshold to trigger action + private let maxProgress: CGFloat = 20 // Max progress for the bar + private let resetDelay: TimeInterval = 0.25 // pause for reset + + let override: TemporaryScheduleOverride + + var titleText: Text { + switch override.context { + case .preMeal: + Text(NSLocalizedString("Pre-Meal", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)")) + case .preset(let preset): + Text(String(format: NSLocalizedString("%@", comment: "The format for an active custom preset. (1: preset name)"), preset.name)) + case .activity(let activity): + Text(String(format: NSLocalizedString("%@", comment: "The format for an active activity preset. (1: preset name)"), activity.preset.name)) + case .custom: + Text(NSLocalizedString("Single Use Preset", comment: "The title of the cell indicating a generic custom preset is enabled")) + } + } + + var title: some View { + HStack(spacing: 6) { + titleText + .font(.system(size: 19)) + } + } + + var duration: Text { + if override.isActive() { + if override.context == .preMeal { + return Text(NSLocalizedString("on until carbs added", comment: "The format for the description of a premeal preset end date")) + } else { + switch override.duration { + case .finite: + let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) + return Text(String(format: NSLocalizedString("on until %@", comment: "The format for the description of a finite custom preset end date"), endTimeText)) + case .indefinite: + return Text(NSLocalizedString("on until turned off", comment: "The format for the description of an indefinite custom preset end date")) + } + } + } else { + let startTimeText = DateFormatter.localizedString(from: override.startDate, dateStyle: .none, timeStyle: .short) + return Text(String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText)) + } + } + + + private var numberFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + return formatter + } + + private var glucoseFormatter: QuantityFormatter { + return QuantityFormatter(for: glucoseDisplayUnit) + } + + var presetDuration: some View { + Group { Text(Image(systemName: "timer")) + duration } + .font(.footnote) + .foregroundColor(.presets) + } + + var descriptionText: Text { + let percent = numberFormatter.string(from: override.settings.insulinNeedsScaleFactor ?? 1)! + var text = Text(percent).bold() + + if let correctionRange = override.settings.targetRange { + text = text + Text(" • ") + text = text + (Text(glucoseFormatter.string(from: correctionRange.lowerBound, includeUnit: false)!) + + Text("-") + + Text(glucoseFormatter.string(from: correctionRange.upperBound, includeUnit: false)!)).bold() + text = text + Text(" " + glucoseDisplayUnit.localizedShortUnitString) + .foregroundStyle(.secondary) + } + return text + } + + var progress: CGFloat { + if endingPreset { + return 1 + } else { + return min(crownValue, maxProgress)/threshold + } + } + + var body: some View { + VStack(spacing: 4) { + title + presetDuration + descriptionText + .padding(.top, 8) + .padding(.bottom, 10) + + Spacer() + + ZStack(alignment: .center) { + if progress == 0 { + Text(endingPreset ? "Ending Preset..." : "Turn Digital Crown to End") + .font(.system(size: 16)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } else { + CircularProgressWithCheckmark(progress: progress, isComplete: endingPreset) + .animation(.easeInOut(duration: 0.2), value: crownValue) // Smooth animation for progress + .animation(.easeOut(duration: 0.3), value: endingPreset) // Fast animation for completion + } + } + } + .padding() + .focusable() // Required for Digital Crown interaction + .digitalCrownRotation( + $crownValue, + from: 0, + through: threshold, + by: 1, + sensitivity: .medium, + isContinuous: false + ) + .onChange(of: crownValue) { (oldValue, newValue) in + lastInteractionTime = Date() + + Task { + try? await Task.sleep(nanoseconds: UInt64(resetDelay * 1_000_000_000)) // Wait for 1 second + if let lastTime = lastInteractionTime, Date().timeIntervalSince(lastTime) >= resetDelay && !endingPreset { + withAnimation { + crownValue = 0 // Reset progress + } + } + } + + if newValue >= threshold && !endingPreset { + withAnimation(.spring(response: 0.2, dampingFraction: 0.5)) { + endingPreset = true + Task { + do { + try await loopManager.clearOverride() + WKInterfaceDevice.current().play(.directionDown) + } catch { + WKInterfaceDevice.current().play(.failure) + } + } + } + } + } + .navigationBarBackButtonHidden(false) // Ensure back button is visible + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/BolusArrow.swift b/WatchApp Extension/Views/Carb Entry & Bolus/BolusArrow.swift index 7a8d47657d..431ffda3d4 100644 --- a/WatchApp Extension/Views/Carb Entry & Bolus/BolusArrow.swift +++ b/WatchApp Extension/Views/Carb Entry & Bolus/BolusArrow.swift @@ -31,7 +31,7 @@ struct BolusArrow: View { .padding(.top, 4) // Animate the arrow down off-screen once finished .offset(y: isFinished ? sizeClass.screenSize.height : 0) - .animation(Animation.default.speed(isFinished ? 0.35 : 1.0)) + .animation(.default.speed(isFinished ? 0.35 : 1.0), value: progress) } private var arrow: some View { diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationVisual.swift b/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationVisual.swift index 4483d4c38b..7a7015bf0c 100644 --- a/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationVisual.swift +++ b/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationVisual.swift @@ -19,7 +19,7 @@ struct BolusConfirmationVisual: View { Circle() .fill(Color.darkInsulin) .opacity(isFinished ? 0 : 1) - .animation(Animation.default.speed(0.5)) + .animation(Animation.default.speed(0.5), value: progress) .overlay(BolusArrow(progress: progress)) if isFinished { diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift index 23761a82bf..e694105a06 100644 --- a/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift +++ b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift @@ -8,6 +8,7 @@ import SwiftUI import LoopKit +import WatchKit struct CarbAndBolusFlow: View { @@ -31,6 +32,8 @@ struct CarbAndBolusFlow: View { @State private var flowState: FlowState @ObservedObject private var viewModel: CarbAndBolusFlowViewModel @Environment(\.sizeClass) private var sizeClass + @Environment(\.dismiss) private var dismiss + // MARK: - State: Carb Entry // Date the user last changed the carb entry with the UI @@ -89,6 +92,16 @@ struct CarbAndBolusFlow: View { // Handle error states. .onReceive(viewModel.$error) { self.activeAlert = $0.map(AlertState.communicationError) } .alert(item: $activeAlert, content: alert(for:)) + + // Handoff + .onAppear { + let activity = NSUserActivity.forDidAddCarbEntryOnWatch() + activity.becomeCurrent() + } + + .onReceive(NotificationCenter.default.publisher(for: WKExtension.applicationWillResignActiveNotification)) { _ in + dismiss() + } } } @@ -148,11 +161,13 @@ extension CarbAndBolusFlow { } private func transitionToBolusEntry() { - viewModel.recommendBolus(forGrams: carbAmount, eatenAt: carbEntryDate, absorptionTime: carbAbsorptionTime, lastEntryDate: carbLastEntryDate) withAnimation { flowState = .bolusEntry inputMode = .carbs } + Task { @MainActor in + await viewModel.recommendBolus(forGrams: carbAmount, eatenAt: carbEntryDate, absorptionTime: carbAbsorptionTime, lastEntryDate: carbLastEntryDate) + } } private var topPaddingToPositionInputViews: CGFloat { @@ -172,7 +187,7 @@ extension CarbAndBolusFlow { } else { return 19 } - case .size44mm, .size45mm: + case .size44mm, .size45mm, .size46mm, .size49mm: return 5 } } @@ -218,7 +233,14 @@ extension CarbAndBolusFlow { self.flowState = .bolusConfirmation } } else if case .carbEntry = self.configuration { - self.viewModel.addCarbsWithoutBolusing() + Task { + do { + try await self.viewModel.addCarbsWithoutBolusing() + dismiss() + } catch { + viewModel.error = .bolusMessageSendFailure + } + } } } .offset(y: actionButtonOffsetY) @@ -242,14 +264,21 @@ extension CarbAndBolusFlow { return 0 case .size40mm, .size41mm: return 20 - case .size44mm, .size45mm: + case .size44mm, .size45mm, .size46mm, .size49mm: return 27 } } private var bolusConfirmationView: some View { BolusConfirmationView(progress: $bolusConfirmationProgress, onConfirmation: { - self.viewModel.addCarbsAndDeliverBolus(self.bolusAmount) + Task { + do { + try await self.viewModel.addCarbsAndDeliverBolus(self.bolusAmount) + dismiss() + } catch { + viewModel.error = .bolusMessageSendFailure + } + } }) .padding(.bottom, bolusConfirmationPadding) .transition(.fadeIn(after: 0.35)) @@ -312,6 +341,10 @@ extension CarbAndBolusFlow { return } + if !receivedInitialBolusRecommendation && recommendedBolus == nil { + return + } + if !receivedInitialBolusRecommendation { receivedInitialBolusRecommendation = true diff --git a/WatchApp Extension/Views/CarbList.swift b/WatchApp Extension/Views/CarbList.swift new file mode 100644 index 0000000000..3377ae6d40 --- /dev/null +++ b/WatchApp Extension/Views/CarbList.swift @@ -0,0 +1,96 @@ +// +// CarbList.swift +// Loop +// +// Created by Pete Schwamb on 9/20/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopAlgorithm + +struct CarbList: View { + @Environment(LoopDataManager.self) var loopManager + + var timeFormatter: DateFormatter = { + let timeFormatter = DateFormatter() + timeFormatter.dateStyle = .none + timeFormatter.timeStyle = .short + return timeFormatter + }() + + var carbFormatter: QuantityFormatter = { + let formatter = QuantityFormatter(for: .gram) + formatter.numberFormatter.numberStyle = .none + return formatter + }() + + @State var entries: [StoredCarbEntry] = [] + + private func reloadCarbEntries() async { + let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -CarbMath.maximumAbsorptionTimeInterval)) + entries = (try? await loopManager.carbStore.getCarbEntries(start: start)) ?? [] + } + + var activeCarbs: String? { + guard let activeContext = loopManager.activeContext, + let activeCarbohydrates = activeContext.activeCarbohydrates + else { + return nil + } + + return carbFormatter.string(from: activeCarbohydrates) + } + + var totalCarbs: String? { + let total = entries.reduce(0, { sum, entry in + return sum + entry.quantity.doubleValue(for: .gram) + }) + + return carbFormatter.string(from: LoopQuantity(unit: .gram, doubleValue: total)) + } + + var body: some View { + List { + Section { + ForEach(entries, id: \.self) { entry in + HStack { + Text(timeFormatter.string(from: entry.startDate)) + Spacer() + Text(carbFormatter.string(from: entry.quantity) ?? "-") + } + } + } header: { + VStack { + HStack { + Text("Active Carbs") + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Text(activeCarbs ?? "-") + .font(.title3) + .foregroundStyle(.primary) + } + HStack { + Text("Total Carbs") + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Text(totalCarbs ?? "-") + .font(.title3) + .foregroundStyle(.primary) + } + .padding(.bottom, 4) + } + } + } + .onAppear { + Task { + await reloadCarbEntries() + } + } + } +} diff --git a/WatchApp Extension/Views/ChartPageView.swift b/WatchApp Extension/Views/ChartPageView.swift new file mode 100644 index 0000000000..e0781317dc --- /dev/null +++ b/WatchApp Extension/Views/ChartPageView.swift @@ -0,0 +1,182 @@ +// +// ChartPageView.swift +// Loop +// +// Created by Pete Schwamb on 9/19/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore +import SpriteKit + +struct ChartPageView: View { + @Environment(\.sizeClass) private var sizeClass + + @Environment(LoopDataManager.self) var loopManager + + @State private var isShowingCarbList: Bool = false + + var presetActive: Bool { + return loopManager.watchInfo.scheduleOverride?.isActive() == true + } + + private var chartHeight: CGFloat { + switch sizeClass { + case .size38mm: + return 73 + case .size44mm: + return 111 + case .size45mm: + return 115 + default: + return 90 + } + } + + var chartView: some View { + SpriteView(scene: loopManager.glucoseChartScene) + .frame(height: chartHeight) + .ignoresSafeArea() + .gesture( + // Handle double tap + TapGesture(count: 2) + .onEnded { + loopManager.glucoseChartScene.increaseVisibleDuration() + } + ) + .gesture( + // Handle single tap + TapGesture() + .onEnded { + loopManager.glucoseChartScene.decreaseVisibleDuration() + } + ) + } + + var activeInsulin: String? { + guard let activeContext = loopManager.activeContext, + let activeInsulin = activeContext.activeInsulin + else { + return nil + } + + let insulinFormatter: QuantityFormatter = { + let insulinFormatter = QuantityFormatter(for: .internationalUnit) + insulinFormatter.numberFormatter.minimumFractionDigits = 1 + insulinFormatter.numberFormatter.maximumFractionDigits = 1 + + return insulinFormatter + }() + + return insulinFormatter.string(from: activeInsulin) + } + + var activeCarbohydrates: String? { + guard let activeContext = loopManager.activeContext, + let activeCarbohydrates = activeContext.activeCarbohydrates + else { + return nil + } + + let carbFormatter = QuantityFormatter(for: .gram) + carbFormatter.numberFormatter.maximumFractionDigits = 0 + + return carbFormatter.string(from: activeCarbohydrates) + } + + var netTempBasalDose: String? { + guard let activeContext = loopManager.activeContext, + let tempBasal = activeContext.lastNetTempBasalDose + else { + return nil + } + + let basalFormatter = NumberFormatter() + basalFormatter.numberStyle = .decimal + basalFormatter.minimumFractionDigits = 1 + basalFormatter.maximumFractionDigits = 3 + basalFormatter.positivePrefix = basalFormatter.plusSign + + let unit = NSLocalizedString( + "U/hr", + comment: "The short unit display string for international units of insulin delivery per hour" + ) + + return basalFormatter.string(from: tempBasal, unit: unit) + } + + var reservoirVolume: String? { + guard let activeContext = loopManager.activeContext, + let reservoirVolume = activeContext.reservoirVolume + else { + return nil + } + + let insulinFormatter: QuantityFormatter = { + let insulinFormatter = QuantityFormatter(for: .internationalUnit) + insulinFormatter.unitStyle = .long + insulinFormatter.numberFormatter.minimumFractionDigits = 0 + insulinFormatter.numberFormatter.maximumFractionDigits = 0 + + return insulinFormatter + }() + + return insulinFormatter.string(from: reservoirVolume) + } + + + var body: some View { + ScrollView(.vertical) { + LoopHeader() + chartView + + VStack { + LabelValueRow( + label: "Active Insulin", + value: activeInsulin + ) + Divider() + LabelValueRow( + label: "Active Carbs", + value: activeCarbohydrates + ) + .onTapGesture { + isShowingCarbList = true + } + Divider() + LabelValueRow( + label: "Net Basal Rate", + value: netTempBasalDose + ) + Divider() + LabelValueRow( + label: "Reservoir Volume", + value: reservoirVolume + ) + } + .padding(.horizontal) + } + .font(.system(size: 14, weight: .light)) + .toolbar(.hidden, for: .navigationBar) + .environment(\.glucoseDisplayUnit, loopManager.displayGlucoseUnit) + .onAppear() { + updateGlucoseChart() + } + .onChange(of: loopManager.activeContext?.predictedGlucose) { oldValue, newValue in + updateGlucoseChart() + } + .sheet(isPresented: $isShowingCarbList) { + CarbList() + } + } + + private func updateGlucoseChart() { + Task { @MainActor in + let chartData = await loopManager.generateChartData() + loopManager.glucoseChartScene.data = chartData + loopManager.glucoseChartScene.setNeedsUpdate() + } + } +} diff --git a/WatchApp Extension/Views/CircleTintedButton.swift b/WatchApp Extension/Views/CircleTintedButton.swift new file mode 100644 index 0000000000..3ff29340ee --- /dev/null +++ b/WatchApp Extension/Views/CircleTintedButton.swift @@ -0,0 +1,66 @@ +// +// CircleTintedButton.swift +// Loop +// +// Created by Pete Schwamb on 9/8/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct CircleTintedButton: View { + @Environment(\.sizeClass) private var sizeClass + + var label: String + var image: Image + var foregroundTint: Color + var backgroundTint: Color + var action: () -> Void + + var buttonSize: CGFloat { + if sizeClass.isLarge { + return 64 + } else if sizeClass.isSmall { + return 54 + } else { + return 60 + } + } + + var body: some View { + ZStack { + VStack { + Button(action: action, label: { + image.foregroundStyle(foregroundTint) + }) + .buttonStyle(CircleTintedButtonStyle(tint: backgroundTint)) + .frame(height: buttonSize) + Text(label) + } + } + .frame(maxWidth: .infinity) + } +} + +private struct CircleTintedButtonStyle: ButtonStyle { + var tint: Color + @Environment(\.sizeClass) private var sizeClass + + func makeBody(configuration: Configuration) -> some View { + backgroundShape + .padding(.horizontal, sizeClass.hasRoundedCorners ? 4 : 0) + .overlay(configuration.label) + .padding(configuration.isPressed ? 1 : 0) + .overlay(Color.black.opacity(configuration.isPressed ? 0.35 : 0)) + } + + private var backgroundShape: some View { + Group { + if sizeClass.hasRoundedCorners { + Circle().fill(tint) + } else { + RoundedRectangle(cornerRadius: 6).fill(tint) + } + } + } +} diff --git a/WatchApp Extension/Views/CircularProgressWithCheckmark.swift b/WatchApp Extension/Views/CircularProgressWithCheckmark.swift new file mode 100644 index 0000000000..68cdea1ae9 --- /dev/null +++ b/WatchApp Extension/Views/CircularProgressWithCheckmark.swift @@ -0,0 +1,36 @@ +// +// CircularProgressWithCheckmark.swift +// Loop +// +// Created by Pete Schwamb on 9/23/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +import SwiftUI + +struct CircularProgressWithCheckmark: View { + let progress: CGFloat + let isComplete: Bool + + var body: some View { + ZStack { + // Background circle (full gray ring) + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 8) + + // Progress arc (blue accent color) + Circle() + .trim(from: 0, to: min(progress, 1.0)) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round)) + .rotationEffect(.degrees(-90)) // Start from top + + // Checkmark icon + Image(systemName: "checkmark") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + .opacity(isComplete ? 1 : 0) + } + .frame(width: 45, height: 45) + } +} diff --git a/WatchApp Extension/Views/Extensions/Color.swift b/WatchApp Extension/Views/Extensions/Color.swift index 4eb24bc4ff..929b0105fe 100644 --- a/WatchApp Extension/Views/Extensions/Color.swift +++ b/WatchApp Extension/Views/Extensions/Color.swift @@ -16,5 +16,12 @@ extension Color { static let insulin = Color(.insulin) static let darkInsulin = Color(.darkInsulin) + static let presets = Color(.presets) + static let darkPresets = Color(.darkPresets) + + static let fresh = Color(.fresh) + static let aging = Color(.agingColor) + static let stale = Color(.staleColor) + static let defaultWatchButtonGray = Color(white: 35 / 255) } diff --git a/WatchApp Extension/Views/Extensions/Environment+SizeClass.swift b/WatchApp Extension/Views/Extensions/Environment+SizeClass.swift index 1ee3331718..fd74d4771e 100644 --- a/WatchApp Extension/Views/Extensions/Environment+SizeClass.swift +++ b/WatchApp Extension/Views/Extensions/Environment+SizeClass.swift @@ -35,6 +35,12 @@ extension WKInterfaceDevice { // Apple Watch Series 7 case size41mm case size45mm + + // Apple Watch Series 10 + case size46mm + + // Apple Watch Ultra + case size49mm } var sizeClass: SizeClass { @@ -71,15 +77,43 @@ extension WKInterfaceDevice.SizeClass { return CGSize(width: 184, height: 224) case .size45mm: return CGSize(width: 198, height: 242) + case .size46mm: // For some reason, the Series 10 sim is showing different (208x248) + return CGSize(width: 200, height: 244) + case .size49mm: + return CGSize(width: 205, height: 251) } } var hasRoundedCorners: Bool { switch self { - case .size40mm, .size41mm, .size44mm, .size45mm: + case .size40mm, .size41mm, .size44mm, .size45mm, .size46mm, .size49mm: return true case .size38mm, .size42mm: return false } } + + var isSmall: Bool { + switch self { + case .size38mm, .size40mm: return true + default: return false + } + } + + var isLarge: Bool { + switch self { + case .size44mm, .size45mm, .size46mm, .size49mm: return true + default: return false + } + } + + // Recommended horizontal padding per HIG + var recommendedHorizontalPadding: CGFloat { + switch self { + case .size40mm, .size41mm, .size42mm: return 8.5 + case .size44mm, .size45mm, .size46mm, .size49mm: return 9.5 + default: return 0 // Older or unknown + } + } + } diff --git a/WatchApp Extension/Views/Extensions/PeriodicPublisher.swift b/WatchApp Extension/Views/Extensions/PeriodicPublisher.swift index da6641d079..be0613d21f 100644 --- a/WatchApp Extension/Views/Extensions/PeriodicPublisher.swift +++ b/WatchApp Extension/Views/Extensions/PeriodicPublisher.swift @@ -7,7 +7,7 @@ // import Combine - +import Foundation /// A publisher which emits a value at a defined interval, which can be delayed via acknowledgment. final class PeriodicPublisher { diff --git a/WatchApp Extension/Views/LabelValueRow.swift b/WatchApp Extension/Views/LabelValueRow.swift new file mode 100644 index 0000000000..d84933ad4d --- /dev/null +++ b/WatchApp Extension/Views/LabelValueRow.swift @@ -0,0 +1,27 @@ +// +// LabelValueRow.swift +// Loop +// +// Created by Pete Schwamb on 9/20/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct LabelValueRow: View { + let label: LocalizedStringKey + let value: String? + + var body: some View { + VStack(alignment: .leading) { + Text(label) + .font(.footnote) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Text(value ?? "–") + .font(.title3) + .foregroundStyle(.primary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/WatchApp Extension/Views/LoopCircleView.swift b/WatchApp Extension/Views/LoopCircleView.swift new file mode 100644 index 0000000000..8c7afdd773 --- /dev/null +++ b/WatchApp Extension/Views/LoopCircleView.swift @@ -0,0 +1,60 @@ +// +// LoopCircleView.swift +// Loop +// +// Created by Pete Schwamb on 9/8/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit + +public struct LoopCircleView: View { + @Environment(\.isEnabled) private var isEnabled + + private let animating: Bool + private let closedLoop: Bool + private let freshness: LoopCompletionFreshness + + public init(closedLoop: Bool, freshness: LoopCompletionFreshness, animating: Bool = false) { + self.closedLoop = closedLoop + self.freshness = freshness + self.animating = animating + } + + private var reversingAnimation: Animation { + if animating && closedLoop { + return .easeInOut(duration: 1).repeatForever(autoreverses: true) + } else { + return .easeInOut(duration: 1) + } + } + + public var body: some View { + GeometryReader { geometry in + Circle() + .trim(from: closedLoop ? 0 : 0.2, to: 1) + .stroke(loopColor, lineWidth: geometry.size.height / 5) + .rotationEffect(Angle(degrees: closedLoop ? -90 : -126)) + .animation(.none, value: freshness) + .animation(.default, value: closedLoop) + .scaleEffect(animating && closedLoop ? 0.75 : 1) + .animation(reversingAnimation, value: UUID()) + } + } + + private var loopColor: Color { + if !isEnabled { + return .defaultWatchButtonGray + } else { + switch freshness { + case .fresh: + return .fresh + case .aging: + return .aging + case .stale: + return .stale + } + } + } +} diff --git a/WatchApp Extension/Views/LoopHeader.swift b/WatchApp Extension/Views/LoopHeader.swift new file mode 100644 index 0000000000..3c0e1a85d7 --- /dev/null +++ b/WatchApp Extension/Views/LoopHeader.swift @@ -0,0 +1,43 @@ +// +// Untitled.swift +// Loop +// +// Created by Pete Schwamb on 9/20/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct LoopHeader: View { + @Environment(LoopDataManager.self) var loopManager + + var freshness: LoopCompletionFreshness { + return LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.loopLastRunDate, at: Date()) + } + + var body: some View { + HStack { + if let activeContext = loopManager.activeContext, + let unit = activeContext.displayGlucoseUnit + { + LoopCircleView(closedLoop: activeContext.isClosedLoop ?? false, freshness: freshness) + .frame(width: 22, height: 22) + .padding(.horizontal) + + Text(loopManager.glucoseValue) + + Spacer() + + if FeatureFlags.showEventualBloodGlucoseOnWatchEnabled, + let eventualGlucose = activeContext.eventualGlucose, + let eventualGlucoseValue = NumberFormatter.glucoseFormatter(for: unit).string(from: eventualGlucose.doubleValue(for: unit)) + { + Text(eventualGlucoseValue) + } + } + } + .font(.system(size: 24, weight: .light)) + } +} diff --git a/WatchApp Extension/Views/OnOffSelectionView.swift b/WatchApp Extension/Views/OnOffSelectionView.swift deleted file mode 100644 index b6bf586045..0000000000 --- a/WatchApp Extension/Views/OnOffSelectionView.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// OnOffSelectionView.swift -// WatchApp Extension -// -// Created by Anna Quinlan on 8/20/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -struct OnOffSelectionView: View { - // MARK: - Initialization - var viewModel: OnOffSelectionViewModel - - // MARK: - View Tree - - var body: some View { - VStack { - Spacer() - titleStack - Spacer() - if viewModel.selectedButton == .on { - buttonStackWithOnSelected - } else if viewModel.selectedButton == .off { - buttonStackWithOffSelected - } - } - } - - var titleStack: some View { - VStack(spacing: 2) { - Text(viewModel.title) - Text(viewModel.message) - } - } - - var buttonStackWithOnSelected: some View { - VStack(spacing: 5) { - onButton - .background(Color(viewModel.selectedButtonTint).cornerRadius(20.0)) - offButton - } - } - - var buttonStackWithOffSelected: some View { - VStack(spacing: 5) { - onButton - offButton - .background(Color(viewModel.selectedButtonTint).cornerRadius(20.0)) - } - } - - var onButton: some View { - Button(action: { - self.viewModel.onSelection(true) - self.viewModel.dismiss?() - }) { - Text("On", comment: "Label for on button") - } - .cornerRadius(20) - } - - var offButton: some View { - Button(action: { - self.viewModel.onSelection(false) - self.viewModel.dismiss?() - }) { - Text("Off", comment: "Label for off button") - } - .cornerRadius(20) - } -} - -struct OnOffSelectionView_Previews: PreviewProvider { - static var previews: some View { - Group { - OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Pre-Meal", message: "80-90 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .on, selectedButtonTint: .carbsColor)) - .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 2 - 38mm")) - - OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Pre-Meal", message: "80-90 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .off, selectedButtonTint: .carbsColor)) - .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 2 - 42mm")) - } - } -} diff --git a/WatchApp Extension/Views/PresetActivateButtonConfirm.swift b/WatchApp Extension/Views/PresetActivateButtonConfirm.swift new file mode 100644 index 0000000000..fe5c72b48f --- /dev/null +++ b/WatchApp Extension/Views/PresetActivateButtonConfirm.swift @@ -0,0 +1,46 @@ +// +// PresetActivateExtraConfirm.swift +// Loop +// +// Created by Pete Schwamb on 9/23/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct PresetActivateButtonConfirm: View { + @Environment(\.sizeClass) private var sizeClass + + let preset: SelectablePreset + @Binding var confirmed: Bool + + private var actionButtonOffsetY: CGFloat { + switch sizeClass { + case .size38mm, .size42mm: + return 0 + case .size40mm, .size41mm: + return 20 + case .size44mm, .size45mm, .size46mm, .size49mm: + return 27 + } + } + + var body: some View { + VStack { + PresetDetailView(preset: preset) + + Spacer() + + ActionButton( + title: Text("Start Preset", comment: "Button text to confirm starting preset before using digital crown"), + color: .accentColor + ) { + confirmed = true + } + + } + .padding() + } +} diff --git a/WatchApp Extension/Views/PresetActivateCrownConfirm.swift b/WatchApp Extension/Views/PresetActivateCrownConfirm.swift new file mode 100644 index 0000000000..eb870cdacc --- /dev/null +++ b/WatchApp Extension/Views/PresetActivateCrownConfirm.swift @@ -0,0 +1,109 @@ +// +// PresetActivateCrownConfirm.swift +// Loop +// +// Created by Pete Schwamb on 9/23/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct PresetActivateCrownConfirm: View { + @Environment(LoopDataManager.self) var loopManager + @Environment(\.sizeClass) private var sizeClass + + @State private var crownValue: CGFloat = 0 // Tracks Digital Crown rotation + @State private var startingPreset: Bool = false + @State private var lastInteractionTime: Date? // Tracks last crown interaction + + private let threshold: CGFloat = 20 // Rotation threshold to trigger action + private let maxProgress: CGFloat = 20 // Max progress for the bar + private let resetDelay: TimeInterval = 0.25 // pause for reset + + let preset: SelectablePreset + + var progress: CGFloat { + if startingPreset { + return 1 + } else { + return min(crownValue, maxProgress)/threshold + } + } + + var body: some View { + VStack(spacing: 4) { + PresetDetailView(preset: preset) + + Spacer() + + ZStack(alignment: .center) { + if progress == 0 { + Text(startingPreset ? "Starting Preset..." : "Turn Digital Crown to Start") + .font(.system(size: 16)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } else { + CircularProgressWithCheckmark(progress: progress, isComplete: startingPreset) + .animation(.easeInOut(duration: 0.2), value: crownValue) // Smooth animation for progress + .animation(.easeOut(duration: 0.3), value: startingPreset) // Fast animation for completion + } + } + + } + .padding() + .focusable() // Required for Digital Crown interaction + .digitalCrownRotation( + $crownValue, + from: 0, + through: threshold, + by: 1, + sensitivity: .medium, + isContinuous: false + ) + .onDisappear() { + if let reminder = loopManager.pendingPresetReminder, reminder.presetIdentifier == preset.id { + // If this was shown for confirming preset activation from a reminder notification, and we + // are being dismissed, treat the dismissal as an acknowledgement + loopManager.pendingPresetReminder = nil + Task { + try await loopManager.acknowledgeAlert(alertIdentifier: reminder.alertIdentifier, managerIdentifier: reminder.managerIdentifier) + } + } + } + .onChange(of: crownValue) { (oldValue, newValue) in + lastInteractionTime = Date() + + Task { + try? await Task.sleep(nanoseconds: UInt64(resetDelay * 1_000_000_000)) // Wait for 1 second + if let lastTime = lastInteractionTime, Date().timeIntervalSince(lastTime) >= resetDelay && !startingPreset { + withAnimation { + crownValue = 0 // Reset progress + } + } + } + + if newValue >= threshold && !startingPreset { + withAnimation(.spring(response: 0.2, dampingFraction: 0.5)) { + startingPreset = true + Task { + do { + var alertIdentifier: String? = nil + // If we're starting the preset from a reminder alert, then set alert identifier to acknowledge the alert + if let reminder = loopManager.pendingPresetReminder, reminder.presetIdentifier == preset.id { + alertIdentifier = reminder.presetIdentifier + } + try await loopManager.activateOverride(preset.createOverride(), alertIdentifierToAcknowledge: alertIdentifier) + WKInterfaceDevice.current().play(.success) + } catch { + print("Error! Could not activate preset: \(error)") + WKInterfaceDevice.current().play(.failure) + } + } + } + } + } + .navigationBarBackButtonHidden(false) // Ensure back button is visible + } +} diff --git a/WatchApp Extension/Views/PresetConfirmationView.swift b/WatchApp Extension/Views/PresetConfirmationView.swift new file mode 100644 index 0000000000..e41bc91e39 --- /dev/null +++ b/WatchApp Extension/Views/PresetConfirmationView.swift @@ -0,0 +1,88 @@ +// +// PresetConfirmationView.swift +// Loop +// +// Created by Pete Schwamb on 9/22/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct PresetConfirmationView: View { + @Environment(LoopDataManager.self) var loopManager + @Environment(\.dismiss) private var dismiss + + let preset: SelectablePreset? + + @State private var confirmedViaButton: Bool = false + + enum DisplayState: Equatable { + case confirmingViaButton(SelectablePreset) + case confirmingViaCrown(SelectablePreset) + case activated(TemporaryScheduleOverride) + case oneTimeUseOverrideEnded + } + + var displayState: DisplayState { + if let override = loopManager.watchInfo.scheduleOverride { + return .activated(override) + } else if let preset { + if isConfirmingFromPresetReminder && !confirmedViaButton { + return .confirmingViaButton(preset) + } else { + return .confirmingViaCrown(preset) + } + } else { + return .oneTimeUseOverrideEnded + } + } + + var isConfirmingFromPresetReminder: Bool { + if let reminder = loopManager.pendingPresetReminder, + let preset, + reminder.presetIdentifier == preset.id + { + return true + } + return false + } + + var body: some View { + ZStack { + switch displayState { + case .confirmingViaButton(let preset): + PresetActivateButtonConfirm(preset: preset, confirmed: $confirmedViaButton) + case .confirmingViaCrown(let preset): + PresetActivateCrownConfirm(preset: preset) + case .activated(let override): + ActiveOverrideView(override: override) + case .oneTimeUseOverrideEnded: + // Should not display, as we will dismiss below + Text("One-time use override has ended.") + } + } + .onDisappear { + if isConfirmingFromPresetReminder { + // Treat exiting reminder confirmation as declining + self.loopManager.pendingPresetReminder = nil + } + } + .onChange(of: displayState, { oldValue, newValue in + if case .confirmingViaCrown = oldValue, + case .activated = newValue, + isConfirmingFromPresetReminder + { + // Successfully activated; clear reminder state + self.loopManager.pendingPresetReminder = nil + } + }) + .onChange(of: loopManager.watchInfo.scheduleOverride) { oldValue, newVelue in + if oldValue != nil, newVelue == nil, preset == nil + { + dismiss() + } + } + } +} diff --git a/WatchApp Extension/Views/PresetDetailView.swift b/WatchApp Extension/Views/PresetDetailView.swift new file mode 100644 index 0000000000..e9b9e7dcb0 --- /dev/null +++ b/WatchApp Extension/Views/PresetDetailView.swift @@ -0,0 +1,67 @@ +// +// PresetDetailView.swift +// Loop +// +// Created by Pete Schwamb on 9/9/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct PresetDetailView: View { + @Environment(\.glucoseDisplayUnit) private var glucoseDisplayUnit + + let preset: SelectablePreset + + var presetTitle: some View { + HStack(spacing: 6) { + Text(preset.name) + .font(.title3) + .accessibilityIdentifier("text_Preset\(preset.name)") + } + } + + private var numberFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + return formatter + } + + private var glucoseFormatter: QuantityFormatter { + return QuantityFormatter(for: glucoseDisplayUnit) + } + + var presetDuration: some View { + Group { Text(Image(systemName: "timer")) + Text(" \(preset.duration.localizedTitle)") } + .font(.footnote) + .foregroundColor(.secondary) + .accessibilityLabel(Text(preset.duration.accessibilityLabel)) + } + + var descriptionText: Text { + let percent = numberFormatter.string(from: preset.insulinNeedsScaleFactor)! + var text = Text(percent).bold() + + if let correctionRange = preset.correctionRange { + text = text + Text(" • ") + text = text + (Text(glucoseFormatter.string(from: correctionRange.lowerBound, includeUnit: false)!) + + Text("-") + + Text(glucoseFormatter.string(from: correctionRange.upperBound, includeUnit: false)!)).bold() + text = text + Text(" " + glucoseDisplayUnit.localizedShortUnitString) + .foregroundStyle(.secondary) + } + return text + } + + var body: some View { + VStack(spacing: 4) { + presetTitle + presetDuration + descriptionText + .padding(.top, 8) + .padding(.bottom, 10) + } + } +} diff --git a/WatchApp Extension/Views/PresetWatchCard.swift b/WatchApp Extension/Views/PresetWatchCard.swift new file mode 100644 index 0000000000..980fb9de74 --- /dev/null +++ b/WatchApp Extension/Views/PresetWatchCard.swift @@ -0,0 +1,141 @@ +// +// PresetWatchCard.swift +// Loop +// +// Created by Pete Schwamb on 9/9/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import SwiftUI +import LoopKit +import LoopCore + +extension Color { + init(presetSymbolTint: PresetSymbol.SymbolTint?) { + guard let presetSymbolTint else { + self = .primary + return + } + + switch presetSymbolTint { + case .preMeal: + self = Color.carbs + } + } +} + +struct PresetSymbolView: View { + + let symbol: PresetSymbol + let iconSize: Double + + init(_ symbol: PresetSymbol, iconSize: Double = 17) { + self.symbol = symbol + self.iconSize = iconSize + } + + var body: some View { + Group { + switch symbol.symbolType { + case .emoji: + Text(symbol.value) + .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize - 2))) + case .image: + Text(Image(symbol.value)) + .foregroundStyle(Color(presetSymbolTint: symbol.tint)) + .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize))) + case .systemImage: + Text(Image(systemName: symbol.value)) + .foregroundStyle(Color(presetSymbolTint: symbol.tint)) + .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize))) + } + } + .fontDesign(.monospaced) + } +} + + +struct PresetWatchCard: View { + + @Environment(\.isEnabled) private var isEnabled + @Environment(\.glucoseDisplayUnit) private var glucoseDisplayUnit + + let presetId: String + let icon: PresetSymbol? + let presetName: String + let duration: PresetDuration + let insulinMultiplier: Double? + let correctionRange: ClosedRange? + let isScheduled: Bool + + private var numberFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + return formatter + } + + private var glucoseFormatter: QuantityFormatter { + return QuantityFormatter(for: glucoseDisplayUnit) + } + + + var presetTitle: some View { + HStack(spacing: 6) { + if let icon, !icon.isEmpty { + PresetSymbolView(icon) + } + Text(presetName) + .accessibilityIdentifier("text_Preset\(presetName)") + } + } + + var presetDuration: some View { + Group { Text(Image(systemName: "timer")) + Text(" \(duration.localizedTitle)") } + .font(.footnote) + .foregroundColor(.secondary) + .accessibilityLabel(Text(duration.accessibilityLabel)) + } + + var descriptionText: Text { + let percent = numberFormatter.string(from: insulinMultiplier ?? 1)! + var text = Text(percent).bold() + + if let correctionRange { + text = text + Text(" • ") + text = text + (Text(glucoseFormatter.string(from: correctionRange.lowerBound, includeUnit: false)!) + + Text("-") + + Text(glucoseFormatter.string(from: correctionRange.upperBound, includeUnit: false)!)).bold() + text = text + Text(" " + glucoseDisplayUnit.localizedShortUnitString) + .foregroundStyle(.secondary) + } + return text.font(.footnote) + + } + + var body: some View { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 12) + .foregroundColor(Color(red: 0.2, green: 0.2, blue: 0.2)) + VStack(alignment: .leading, spacing: 10) { + presetTitle + descriptionText + } + .padding(10) + } + } +} + +extension PresetWatchCard { + init (_ preset: SelectablePreset) { + self.init( + presetId: preset.id, + icon: preset.icon, + presetName: preset.name, + duration: preset.duration, + insulinMultiplier: preset.insulinNeedsScaleFactor, + correctionRange: preset.correctionRange, + isScheduled: preset.isScheduled + ) + } +} diff --git a/WatchApp Extension/Views/PresetsListView.swift b/WatchApp Extension/Views/PresetsListView.swift new file mode 100644 index 0000000000..21f684f130 --- /dev/null +++ b/WatchApp Extension/Views/PresetsListView.swift @@ -0,0 +1,35 @@ +// +// PresetsList.swift +// Loop +// +// Created by Pete Schwamb on 9/9/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct PresetListView: View { + @Environment(LoopDataManager.self) var loopManager + @Environment(\.dismiss) private var dismiss + + let presets: [SelectablePreset] + @Binding var path: NavigationPath + + var body: some View { + ScrollView(.vertical) { + ForEach(presets) { preset in + PresetWatchCard(preset) + .onTapGesture { + path.append(preset) + } + } + .padding() + } + .navigationTitle("Select Preset") + .navigationDestination(for: SelectablePreset.self) { preset in + PresetConfirmationView(preset: preset) + } + } +} diff --git a/WatchApp Extension/Views/PresetsView.swift b/WatchApp Extension/Views/PresetsView.swift new file mode 100644 index 0000000000..af738c2390 --- /dev/null +++ b/WatchApp Extension/Views/PresetsView.swift @@ -0,0 +1,43 @@ +// +// PresetsView.swift +// Loop +// +// Created by Pete Schwamb on 9/22/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopCore + +struct PresetsView: View { + @Environment(LoopDataManager.self) var loopManager + + @State private var path = NavigationPath() + + enum DisplayState: Equatable { + case presetsList + case activeOverride(TemporaryScheduleOverride) + } + + var displayState: DisplayState { + if let override = loopManager.watchInfo.scheduleOverride { + return .activeOverride(override) + } else { + return .presetsList + } + } + + var body: some View { + ZStack { + switch displayState { + case .activeOverride(let override): + PresetConfirmationView(preset: loopManager.selectablePresets.first {$0.id == override.presetId}) + case .presetsList: + NavigationStack(path: $path) { + PresetListView(presets: loopManager.selectablePresets, path: $path) + } + } + } + } +} diff --git a/WatchApp Extension/Views/WatchActionsView.swift b/WatchApp Extension/Views/WatchActionsView.swift new file mode 100644 index 0000000000..369389af6a --- /dev/null +++ b/WatchApp Extension/Views/WatchActionsView.swift @@ -0,0 +1,86 @@ +// +// WatchActionsView.swift +// Loop +// +// Created by Pete Schwamb on 8/15/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +import SwiftUI +import LoopKit +import LoopCore + +struct WatchActionsView: View { + @Environment(LoopDataManager.self) var loopManager + + @State private var isShowingPresets: Bool = false + @State private var overrideToShow: TemporaryScheduleOverride? + + var overrideActive: Bool { + return loopManager.watchInfo.scheduleOverride?.isActive() == true + } + + var body: some View { + ScrollView(.vertical) { + LoopHeader() + + HStack(spacing: 0) { + CircleTintedButton( + label: "Carbs", + image: Image("carbs"), + foregroundTint: .carbs, + backgroundTint: .darkCarbs + ) { + loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .carbEntry(nil)) + } + CircleTintedButton( + label: "Bolus", + image: Image("bolus"), + foregroundTint: .insulin, + backgroundTint: .darkInsulin + ) { + loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .manualBolus) + } + } + .padding(.bottom, 4) + HStack { + CircleTintedButton( + label: "Presets", + image: Image("presets"), + foregroundTint: overrideActive ? .darkPresets : .presets, + backgroundTint: overrideActive ? .presets : .darkPresets + ) { + if overrideActive { + overrideToShow = loopManager.watchInfo.scheduleOverride + } else { + isShowingPresets = true + } + } + Spacer() + .frame(maxWidth: .infinity) + } + } + .font(.system(size: 14, weight: .light)) + .toolbar(.hidden, for: .navigationBar) + .sheet(isPresented: $isShowingPresets) { + PresetsView() + } + .sheet(isPresented: Binding(get: { + overrideToShow != nil + }, set: { + if !$0 { overrideToShow = nil } + })) { + let preset = loopManager.selectablePresets.first(where: { $0.id == overrideToShow!.presetId }) + PresetConfirmationView(preset: preset) + } + .sheet(isPresented:Binding( + get: { loopManager.bolusViewModel != nil }, + set: { if !$0 { loopManager.bolusViewModel = nil } } + )) { + CarbAndBolusFlow(viewModel: loopManager.bolusViewModel!) + } + .environment(\.glucoseDisplayUnit, loopManager.displayGlucoseUnit) + } + +} diff --git a/WatchApp Extension/en.lproj/ckcomplication.strings b/WatchApp Extension/en.lproj/ckcomplication.strings new file mode 100644 index 0000000000..0aa78a1ee4 --- /dev/null +++ b/WatchApp Extension/en.lproj/ckcomplication.strings @@ -0,0 +1,19 @@ +/* + ckcomplication.strings + Loop + + Created by Nate Racklyeft on 9/18/16. + Copyright © 2016 Nathan Racklyeft. All rights reserved. +*/ + +/* The complication template example glucose and trend string */ +"120↘︎" = "120↘︎"; + +/* The complication template example glucose string */ +"120" = "120"; + +/* The complication template example time string */ +"3MIN" = "3MIN"; + +/* Utilitarian large flat format string (1: Glucose & Trend symbol) (2: Eventual Glucose) (3: Time) */ +"UtilitarianLargeFlat" = "%@%@ %@"; diff --git a/WatchApp/Base.lproj/InfoPlist.strings b/WatchApp/Base.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/Base.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/Base.lproj/Interface.storyboard b/WatchApp/Base.lproj/Interface.storyboard deleted file mode 100644 index 03cdd19a1b..0000000000 --- a/WatchApp/Base.lproj/Interface.storyboard +++ /dev/null @@ -1,445 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - - - - - -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - - - -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
- -
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/WatchApp/ContentView.swift b/WatchApp/ContentView.swift new file mode 100644 index 0000000000..fe8d48a328 --- /dev/null +++ b/WatchApp/ContentView.swift @@ -0,0 +1,48 @@ +// +// WatchAppContent.swift +// Loop +// +// Created by Pete Schwamb on 9/21/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopCore + +struct ContentView: View { + @Environment(LoopDataManager.self) var loopManager + + @State private var presetToConfirm: SelectablePreset? = nil + @State private var selectedPage = UserDefaults.standard.startOnChartPage ? 1 : 0 + + var body: some View { + VStack { + // TabView for swipeable pages + TabView(selection: $selectedPage) { + WatchActionsView() + .tag(0) + + ChartPageView() + .tag(1) + } + .tabViewStyle(.page) + .indexViewStyle(.page(backgroundDisplayMode: .automatic)) + } + .onChange(of: loopManager.pendingPresetReminder) { oldValue, newValue in + if oldValue == nil, newValue != nil { + presetToConfirm = loopManager.pendingPreset + } + } + .sheet(item: $presetToConfirm) { preset in + PresetConfirmationView(preset: preset) + } + .onChange(of: selectedPage, { oldValue, newValue in + UserDefaults.standard.startOnChartPage = selectedPage == 1 + }) + } +} + + +#Preview { + ContentView() +} diff --git a/WatchApp/DerivedAssetsBase.xcassets/accent.colorset/Contents.json b/WatchApp/DerivedAssetsBase.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from WatchApp/DerivedAssetsBase.xcassets/accent.colorset/Contents.json rename to WatchApp/DerivedAssetsBase.xcassets/AccentColor.colorset/Contents.json diff --git a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/Contents.json b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/Contents.json index 81ddce690d..1043a80496 100644 --- a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/Contents.json +++ b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/Contents.json @@ -1,92 +1,140 @@ { "images" : [ { - "size" : "24x24", - "idiom" : "watch", "filename" : "Icon-AppleWatch-24x24@2x.png", - "scale" : "2x", + "idiom" : "watch", "role" : "notificationCenter", + "scale" : "2x", + "size" : "24x24", "subtype" : "38mm" }, { - "size" : "27.5x27.5", - "idiom" : "watch", "filename" : "Icon-AppleWatch-27.5x27.5@2x.png", - "scale" : "2x", + "idiom" : "watch", "role" : "notificationCenter", + "scale" : "2x", + "size" : "27.5x27.5", "subtype" : "42mm" }, { - "size" : "29x29", - "idiom" : "watch", "filename" : "Icon-AppleWatch-Companion-29x29@2x.png", + "idiom" : "watch", "role" : "companionSettings", - "scale" : "2x" + "scale" : "2x", + "size" : "29x29" }, { - "size" : "29x29", - "idiom" : "watch", "filename" : "Icon-AppleWatch-Companion-29x29@3x.png", + "idiom" : "watch", "role" : "companionSettings", - "scale" : "3x" + "scale" : "3x", + "size" : "29x29" }, { - "size" : "40x40", + "filename" : "icon-watchos-33x33@2x.png", "idiom" : "watch", - "filename" : "Icon-AppleWatch-40x40@2x.png", + "role" : "notificationCenter", "scale" : "2x", + "size" : "33x33", + "subtype" : "45mm" + }, + { + "filename" : "Icon-AppleWatch-40x40@2x.png", + "idiom" : "watch", "role" : "appLauncher", + "scale" : "2x", + "size" : "40x40", "subtype" : "38mm" }, { - "size" : "44x44", - "idiom" : "watch", "filename" : "Icon-AppleWatch-44x44@2x.png", - "scale" : "2x", + "idiom" : "watch", "role" : "appLauncher", + "scale" : "2x", + "size" : "44x44", "subtype" : "40mm" }, { - "size" : "50x50", + "filename" : "icon-watchos-46x46@2x.png", "idiom" : "watch", - "filename" : "Icon-AppleWatch-100x100@2x.png", + "role" : "appLauncher", "scale" : "2x", + "size" : "46x46", + "subtype" : "41mm" + }, + { + "filename" : "Icon-AppleWatch-100x100@2x.png", + "idiom" : "watch", "role" : "appLauncher", + "scale" : "2x", + "size" : "50x50", "subtype" : "44mm" }, { - "size" : "86x86", + "filename" : "icon-watchos-51x51@2x.png", "idiom" : "watch", - "filename" : "Icon-AppleWatch-86x86@2x.png", + "role" : "appLauncher", + "scale" : "2x", + "size" : "51x51", + "subtype" : "45mm" + }, + { + "filename" : "icon-watchos-54x54@2x.png", + "idiom" : "watch", + "role" : "appLauncher", "scale" : "2x", + "size" : "54x54", + "subtype" : "49mm" + }, + { + "filename" : "Icon-AppleWatch-86x86@2x.png", + "idiom" : "watch", "role" : "quickLook", + "scale" : "2x", + "size" : "86x86", "subtype" : "38mm" }, { - "size" : "98x98", - "idiom" : "watch", "filename" : "Icon-AppleWatch-98x98@2x.png", - "scale" : "2x", + "idiom" : "watch", "role" : "quickLook", + "scale" : "2x", + "size" : "98x98", "subtype" : "42mm" }, { + "filename" : "Icon-AppleWatch-216x216@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", "size" : "108x108", + "subtype" : "44mm" + }, + { + "filename" : "icon-watchos-117x117@2x.png", "idiom" : "watch", - "filename" : "Icon-AppleWatch-216x216@2x.png", + "role" : "quickLook", "scale" : "2x", + "size" : "117x117", + "subtype" : "45mm" + }, + { + "filename" : "icon-watchos-129x129@2x.png", + "idiom" : "watch", "role" : "quickLook", - "subtype" : "44mm" + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" }, { - "size" : "1024x1024", - "idiom" : "watch-marketing", "filename" : "Icon-App-Store-1024.png", - "scale" : "1x" + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-117x117@2x.png b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-117x117@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..3d7e70c949589d2f0a14edc4aae0c05da1c56a3a GIT binary patch literal 22468 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 literal 0 HcmV?d00001 diff --git a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-129x129@2x.png b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-129x129@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9a4e6db9762cdf67b99591377adfeaf965f6dd7 GIT binary patch literal 19963 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 o=@X5YMP%eVZ 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 literal 0 HcmV?d00001 diff --git a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-51x51@2x.png b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-51x51@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..032b0f6da83b6e11a64c882ea66bbfb7bba80d32 GIT binary patch literal 6674 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 literal 0 HcmV?d00001 diff --git a/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-54x54@2x.png b/WatchApp/DerivedAssetsBase.xcassets/AppIcon.appiconset/icon-watchos-54x54@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..5d70b71603db9841c227d28e0a628478456c82e1 GIT binary patch literal 7146 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 literal 0 HcmV?d00001 diff --git a/WatchApp/DerivedAssetsBase.xcassets/Contents.json b/WatchApp/DerivedAssetsBase.xcassets/Contents.json index da4a164c91..73c00596a7 100644 --- a/WatchApp/DerivedAssetsBase.xcassets/Contents.json +++ b/WatchApp/DerivedAssetsBase.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/WatchApp/Info.plist b/WatchApp/Info.plist index 70d55802d0..2cd2620f9e 100644 --- a/WatchApp/Info.plist +++ b/WatchApp/Info.plist @@ -22,6 +22,12 @@ ???? CFBundleVersion $(CURRENT_PROJECT_VERSION) + CLKComplicationPrincipalClass + $(PRODUCT_MODULE_NAME).ComplicationController + NSHealthShareUsageDescription + Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake. + NSHealthUpdateUsageDescription + Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit. NSUserActivityTypes com.loopkit.Loop.AddCarbEntryOnWatch @@ -31,9 +37,13 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + WKApplication + WKCompanionAppBundleIdentifier $(MAIN_APP_BUNDLE_IDENTIFIER) - WKWatchKitApp - + CFBundleIconName + AppIcon + WKExtensionDelegateClassName + $(PRODUCT_MODULE_NAME).ExtensionDelegate diff --git a/WatchApp/LoopWatchApp.swift b/WatchApp/LoopWatchApp.swift new file mode 100644 index 0000000000..daf3e10284 --- /dev/null +++ b/WatchApp/LoopWatchApp.swift @@ -0,0 +1,22 @@ +// +// LoopWatchApp.swift +// Loop +// +// Created by Pete Schwamb on 9/21/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// +import SwiftUI + +@main +struct LoopWatchApp: App { + @WKApplicationDelegateAdaptor(ExtensionDelegate.self) private var appDelegate + + var loopManager = LoopDataManager.shared + + var body: some Scene { + WindowGroup { + ContentView() + .environment(loopManager) + } + } +} diff --git a/WatchApp/ar.lproj/InfoPlist.strings b/WatchApp/ar.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/ar.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/ar.lproj/Interface.strings b/WatchApp/ar.lproj/Interface.strings deleted file mode 100644 index 24928b657b..0000000000 --- a/WatchApp/ar.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "TOTAL CARBS"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Pre-Meal"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Carbs"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus Failed"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Running"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TITLE"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Override"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Label"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "ACTIVE CARBS"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Label"; - diff --git a/WatchApp/cs.lproj/Interface.strings b/WatchApp/cs.lproj/Interface.strings deleted file mode 100644 index 3a926b93b6..0000000000 --- a/WatchApp/cs.lproj/Interface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - diff --git a/WatchApp/da.lproj/InfoPlist.strings b/WatchApp/da.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/da.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/da.lproj/Interface.strings b/WatchApp/da.lproj/Interface.strings deleted file mode 100644 index 751049d309..0000000000 --- a/WatchApp/da.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "TOTALE KULHYDRATER"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Før-måltid"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Kulhydrater"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus fejlede"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Kører"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TITEL"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Override"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Etiket"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "AKTIVE KULHYDRATER"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Etiket"; - diff --git a/WatchApp/de.lproj/InfoPlist.strings b/WatchApp/de.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/de.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/de.lproj/Interface.strings b/WatchApp/de.lproj/Interface.strings deleted file mode 100644 index e2bd9d8688..0000000000 --- a/WatchApp/de.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "GESAMT KH"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Vor dem Essen"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "KH"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus fehlgeschlagen"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Laufen"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TITEL"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Voreinstellung"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Beschriftung"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "AKTIVE KH"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Beschriftung"; - diff --git a/WatchApp/en.lproj/Interface.strings b/WatchApp/en.lproj/Interface.strings deleted file mode 100644 index de919648ad..0000000000 --- a/WatchApp/en.lproj/Interface.strings +++ /dev/null @@ -1,15 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "---"; - diff --git a/WatchApp/es.lproj/InfoPlist.strings b/WatchApp/es.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/es.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/es.lproj/Interface.strings b/WatchApp/es.lproj/Interface.strings deleted file mode 100644 index 5611ae9b58..0000000000 --- a/WatchApp/es.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "CARBS"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Pre-Comida"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Carbohidratos"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolo Falló"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Correr"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TÍTULO"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Sobreescritura"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolo"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Etiqueta"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "CARBS ACTIVOS"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Etiqueta"; - diff --git a/WatchApp/fi.lproj/InfoPlist.strings b/WatchApp/fi.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/fi.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/fi.lproj/Interface.strings b/WatchApp/fi.lproj/Interface.strings deleted file mode 100644 index 5fb2f12a56..0000000000 --- a/WatchApp/fi.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "HIILARI YHT."; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Ennen ateriaa"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Hiilihydraatit"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus epäonnistui"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Juoksu"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "OTSIKKO"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Tilapäisas."; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Nimiö"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "AKT. HIILARI"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Nimiö"; - diff --git a/WatchApp/fr.lproj/InfoPlist.strings b/WatchApp/fr.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/fr.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/fr.lproj/Interface.strings b/WatchApp/fr.lproj/Interface.strings deleted file mode 100644 index b05a7fb86a..0000000000 --- a/WatchApp/fr.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "GLUCIDES TOTAUX"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Pré-Repas"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Glucides"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Échec du bolus"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "En marche"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TITLE"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Ajustement"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Label"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "GLUCIDES ACTIFS"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Label"; - diff --git a/WatchApp/he.lproj/InfoPlist.strings b/WatchApp/he.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/he.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/he.lproj/Interface.strings b/WatchApp/he.lproj/Interface.strings deleted file mode 100644 index cc20662d50..0000000000 --- a/WatchApp/he.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "-"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "TOTAL CARBS"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Pre-Meal"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Carbs"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "-"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus Failed"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Running"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "-"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TITLE"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Override"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Label"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "ACTIVE CARBS"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Label"; - diff --git a/WatchApp/hi.lproj/Interface.strings b/WatchApp/hi.lproj/Interface.strings deleted file mode 100644 index 3a926b93b6..0000000000 --- a/WatchApp/hi.lproj/Interface.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - diff --git a/WatchApp/it.lproj/InfoPlist.strings b/WatchApp/it.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/it.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/it.lproj/Interface.strings b/WatchApp/it.lproj/Interface.strings deleted file mode 100644 index e7f8c1ef97..0000000000 --- a/WatchApp/it.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "TOTALE CARBOIDRATI"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Pre-Pasto"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Carb."; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolo Fallito"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Corsa"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TITOLO"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Programma Alternativo"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolo"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Etichetta"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "CARBOIDRATI ATTIVI"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Etichetta"; - diff --git a/WatchApp/ja.lproj/InfoPlist.strings b/WatchApp/ja.lproj/InfoPlist.strings deleted file mode 100644 index de9898d5ad..0000000000 --- a/WatchApp/ja.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "ループ"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/ja.lproj/Interface.strings b/WatchApp/ja.lproj/Interface.strings deleted file mode 100644 index fa66ec3dd0..0000000000 --- a/WatchApp/ja.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "カーボ合計"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "食前"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "カーボ"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "ボーラス不成功"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "動作中"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "タイトル"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "オーバーライド"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "ループ"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "ボーラス"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "ラベル"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "ループ"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "残存糖質"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "ラベル"; - diff --git a/WatchApp/nb.lproj/InfoPlist.strings b/WatchApp/nb.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/nb.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/nb.lproj/Interface.strings b/WatchApp/nb.lproj/Interface.strings deleted file mode 100644 index 92d8e5a056..0000000000 --- a/WatchApp/nb.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "KARBOHYDRATER TOTALT"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Pre-måltid"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Karbohydrater"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus feilet"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Løper"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TITTEL"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Overstyr"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Etikett"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "AKTIVE KARBOHYDRATER"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Etikett"; - diff --git a/WatchApp/nl.lproj/InfoPlist.strings b/WatchApp/nl.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/nl.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/nl.lproj/Interface.strings b/WatchApp/nl.lproj/Interface.strings deleted file mode 100644 index daf7473d97..0000000000 --- a/WatchApp/nl.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "Totaal Koolhydraten"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– –"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Pre-Meal"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Koolhydraten"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus Mislukt"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Hardlopen"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TITEL"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Override"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Etiket"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "ACTIEVE KOOLHYDRATEN"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– –"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Etiket"; - diff --git a/WatchApp/pl.lproj/InfoPlist.strings b/WatchApp/pl.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/pl.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/pl.lproj/Interface.strings b/WatchApp/pl.lproj/Interface.strings deleted file mode 100644 index 6199faad1e..0000000000 --- a/WatchApp/pl.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "WĘGLOWODANY OGÓŁEM"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Przed posiłkiem"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Węglowodany"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus nie podany"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Pracuje"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TYTUŁ"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Pominięcie"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Etykieta"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "AKTYWNE WĘGLOWODANY"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Etykieta"; - diff --git a/WatchApp/pt-BR.lproj/InfoPlist.strings b/WatchApp/pt-BR.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/pt-BR.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/pt-BR.lproj/Interface.strings b/WatchApp/pt-BR.lproj/Interface.strings deleted file mode 100644 index c84db2cb98..0000000000 --- a/WatchApp/pt-BR.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "CARBS TOTAL"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Pré-Refeição"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Carbs"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus Falhou"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Executando"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TÍTULO"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Sobrepor"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Rótulo"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "CARBS ATIVOS"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Rótulo"; - diff --git a/WatchApp/ro.lproj/InfoPlist.strings b/WatchApp/ro.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/ro.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/ro.lproj/Interface.strings b/WatchApp/ro.lproj/Interface.strings deleted file mode 100644 index fc462cb690..0000000000 --- a/WatchApp/ro.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "CARBOHIDRAȚI TOTALI"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Preprandial"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Carbohidrați"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus eșuat"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Se rulează"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TITLU"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Înlocuire"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Etichetă"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "CARBOHIDRAȚI ACTIVI"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Etichetă"; - diff --git a/WatchApp/ru.lproj/InfoPlist.strings b/WatchApp/ru.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/ru.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/ru.lproj/Interface.strings b/WatchApp/ru.lproj/Interface.strings deleted file mode 100644 index cf2c90d48d..0000000000 --- a/WatchApp/ru.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "ВСЕГО УГЛЕВОДОВ"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "До еды"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Углеводы"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Болюс не состоялся"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Выполнение"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "НАЗВАНИЕ"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Включить"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Болюс"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Ярлык"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "АКТИВНЫЕ УГЛЕВОДЫ"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Ярлык"; - diff --git a/WatchApp/sk.lproj/InfoPlist.strings b/WatchApp/sk.lproj/InfoPlist.strings deleted file mode 100644 index 273b97d2be..0000000000 --- a/WatchApp/sk.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - diff --git a/WatchApp/sk.lproj/Interface.strings b/WatchApp/sk.lproj/Interface.strings deleted file mode 100644 index 39844f8142..0000000000 --- a/WatchApp/sk.lproj/Interface.strings +++ /dev/null @@ -1,15 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - diff --git a/WatchApp/sv.lproj/InfoPlist.strings b/WatchApp/sv.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/sv.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/sv.lproj/Interface.strings b/WatchApp/sv.lproj/Interface.strings deleted file mode 100644 index 668835726b..0000000000 --- a/WatchApp/sv.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "Kolh. totalt"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Före måltid"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Kolhydrater"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus misslyckades"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Löpning"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TITLE"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Override"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Label"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "AKTIVA KOLH."; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Label"; - diff --git a/WatchApp/tr.lproj/InfoPlist.strings b/WatchApp/tr.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/tr.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/tr.lproj/Interface.strings b/WatchApp/tr.lproj/Interface.strings deleted file mode 100644 index 6de4cf4758..0000000000 --- a/WatchApp/tr.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "TOPLAM KARB"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Yemek öncesi"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Karb"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus Başarısız"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Çalışıyor"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "–"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "BAŞLIK"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Geçersiz kıl"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Etiket"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "AKTİF KARB"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "– – –"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Etiket"; - diff --git a/WatchApp/vi.lproj/InfoPlist.strings b/WatchApp/vi.lproj/InfoPlist.strings deleted file mode 100644 index 9250064a26..0000000000 --- a/WatchApp/vi.lproj/InfoPlist.strings +++ /dev/null @@ -1,6 +0,0 @@ -/* (No Comment) */ -"CFBundleDisplayName" = "Loop"; - -/* (No Comment) */ -"CFBundleName" = "$(PRODUCT_NAME)"; - diff --git a/WatchApp/vi.lproj/Interface.strings b/WatchApp/vi.lproj/Interface.strings deleted file mode 100644 index 0f58d40ea9..0000000000 --- a/WatchApp/vi.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "TỔNG CỘNG LƯỢNG CARBS"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "Trước bữa ăn"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "Carbs"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "Bolus lỗi"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "Đang chạy"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "TITLE"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Chồng lên"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "Bolus"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "Label"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "LƯỢNG CARBS CÒN HOẠT ĐỘNG"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "Label"; - diff --git a/WatchApp/zh-Hans.lproj/Interface.strings b/WatchApp/zh-Hans.lproj/Interface.strings deleted file mode 100644 index a493bb05ef..0000000000 --- a/WatchApp/zh-Hans.lproj/Interface.strings +++ /dev/null @@ -1,60 +0,0 @@ -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "CsQ-fc-KLC"; */ -"CsQ-fc-KLC.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ -"dea-qG-va8.text" = "碳水总量"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "Dt1-kz-jMZ"; */ -"Dt1-kz-jMZ.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ -"f5G-bS-9pd.text" = "餐前模式"; - -/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ -"hln-CI-MRP.text" = "碳水化合物"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "IRi-4t-ESO"; */ -"IRi-4t-ESO.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ -"jj3-Gq-HBy.text" = "大剂量输注失败"; - -/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ -"JXa-s1-PJx.text" = "运行中"; - -/* Class = "WKInterfaceLabel"; text = "–"; ObjectID = "Mhe-aR-kQQ"; */ -"Mhe-aR-kQQ.text" = "—"; - -/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ -"MZU-QV-PtZ.text" = "名称"; - -/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ -"nC0-X3-oFJ.text" = "Override"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ -"rNf-Mh-tID.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ -"smL-Rc-IZh.text" = "大剂量"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ -"T4U-wP-dSW.text" = "标签"; - -/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ -"UVY-pa-SUL.text" = "🏃‍♀️"; - -/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ -"v5b-sO-bb8.title" = "Loop"; - -/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ -"XkS-y5-khE.text" = ""; - -/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ -"ycL-5X-a05.text" = "ACTIVE CARBS"; - -/* Class = "WKInterfaceLabel"; text = "– – –"; ObjectID = "yl8-ZP-c3l"; */ -"yl8-ZP-c3l.text" = "---"; - -/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ -"zO8-x6-bZd.text" = "标签"; - From 09016ddfca52d2f410e0fbdbc0585a663943ce22 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 25 Sep 2025 13:12:40 -0700 Subject: [PATCH 290/421] [LOOP-5404] Replace assertionFailure with os.log (#832) --- .../Insulin Delivery Log/InsulinDeliveryLogViewModel.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index c2fe7f0eef..2785a4a0ed 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -8,6 +8,7 @@ import LoopAlgorithm import LoopKit +import os.log @MainActor @Observable @@ -62,6 +63,8 @@ class InsulinDeliveryLogViewModel { private let loopDataManager: LoopDataManager private let pumpManager: PumpManager + private let log = OSLog(category: "InsulinDeliveryLogViewModel") + private(set) var state: State var selectedFilterOption: FilterOptions = .all @@ -358,7 +361,7 @@ class InsulinDeliveryLogViewModel { ) } } else { - assertionFailure("No `decision.automaticDoseRecommendation`") + log.error("No `decision.automaticDoseRecommendation`") } } } else if let scheduledBasalRate = dose.scheduledBasalRate, scheduledBasalRate.doubleValue(for: .internationalUnitsPerHour) == dose.value { @@ -379,7 +382,7 @@ class InsulinDeliveryLogViewModel { ) ) } else { - assertionFailure("No `decision` or `scheduledBasalRate`") + log.error("No `decision` or `scheduledBasalRate`") } } else { events.append( From 14eaa677cd99a7513a4f54e6d74719a9a084a2ff Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 25 Sep 2025 18:46:21 -0500 Subject: [PATCH 291/421] Fix testflight build upload error (#831) --- Loop.xcodeproj/project.pbxproj | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 24735c93c9..502d093249 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -434,7 +434,6 @@ C1275DDB2E8175B40013B99D /* PresetConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1275DDA2E8175AF0013B99D /* PresetConfirmationView.swift */; }; C1275DDD2E8185990013B99D /* PresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1275DDC2E8185960013B99D /* PresetsView.swift */; }; C1275DDE2E81FD470013B99D /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43F78D4B1C914197002152D1 /* LoopKit.framework */; }; - C1275DDF2E81FD470013B99D /* LoopKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 43F78D4B1C914197002152D1 /* LoopKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C1275DE22E81FD530013B99D /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1275DE12E81FD530013B99D /* LoopKit.framework */; }; C1275E1C2E82269A0013B99D /* LoopKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C1275E1A2E82269A0013B99D /* LoopKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C1275E1F2E822CEE0013B99D /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */; }; @@ -736,17 +735,6 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; - C1275DE02E81FD470013B99D /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - C1275DDF2E81FD470013B99D /* LoopKit.framework in Embed Frameworks */, - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; C1E3DC4828595FAA00CA19FF /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -3072,7 +3060,6 @@ 43D9FFCB21EAE05D00AF44BF /* Sources */, 43D9FFCC21EAE05D00AF44BF /* Frameworks */, 43D9FFCD21EAE05D00AF44BF /* Resources */, - C1275DE02E81FD470013B99D /* Embed Frameworks */, ); buildRules = ( ); From 7606db1e828bfee66cf6a48af96cdca9c1b7694a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 29 Sep 2025 09:43:46 -0500 Subject: [PATCH 292/421] LOOP-5327 - fix another upload issue (#833) * Fix testflight build upload error * Fix another testflight error --- WatchApp/Info.plist | 2 -- 1 file changed, 2 deletions(-) diff --git a/WatchApp/Info.plist b/WatchApp/Info.plist index 2cd2620f9e..d79ed32bcc 100644 --- a/WatchApp/Info.plist +++ b/WatchApp/Info.plist @@ -43,7 +43,5 @@ $(MAIN_APP_BUNDLE_IDENTIFIER) CFBundleIconName AppIcon - WKExtensionDelegateClassName - $(PRODUCT_MODULE_NAME).ExtensionDelegate From 96f3bd19a748ce66128c82cc0b7c6a8ec3b8cdf3 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 2 Oct 2025 14:03:58 -0500 Subject: [PATCH 293/421] LOOP-5460 Fix repeat options saving on new preset creation (#834) * Fix repeat options saving on new preset creation * Fix additional state bugs --- .../CreatePresetNameAndScheduledEdit.swift | 25 +++++++++++---- Loop/Views/Presets/NewCustomPreset.swift | 32 +++++++++++++------ Loop/Views/Presets/NewPresetRangeEdit.swift | 9 ++++++ Loop/Views/Presets/ReviewNewPresetView.swift | 6 ++-- 4 files changed, 54 insertions(+), 18 deletions(-) diff --git a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift index bc2eac8cee..4acc6ea322 100644 --- a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift +++ b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift @@ -43,11 +43,24 @@ struct CreatePresetNameAndScheduledEdit: View { @FocusState private var isTextFieldFocused: Bool - @State private var selectedRepeatOption: RepeatOption = .never + @State private var selectedRepeatOption: RepeatOption @State private var showingDayPicker: Bool = false var onCancel: () -> Void + init( + preset: Binding, + path: Binding, + isDurationPickerExpanded: Bool = false, + onCancel: @escaping () -> Void + ) { + self._preset = preset + self._path = path + self.isDurationPickerExpanded = isDurationPickerExpanded + self.selectedRepeatOption = preset.wrappedValue.repeatOptions == .none ? .never : .weekly + self.onCancel = onCancel + } + var body: some View { CardSectionScrollView { CardSection { @@ -145,7 +158,7 @@ struct CreatePresetNameAndScheduledEdit: View { preset.startDate = Date().addingTimeInterval(.hours(1)) } else { preset.startDate = nil - preset.repeatOptions = nil + preset.repeatOptions = .none } } })) @@ -197,7 +210,7 @@ struct CreatePresetNameAndScheduledEdit: View { .foregroundColor(.primary) HStack { Spacer() - RepeatOptionView(repeatOptions: preset.repeatOptions ?? .none) + RepeatOptionView(repeatOptions: preset.repeatOptions) .padding(.vertical, 6) .onTapGesture { withAnimation { @@ -208,7 +221,7 @@ struct CreatePresetNameAndScheduledEdit: View { .popover(isPresented: $showingDayPicker, arrowEdge: .bottom) { DayPickerPopup(selectedDays: Binding( get: { - preset.repeatOptions ?? .none + preset.repeatOptions }, set: { newValue in preset.repeatOptions = newValue.union(requiredRepeatOption ?? .none) })) @@ -219,7 +232,7 @@ struct CreatePresetNameAndScheduledEdit: View { } } } - if let options = preset.repeatOptions, options != .none { + if preset.repeatOptions != .none { Text(preset.scheduleDescription()) .font(.footnote) .foregroundColor(.secondary) @@ -239,7 +252,7 @@ struct CreatePresetNameAndScheduledEdit: View { assignRepeatDays() } if newValue == .never { - preset.repeatOptions = nil + preset.repeatOptions = .none } }) .onChange(of: preset.startDate, { oldValue, newValue in diff --git a/Loop/Views/Presets/NewCustomPreset.swift b/Loop/Views/Presets/NewCustomPreset.swift index 16e1ccd81b..5e3ac6151e 100644 --- a/Loop/Views/Presets/NewCustomPreset.swift +++ b/Loop/Views/Presets/NewCustomPreset.swift @@ -40,23 +40,36 @@ extension PresetScheduleRepeatOptions: @retroactive CustomStringConvertible { } struct NewCustomPreset { - var savePreset: Bool = true + var savePreset: Bool var insulinMultiplier: Double = 1 var correctionRange: ClosedRange? var name: String = "" var duration: PresetDuration? var startDate: Date? - var repeatOptions: PresetScheduleRepeatOptions? + var repeatOptions: PresetScheduleRepeatOptions + + init( + savePreset: Bool = true, + insulinMultiplier: Double = 1, + correctionRange: ClosedRange? = nil, + name: String = "", + duration: PresetDuration? = nil, + startDate: Date? = nil, + repeatOptions: PresetScheduleRepeatOptions = .none + ) { + self.savePreset = savePreset + self.insulinMultiplier = insulinMultiplier + self.correctionRange = correctionRange + self.name = name + self.duration = duration + self.startDate = startDate + self.repeatOptions = repeatOptions + } } extension NewCustomPreset { func scheduleDescription() -> String { - guard let startDate = startDate, let repeatOptions = repeatOptions else { - return "" - } - - // Handle case where no days are selected - if repeatOptions.isEmpty { + guard let startDate = startDate, !repeatOptions.isEmpty else { return "" } @@ -123,7 +136,8 @@ extension NewCustomPreset { name: split.name, settings: settings, duration: overrideDuration, - scheduleStartDate: startDate + scheduleStartDate: startDate, + repeatOptions: repeatOptions ) } } diff --git a/Loop/Views/Presets/NewPresetRangeEdit.swift b/Loop/Views/Presets/NewPresetRangeEdit.swift index 867d1f6d75..53f74950c0 100644 --- a/Loop/Views/Presets/NewPresetRangeEdit.swift +++ b/Loop/Views/Presets/NewPresetRangeEdit.swift @@ -22,6 +22,15 @@ struct NewPresetRangeEdit: View { @State private var editedRange: ClosedRange? + init(preset: Binding, path: Binding, guardrail: Guardrail, scheduledRange: ClosedRange, onCancel: @escaping () -> Void) { + self._preset = preset + self._path = path + self._editedRange = .init(initialValue: preset.wrappedValue.correctionRange) + self.guardrail = guardrail + self.scheduledRange = scheduledRange + self.onCancel = onCancel + } + var body: some View { CardSectionScrollView { CardSection { diff --git a/Loop/Views/Presets/ReviewNewPresetView.swift b/Loop/Views/Presets/ReviewNewPresetView.swift index bff0ca6bd4..9e3d26228b 100644 --- a/Loop/Views/Presets/ReviewNewPresetView.swift +++ b/Loop/Views/Presets/ReviewNewPresetView.swift @@ -104,7 +104,7 @@ struct ReviewNewPresetView: View { if preset.savePreset, let startDate = preset.startDate { CardSection { HStack { - if preset.repeatOptions != nil { + if preset.repeatOptions != .none { Text("Start Date") } else { Text("Start at") @@ -113,12 +113,12 @@ struct ReviewNewPresetView: View { Text(DateFormatter.localizedString(from: startDate, dateStyle: .short, timeStyle: .short)) .foregroundColor(.secondary) } - if let repeatOptions = preset.repeatOptions { + if preset.repeatOptions != .none { Divider() HStack { Text("Repeat weekly on") Spacer() - RepeatOptionView(repeatOptions: repeatOptions) + RepeatOptionView(repeatOptions: preset.repeatOptions) } .padding(.vertical, 4) } From 65f706d8264305c0454a22ecba4d3abaeb4e649c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 3 Oct 2025 13:38:21 -0500 Subject: [PATCH 294/421] Bring in fixes from DIY for bolus entry, and also fix safe area layout issue (#836) --- .../StatusTableViewController.swift | 5 ++ Loop/Views/BolusEntryView.swift | 69 ++++++++++--------- Loop/Views/ManualEntryDoseView.swift | 66 ++++++++---------- LoopUI/Views/DeviceStatusHUDView.swift | 4 +- 4 files changed, 72 insertions(+), 72 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 0941b2f072..201f074493 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -425,6 +425,11 @@ final class StatusTableViewController: LoopChartsTableViewController { override func reloadData(animated: Bool = false) async { dispatchPrecondition(condition: .onQueue(.main)) + + guard view.window != nil else { + return + } + // This should be kept up to date immediately hudView?.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted hudView?.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index f7677fd9ad..7e6e1132cc 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -18,17 +18,21 @@ struct BolusEntryView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.dismissAction) var dismiss @Environment(\.appName) var appName - + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ObservedObject var viewModel: BolusEntryViewModel @State private var enteredBolusString = "" - @State private var shouldBolusEntryBecomeFirstResponder = false @State private var isInteractingWithChart = false - @State private var isKeyboardVisible = false - @State private var pickerShouldExpand = false @State private var editedBolusAmount = false + @FocusState private var bolusFieldFocused: Bool + + private var accessoryClearance: CGFloat { + dynamicTypeSize.isAccessibilitySize ? 72 : 52 + } + var body: some View { VStack(spacing: 0) { List { @@ -37,21 +41,11 @@ struct BolusEntryView: View { } .padding(.top, -28) .insetGroupedListStyle() - - self.actionArea - .frame(height: self.isKeyboardVisible || shouldBolusEntryBecomeFirstResponder ? 0 : nil) - .opacity(self.isKeyboardVisible || shouldBolusEntryBecomeFirstResponder ? 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.shouldBolusEntryBecomeFirstResponder = false + if !bolusFieldFocused { + actionArea } + } - .keyboardAware() - .edgesIgnoringSafeArea(self.isKeyboardVisible ? [] : .bottom) .navigationBarTitle(self.title) .supportedInterfaceOrientations(.portrait) .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) @@ -71,6 +65,7 @@ struct BolusEntryView: View { enteredBolusStringBinding.wrappedValue = "0" } } + .edgesIgnoringSafeArea(self.bolusFieldFocused ? [] : .bottom) .task { await self.viewModel.generateRecommendationAndStartObserving() } @@ -283,18 +278,31 @@ struct BolusEntryView: View { Text("Bolus", comment: "Label for bolus entry row on bolus screen") Spacer() HStack(alignment: .firstTextBaseline) { - DismissibleKeyboardTextField( - text: enteredBolusStringBinding, - placeholder: viewModel.formatBolusAmount(0.0), - font: .preferredFont(forTextStyle: .title1), - textColor: .loopAccent, - textAlignment: .right, - keyboardType: .decimalPad, - shouldBecomeFirstResponder: shouldBolusEntryBecomeFirstResponder, - maxLength: 5, - doneButtonColor: .loopAccent, - textFieldDidBeginEditing: didBeginEditing - ) + TextField(viewModel.formatBolusAmount(0.0), text: enteredBolusStringBinding) + .keyboardType(.decimalPad) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.title) + .multilineTextAlignment(.trailing) + .foregroundColor(.loopAccent) + .focused($bolusFieldFocused) + .onChange(of: bolusFieldFocused) { oldValue, focused in + if focused { + didBeginEditing() + } + } + .onChange(of: enteredBolusString) { oldValue, newValue in + if newValue.count > 5 { + enteredBolusString = String(newValue.prefix(5)) + viewModel.updateEnteredBolus(enteredBolusString) + } + } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { bolusFieldFocused = false } + } + } bolusUnitsLabel } .accessibilityIdentifier("textField_Bolus") @@ -328,7 +336,6 @@ struct BolusEntryView: View { enterManualGlucoseButton .transition(AnyTransition.opacity.combined(with: .move(edge: .bottom))) } - actionButton } .padding(.bottom) // FIXME: unnecessary on iPhone 8 size devices @@ -385,7 +392,7 @@ struct BolusEntryView: View { Button( action: { if self.viewModel.actionButtonAction == .enterBolus { - self.shouldBolusEntryBecomeFirstResponder = true + self.bolusFieldFocused = true } else { Task { if await self.viewModel.didPressActionButton() { diff --git a/Loop/Views/ManualEntryDoseView.swift b/Loop/Views/ManualEntryDoseView.swift index be48d1046a..a29de9fb6c 100644 --- a/Loop/Views/ManualEntryDoseView.swift +++ b/Loop/Views/ManualEntryDoseView.swift @@ -15,16 +15,20 @@ import LoopUI struct ManualEntryDoseView: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ObservedObject var viewModel: ManualEntryDoseViewModel @State private var enteredBolusString = "" - @State private var shouldBolusEntryBecomeFirstResponder = false - @State private var isInteractingWithChart = false - @State private var isKeyboardVisible = false + @FocusState private var bolusFieldFocused: Bool @Environment(\.dismissAction) var dismiss + private var accessoryClearance: CGFloat { + dynamicTypeSize.isAccessibilitySize ? 72 : 52 + } + var body: some View { GeometryReader { geometry in VStack(spacing: 0) { @@ -32,27 +36,9 @@ struct ManualEntryDoseView: 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) .insetGroupedListStyle() - 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.shouldBolusEntryBecomeFirstResponder = false - } - } - .keyboardAware() - .edgesIgnoringSafeArea(self.isKeyboardVisible ? [] : .bottom) .navigationBarTitle(self.title) .supportedInterfaceOrientations(.portrait) } @@ -62,12 +48,6 @@ struct ManualEntryDoseView: View { return Text("Log Dose", comment: "Title for dose logging screen") } - 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 chartSection: some View { Section { VStack(spacing: 8) { @@ -189,17 +169,25 @@ struct ManualEntryDoseView: View { Text("Bolus", comment: "Label for bolus entry row on bolus screen") Spacer() HStack(alignment: .firstTextBaseline) { - DismissibleKeyboardTextField( - text: typedBolusEntry, - placeholder: Self.doseAmountFormatter.string(from: 0.0)!, - font: .preferredFont(forTextStyle: .title1), - textColor: .loopAccent, - textAlignment: .right, - keyboardType: .decimalPad, - shouldBecomeFirstResponder: shouldBolusEntryBecomeFirstResponder, - maxLength: 5, - doneButtonColor: .loopAccent - ) + TextField(Self.doseAmountFormatter.string(from: 0.0)!, text: typedBolusEntry) + .keyboardType(.decimalPad) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.title) + .multilineTextAlignment(.trailing) + .foregroundColor(.loopAccent) + .focused($bolusFieldFocused) + .onChange(of: enteredBolusString) { oldValue, newValue in + if newValue.count > 5 { + enteredBolusString = String(newValue.prefix(5)) + } + } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { bolusFieldFocused = false } + } + } bolusUnitsLabel } } @@ -258,7 +246,7 @@ struct ManualEntryDoseView: View { } } -extension InsulinType: Labeled { +extension InsulinType: @retroactive Labeled { public var label: String { return title } diff --git a/LoopUI/Views/DeviceStatusHUDView.swift b/LoopUI/Views/DeviceStatusHUDView.swift index af0a9bfd99..b0a4fcee7c 100644 --- a/LoopUI/Views/DeviceStatusHUDView.swift +++ b/LoopUI/Views/DeviceStatusHUDView.swift @@ -31,8 +31,8 @@ import LoopKitUI // round the edges of the progress view progressView.layer.cornerRadius = 2 progressView.clipsToBounds = true - progressView.layer.sublayers![1].cornerRadius = 2 - progressView.subviews[1].clipsToBounds = true + progressView.layer.sublayers!.last!.cornerRadius = 2 + progressView.subviews.last!.clipsToBounds = true } } From d7146a9e7a393661d64da536ad418dc030296506 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 6 Oct 2025 15:06:25 -0500 Subject: [PATCH 295/421] LOOP-5327 Add icon to watch presets detail view (#837) * Add icon to watch presets detail view * restore font * Fix name localization for presets, and display icon on watch --- Loop/Managers/TemporaryPresetsManager.swift | 24 +---------------- .../Components/ActivePresetBanner.swift | 11 +------- LoopCore/SelectablePreset.swift | 27 ++++++++++++++++++- .../Views/ActiveOverrideView.swift | 19 +++++-------- .../Views/PresetDetailView.swift | 3 +++ 5 files changed, 37 insertions(+), 47 deletions(-) diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 055b194d41..e4d03c9ef8 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -120,29 +120,7 @@ class TemporaryPresetsManager { } public var activePreset: SelectablePreset? { - guard let override = activeOverride else { - return nil - } - - let range = override.settings.targetRange - - switch override.context { - case .preMeal: - return .preMeal(range: range!) - case .activity(let activity): - return .activity(activity) - case .custom: - let preset = TemporaryPreset( - id: override.syncIdentifier.uuidString, - symbol: nil, - name: "Single Use Preset", - settings: override.settings, - duration: override.duration - ) - return .custom(preset) - case .preset(let preset): - return .custom(preset) - } + return activeOverride?.createPreset() } var selectablePresets: [SelectablePreset] { diff --git a/Loop/Views/Presets/Components/ActivePresetBanner.swift b/Loop/Views/Presets/Components/ActivePresetBanner.swift index 8fa9271f42..53031b1bda 100644 --- a/Loop/Views/Presets/Components/ActivePresetBanner.swift +++ b/Loop/Views/Presets/Components/ActivePresetBanner.swift @@ -51,16 +51,7 @@ struct ActivePresetBanner: View { } var titleText: Text { - switch override.context { - case .preMeal: - Text(NSLocalizedString("Pre-Meal", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)")) - case .preset(let preset): - Text(String(format: NSLocalizedString("%@", comment: "The format for an active custom preset. (1: preset name)"), preset.name)) - case .activity(let activity): - Text(String(format: NSLocalizedString("%@", comment: "The format for an active activity preset. (1: preset name)"), activity.preset.name)) - case .custom: - Text(NSLocalizedString("Single Use Preset", comment: "The title of the cell indicating a generic custom preset is enabled")) - } + Text(override.createPreset().name) } var accessibilityIdentifier: String { diff --git a/LoopCore/SelectablePreset.swift b/LoopCore/SelectablePreset.swift index 1b1a0d27d3..6c096ecf3e 100644 --- a/LoopCore/SelectablePreset.swift +++ b/LoopCore/SelectablePreset.swift @@ -238,7 +238,7 @@ public enum SelectablePreset: Hashable, Identifiable { get { switch self { case .custom(let preset): return preset.name - case .preMeal: return "Pre-Meal" + case .preMeal: return NSLocalizedString("Pre-Meal", comment: "The title of pre-meal preset") case .activity(let activity): return activity.activityType.name } } @@ -398,6 +398,31 @@ extension SelectablePreset { } } +extension TemporaryScheduleOverride { + public func createPreset() -> SelectablePreset { + let range = settings.targetRange + + switch context { + case .preMeal: + return .preMeal(range: range!) + case .activity(let activity): + return .activity(activity) + case .custom: + let preset = TemporaryPreset( + id: syncIdentifier.uuidString, + symbol: nil, + name: NSLocalizedString("Single Use Preset", comment: "The title shown for a single use preset"), + settings: settings, + duration: duration + ) + return .custom(preset) + case .preset(let preset): + return .custom(preset) + } + } +} + + extension PresetExpectedEndTime { private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() diff --git a/WatchApp Extension/Views/ActiveOverrideView.swift b/WatchApp Extension/Views/ActiveOverrideView.swift index 840fa12f3d..a47009a2da 100644 --- a/WatchApp Extension/Views/ActiveOverrideView.swift +++ b/WatchApp Extension/Views/ActiveOverrideView.swift @@ -23,23 +23,16 @@ struct ActiveOverrideView: View { private let resetDelay: TimeInterval = 0.25 // pause for reset let override: TemporaryScheduleOverride - - var titleText: Text { - switch override.context { - case .preMeal: - Text(NSLocalizedString("Pre-Meal", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)")) - case .preset(let preset): - Text(String(format: NSLocalizedString("%@", comment: "The format for an active custom preset. (1: preset name)"), preset.name)) - case .activity(let activity): - Text(String(format: NSLocalizedString("%@", comment: "The format for an active activity preset. (1: preset name)"), activity.preset.name)) - case .custom: - Text(NSLocalizedString("Single Use Preset", comment: "The title of the cell indicating a generic custom preset is enabled")) - } + var preset: SelectablePreset { + return override.createPreset() } var title: some View { HStack(spacing: 6) { - titleText + if let icon = preset.icon, !icon.isEmpty { + PresetSymbolView(icon) + } + Text(preset.name) .font(.system(size: 19)) } } diff --git a/WatchApp Extension/Views/PresetDetailView.swift b/WatchApp Extension/Views/PresetDetailView.swift index e9b9e7dcb0..0a3354f6cd 100644 --- a/WatchApp Extension/Views/PresetDetailView.swift +++ b/WatchApp Extension/Views/PresetDetailView.swift @@ -17,6 +17,9 @@ struct PresetDetailView: View { var presetTitle: some View { HStack(spacing: 6) { + if let icon = preset.icon, !icon.isEmpty { + PresetSymbolView(icon) + } Text(preset.name) .font(.title3) .accessibilityIdentifier("text_Preset\(preset.name)") From 70e5ee82b9e4af7c1d00c82c11ea2dede5654b50 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 6 Oct 2025 17:37:08 -0500 Subject: [PATCH 296/421] Add warning info text for insulin needs guardrails (#838) --- Loop.xcodeproj/project.pbxproj | 18 ++-- .../InsulinNeedsAdjustmentPreview.swift | 87 +++++++++++++++++++ Loop/Views/Presets/EditPresetView.swift | 41 +++------ Loop/Views/Presets/ReviewNewPresetView.swift | 29 +------ 4 files changed, 112 insertions(+), 63 deletions(-) create mode 100644 Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 502d093249..c6b02fbe3e 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -460,6 +460,7 @@ 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 */; }; + C16E34C32E94443300581D20 /* InsulinNeedsAdjustmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16E34C22E94442E00581D20 /* InsulinNeedsAdjustmentPreview.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 */; }; @@ -1440,6 +1441,7 @@ 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 = ""; }; + C16E34C22E94442E00581D20 /* InsulinNeedsAdjustmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinNeedsAdjustmentPreview.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 = ""; }; @@ -2558,19 +2560,20 @@ 84E8BBC22CC9B9780078E6CF /* Components */ = { isa = PBXGroup; children = ( - C10509702D84A80500118A37 /* RepeatOptionsView.swift */, + 847F23422E4543140035C864 /* ActivePresetBanner.swift */, + 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */, + C105095C2D7A1DB300118A37 /* CardSection.swift */, + C10509602D7B3DEA00118A37 /* CardSectionScrollView.swift */, + C10509642D7B6B0000118A37 /* CorrectionRangePreview.swift */, C105096C2D80E22C00118A37 /* DayPickerPopup.swift */, + 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */, + C16E34C22E94442E00581D20 /* InsulinNeedsAdjustmentPreview.swift */, C10509662D7F7A3700118A37 /* InsulinScaleAdjustView.swift */, - C10509642D7B6B0000118A37 /* CorrectionRangePreview.swift */, - C10509602D7B3DEA00118A37 /* CardSectionScrollView.swift */, - C105095C2D7A1DB300118A37 /* CardSection.swift */, - 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */, 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */, 84C170EE2CCA37680098E52F /* PresetCard.swift */, 84D1F1A82D09800700CB271F /* PresetDetentView.swift */, 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */, - 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */, - 847F23422E4543140035C864 /* ActivePresetBanner.swift */, + C10509702D84A80500118A37 /* RepeatOptionsView.swift */, ); path = Components; sourceTree = ""; @@ -3710,6 +3713,7 @@ 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */, 439A7944211FE22F0041B75F /* NSUserActivity.swift in Sources */, 4328E0331CFC091100E199AA /* WatchContext+LoopKit.swift in Sources */, + C16E34C32E94443300581D20 /* InsulinNeedsAdjustmentPreview.swift in Sources */, 4F526D611DF8D9A900A04910 /* NetBasal.swift in Sources */, C1DE5D23251BFC4D00439E49 /* SimpleBolusView.swift in Sources */, 84213C8B2D94948900642E78 /* InsulinDeliveryOverview.swift in Sources */, diff --git a/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift b/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift new file mode 100644 index 0000000000..1a211ce5cd --- /dev/null +++ b/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift @@ -0,0 +1,87 @@ +// +// InsulinNeedsAdjustmentPreview.swift +// Loop +// +// Created by Pete Schwamb on 10/6/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopAlgorithm +import LoopKit +import LoopKitUI + +public struct InsulinNeedsAdjustmentPreview: View { + @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.guidanceColors) private var guidanceColors + + var range: ClosedRange? + var guardrail: Guardrail + private var insulinPercentage: Double + var showDisclosure: Bool + + init(insulinPercentage: Double, guardrail: Guardrail, showDisclosure: Bool = false) { + self.insulinPercentage = insulinPercentage + self.guardrail = guardrail + self.showDisclosure = showDisclosure + } + + var valueColor: Color { + switch Guardrail.presetInsulinNeeds.classification(for: .init(unit: .percent, doubleValue: insulinPercentage)) { + case .withinRecommendedRange: + return .accentColor + case .outsideRecommendedRange(let threshold): + switch threshold { + case .minimum, .maximum: + return guidanceColors.critical + case .belowRecommended, .aboveRecommended: + return guidanceColors.warning + } + } + } + + private var guardrailWarningIfNecessary: some View { + + let classification = Guardrail.presetInsulinNeeds.classification(for: .init(unit: .percent, doubleValue: insulinPercentage)) + + return Group { + if case .outsideRecommendedRange(let threshold) = classification { + let severity = threshold.severity + let color: Color = severity > .default ? guidanceColors.critical : guidanceColors.warning + HStack(alignment: .top, spacing: 12) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + .foregroundColor(color) + Text(SafetyClassification.captionForCrossedThresholds([threshold], isRange: true)) + .accessibilityIdentifier("text_InsulinNeedsWarning"); + } + .padding(12) + .background(color.opacity(0.1)) + .cornerRadius(12) + } + } + } + + public var body: some View { + VStack(alignment: .center, spacing: 8) { + HStack { + Text("Overall Insulin") + .font(.headline) + Spacer() + if showDisclosure { + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } + }.padding(.bottom, 10) + Text("\(Int(insulinPercentage))%") + .font(.system(size: 34, weight: .semibold)) + .foregroundColor(valueColor) + Text("of scheduled") + guardrailWarningIfNecessary + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.primary) + .padding(.bottom, 5) + .padding(.horizontal, 2) + } + .foregroundColor(.primary) + } +} diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 484815b8c2..45a9d53f92 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -63,36 +63,17 @@ struct EditPresetView: View { } } label: { CardSection("Temporary Settings Adjustments") { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Overall Insulin") - .font(.headline) - if preset.canAdjustSensitivity { - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } - }.padding(.bottom, 10) - - HStack { - Spacer() - VStack(alignment: .center) { - Text("\(Int(((1.0 / (preset.insulinSensitivityMultiplier ?? 1)) * 100).rounded()))%") - .font(.system(size: 34, weight: .bold)) - .foregroundColor(.accentColor) - Text("of scheduled") - .foregroundColor(.primary) - } - Spacer() - } - - if (!preset.canAdjustSensitivity) { - (Text(Image(systemName: "info.circle")) + Text(" Overall insulin cannot be adjusted for this preset")) - .foregroundColor(.secondary) - .font(.footnote) - .italic() - .padding(.top, 4) - } + InsulinNeedsAdjustmentPreview( + insulinPercentage: preset.insulinNeedsScaleFactor * 100, + guardrail: Guardrail.presetInsulinNeeds, + showDisclosure: preset.canAdjustSensitivity + ) + if (!preset.canAdjustSensitivity) { + (Text(Image(systemName: "info.circle")) + Text(" Overall insulin cannot be adjusted for this preset")) + .foregroundColor(.secondary) + .font(.footnote) + .italic() + .padding(.top, 4) } } .foregroundColor(.primary) diff --git a/Loop/Views/Presets/ReviewNewPresetView.swift b/Loop/Views/Presets/ReviewNewPresetView.swift index 9e3d26228b..c8c81e5134 100644 --- a/Loop/Views/Presets/ReviewNewPresetView.swift +++ b/Loop/Views/Presets/ReviewNewPresetView.swift @@ -54,7 +54,9 @@ struct ReviewNewPresetView: View { .cornerRadius(10) .padding(.top, 10) - sensitivitySection + CardSection("Temporary Settings Adjustments") { + InsulinNeedsAdjustmentPreview(insulinPercentage: preset.insulinMultiplier * 100, guardrail: Guardrail.presetInsulinNeeds) + } CardSection { CorrectionRangePreview( @@ -178,29 +180,4 @@ struct ReviewNewPresetView: View { currentDate = Date() } } - - var sensitivitySection: some View { - CardSection("Temporary Settings Adjustments") { - VStack(alignment: .leading, spacing: 8) { - Text("Overall Insulin") - .font(.headline) - .padding(.bottom, 10) - - - HStack { - Spacer() - VStack(alignment: .center) { - Text("\(Int(preset.insulinMultiplier * 100))%") - .font(.system(size: 34, weight: .semibold)) - .foregroundColor(.accentColor) - Text("of scheduled") - .foregroundColor(.primary) - } - Spacer() - } - } - .foregroundColor(.primary) - } - } - } From 58807a246bd015c65a88da58abf68c52a26c0a4f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Oct 2025 09:49:08 -0500 Subject: [PATCH 297/421] LOOP-5435 fix bolus preview forecast (#840) * Fix bolus preview forecast * Remove debug print --- Loop/Managers/LoopDataManager.swift | 34 ++++++++++++++++++++-- Loop/View Models/BolusEntryViewModel.swift | 25 +++++++++------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index f3d67279b0..25a9b19aa6 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -703,6 +703,7 @@ final class LoopDataManager: ObservableObject { let output = LoopAlgorithm.run(input: input) + switch output.recommendationResult { case .success(let prediction): guard var manualBolusRecommendation = prediction.manual else { return nil } @@ -1286,8 +1287,37 @@ extension LoopDataManager: BolusEntryViewModelDelegate { temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: presumingMealEntry) } - func generatePrediction(input: StoredDataAlgorithmInput) throws -> [PredictedGlucoseValue] { - try input.predictGlucose() + func generatePrediction( + originalCarbEntry: StoredCarbEntry?, + potentialCarbEntry: NewCarbEntry?, + potentialDose: SimpleInsulinDose?, + manualGlucose: NewGlucoseSample? + ) async throws -> (historicGlucose: [StoredGlucoseSample], predictedGlucose: [PredictedGlucoseValue]) { + let startDate = now() + + var endingPremealOverride = false + + if potentialCarbEntry != nil, + let activeOverride = temporaryPresetsManager.activeOverride, + activeOverride.context == .preMeal + { + endingPremealOverride = true + } + + var input = try await fetchData(for: startDate, presumePresetEndingNow: endingPremealOverride, ensureDosingCoverageStart: nil) + + let insulinModel = insulinModel(for: deliveryDelegate?.pumpInsulinType) + + // Add potential bolus, carbs, manual glucose + input = input + .addingDose(dose: potentialDose) + .addingGlucoseSample(sample: manualGlucose?.asStoredGlucoseSample) + .removingCarbEntry(carbEntry: originalCarbEntry) + .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) + + let prediction = try input.predictGlucose() + + return (historicGlucose: input.glucoseHistory, predictedGlucose: prediction) } } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 7712be10e4..27a211049e 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -44,7 +44,12 @@ protocol BolusEntryViewModelDelegate: AnyObject { truncatingActiveOverride: Bool ) async throws -> ManualBolusRecommendation? - func generatePrediction(input: StoredDataAlgorithmInput) throws -> [PredictedGlucoseValue] + func generatePrediction( + originalCarbEntry: StoredCarbEntry?, + potentialCarbEntry: NewCarbEntry?, + potentialDose: SimpleInsulinDose?, + manualGlucose: NewGlucoseSample? + ) async throws -> (historicGlucose: [StoredGlucoseSample], predictedGlucose: [PredictedGlucoseValue]) var activeInsulin: InsulinValue? { get } var activeCarbs: CarbValue? { get } @@ -236,6 +241,7 @@ final class BolusEntryViewModel: ObservableObject { private func observeEnteredManualGlucoseChanges() { $manualGlucoseQuantity + .dropFirst() .sink { [weak self] manualGlucoseQuantity in guard let self = self else { return } @@ -515,7 +521,6 @@ final class BolusEntryViewModel: ObservableObject { do { let startDate = now() - var input = try await delegate.fetchData(for: startDate, presumePresetEndingNow: potentialCarbEntry != nil, ensureDosingCoverageStart: nil) let insulinModel = delegate.insulinModel(for: deliveryDelegate?.pumpInsulinType) @@ -528,16 +533,14 @@ final class BolusEntryViewModel: ObservableObject { insulinModel: insulinModel ) - storedGlucoseValues = input.glucoseHistory - - // Add potential bolus, carbs, manual glucose - input = input - .addingDose(dose: enteredBolusDose) - .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseSample) - .removingCarbEntry(carbEntry: originalCarbEntry) - .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) + let (glucoseHistory, prediction) = try await delegate.generatePrediction( + originalCarbEntry: originalCarbEntry, + potentialCarbEntry: potentialCarbEntry, + potentialDose: enteredBolusDose, + manualGlucose: manualGlucoseSample + ) - let prediction = try delegate.generatePrediction(input: input) + storedGlucoseValues = glucoseHistory predictedGlucoseValues = prediction dosingDecision.predictedGlucose = prediction } catch { From 00608fd2d066189cbcb01cbe5329f86f31370bce Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 7 Oct 2025 10:44:57 -0500 Subject: [PATCH 298/421] Fix tests for bolus forecast preview updates (#841) --- LoopTests/ViewModels/BolusEntryViewModelTests.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 26d7de9ef4..10abce4428 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -925,9 +925,14 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { var prediction: [PredictedGlucoseValue] = [] var lastGeneratePredictionInput: StoredDataAlgorithmInput? - func generatePrediction(input: StoredDataAlgorithmInput) throws -> [PredictedGlucoseValue] { - lastGeneratePredictionInput = input - return prediction + + func generatePrediction( + originalCarbEntry: StoredCarbEntry?, + potentialCarbEntry: NewCarbEntry?, + potentialDose: SimpleInsulinDose?, + manualGlucose: NewGlucoseSample? + ) async throws -> (historicGlucose: [StoredGlucoseSample], predictedGlucose: [PredictedGlucoseValue]) { + return (historicGlucose: loopStateInput.glucoseHistory, predictedGlucose: prediction) } var algorithmOutput: AlgorithmOutput = AlgorithmOutput( From 7d2affa925d59fa75b1f4893284a140c1db9608c Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 9 Oct 2025 05:34:09 -0300 Subject: [PATCH 299/421] [LOOP-5452-5493] Automation icon update (#839) * added time ago label to loop status * added > to time ago string * removed auto-switching from closed to open loop * addressing PR comments * adding animation * adding upper limit of 7 days --- Loop/Managers/DeviceDataManager.swift | 10 ----- LoopUI/StatusBarHUDView.xib | 20 +++++++--- LoopUI/Views/LoopCompletionHUDView.swift | 48 +++++++++++++++++++++--- LoopUI/Views/LoopStateView.swift | 9 ++++- 4 files changed, 66 insertions(+), 21 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 9efac119ee..33259a907a 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -301,16 +301,6 @@ final class DeviceDataManager { setupPump() setupCGM() - - cgmStalenessMonitor.$cgmDataIsStale - .combineLatest($cgmHasValidSensorSession) - .map { $0 == false || $1 } - .combineLatest($pumpIsAllowingAutomation) - .map { $0 && $1 } - .receive(on: RunLoop.main) - .removeDuplicates() - .assign(to: \.automaticDosingStatus.isAutomaticDosingAllowed, on: self) - .store(in: &cancellables) } func instantiateDeviceManagers() { diff --git a/LoopUI/StatusBarHUDView.xib b/LoopUI/StatusBarHUDView.xib index 45cfcf329a..d9c2050dd0 100644 --- a/LoopUI/StatusBarHUDView.xib +++ b/LoopUI/StatusBarHUDView.xib @@ -1,8 +1,8 @@ - + - + @@ -153,6 +153,12 @@ + @@ -172,11 +178,13 @@ + + @@ -332,7 +340,9 @@ + + @@ -344,16 +354,16 @@ - + - + - +
diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index d9c920bc79..493ef9678b 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -21,8 +21,21 @@ public final class LoopCompletionHUDView: BaseHUDView { private(set) var freshness = LoopCompletionFreshness.stale { didSet { loopStateView.freshness = freshness + updateLabelColor() } } + + private var freshnessColor: UIColor { + switch freshness { + case .fresh: return .label + case .aging: return loopStatusColors.warning + case .stale: return loopStatusColors.error + } + } + + private func updateLabelColor() { + caption?.textColor = freshnessColor + } override public func awakeFromNib() { super.awakeFromNib() @@ -137,10 +150,12 @@ public final class LoopCompletionHUDView: BaseHUDView { @objc private func updateDisplay(_: Timer?) { lastLoopMessage = "" + caption?.isHidden = false let timeAgoToIncludeTimeStamp: TimeInterval = .minutes(20) let timeAgoToIncludeDate: TimeInterval = .hours(4) if loopIconClosed, let date = lastLoopCompleted { - let ago = abs(min(0, date.timeIntervalSinceNow)) + // restrict time ago from 0 to 7 days + let ago = min(abs(min(0, date.timeIntervalSinceNow)), TimeInterval.days(7)) freshness = LoopCompletionFreshness(age: ago) @@ -151,7 +166,7 @@ public final class LoopCompletionHUDView: BaseHUDView { UIContentSizeCategory.medium, UIContentSizeCategory.large: // Use a longer form only for smaller text sizes - caption?.text = String(format: LocalizedString("%@ ago", comment: "Format string describing the time interval since the last completion date. (1: The localized date components"), timeString) + caption?.attributedText = formattedTimeAgoString(timeString, includeGreaterThan: ago > .hours(1)) default: caption?.text = timeString } @@ -185,18 +200,18 @@ public final class LoopCompletionHUDView: BaseHUDView { 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) + caption?.attributedText = formattedTimeAgoString(timeString, includeGreaterThan: ago > .hours(1)) default: caption?.text = timeString } accessibilityLabel = String(format: LocalizedString("Last device communication ran %@ ago", comment: "Accessbility format label describing the time interval since the last device communication date. (1: The localized date components)"), timeString) } else { - caption?.text = "–" + caption?.text = "" accessibilityLabel = nil } } else { - caption?.text = "–" + caption?.text = "" accessibilityLabel = LocalizedString("Waiting for first run", comment: "Accessibility label describing completion HUD waiting for first run") } @@ -208,6 +223,29 @@ public final class LoopCompletionHUDView: BaseHUDView { accessibilityIdentifier = "loopCompletionHUDLoopStatusOpen" } } + + private func formattedTimeAgoString(_ timeString: String, includeGreaterThan: Bool = false) -> NSAttributedString { + let config = UIImage.SymbolConfiguration(pointSize: 11, weight: .semibold) + let symbol = UIImage(systemName: "arrow.trianglehead.2.clockwise.rotate.90", withConfiguration: config) + let tintedSymbol = symbol?.withTintColor(freshnessColor, renderingMode: .alwaysOriginal) + let attachment = NSTextAttachment() + attachment.image = tintedSymbol + attachment.bounds = CGRect(x: 0, y: -2, width: 11, height: 11) + let imageString = NSAttributedString(attachment: attachment) + + let timeAgoString: NSAttributedString + if includeGreaterThan { + timeAgoString = NSAttributedString(string: String(format: LocalizedString(" >%@ ago", comment: "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components"), timeString)) + } else { + timeAgoString = NSAttributedString(string: String(format: LocalizedString(" %@ ago", comment: "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components"), timeString)) + } + + let combined = NSMutableAttributedString() + combined.append(imageString) + combined.append(timeAgoString) + + return combined + } override public func didMoveToWindow() { super.didMoveToWindow() diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index 0b4a64670b..d617f25110 100644 --- a/LoopUI/Views/LoopStateView.swift +++ b/LoopUI/Views/LoopStateView.swift @@ -14,7 +14,14 @@ import UIKit class WrappedLoopStateViewModel: ObservableObject { @Published var loopStatusColors: StateColorPalette @Published var closedLoop: Bool - @Published var freshness: LoopCompletionFreshness + @Published var freshness: LoopCompletionFreshness { + didSet { + switch freshness { + case .aging, .stale: animating = true + default: animating = false + } + } + } @Published var animating: Bool init( From 2ab7dfa045cf6238c3d7a20cbf4c6b0171e22d96 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 15 Oct 2025 05:44:00 -0300 Subject: [PATCH 300/421] [LOOP-5452] adding logic for device inoperable display in LoopStateView (#842) --- .../StatusTableViewController.swift | 1 + LoopUI/Views/LoopCompletionHUDView.swift | 6 ++++++ LoopUI/Views/LoopStateView.swift | 13 +++++++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 201f074493..aabca71bf5 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -432,6 +432,7 @@ final class StatusTableViewController: LoopChartsTableViewController { // This should be kept up to date immediately hudView?.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted + hudView?.loopCompletionHUD.deviceInoperable = basalDeliveryState == .pumpInoperable hudView?.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate hudView?.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index 493ef9678b..cef0eb8036 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -63,6 +63,12 @@ public final class LoopCompletionHUDView: BaseHUDView { } } + public var deviceInoperable: Bool = false { + didSet { + loopStateView.deviceInoperable = deviceInoperable + } + } + public var mostRecentGlucoseDataDate: Date? public var mostRecentPumpDataDate: Date? diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index d617f25110..46d3bebe62 100644 --- a/LoopUI/Views/LoopStateView.swift +++ b/LoopUI/Views/LoopStateView.swift @@ -23,17 +23,20 @@ class WrappedLoopStateViewModel: ObservableObject { } } @Published var animating: Bool + @Published var deviceInoperable: Bool init( loopStatusColors: StateColorPalette = StateColorPalette(unknown: .black, normal: .black, warning: .black, error: .black), closedLoop: Bool = true, freshness: LoopCompletionFreshness = .stale, - animating: Bool = false + animating: Bool = false, + deviceInoperable: Bool = false ) { self.loopStatusColors = loopStatusColors self.closedLoop = closedLoop self.freshness = freshness self.animating = animating + self.deviceInoperable = deviceInoperable } } @@ -42,7 +45,7 @@ struct WrappedLoopCircleView: View { @StateObject 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, deviceInoperable: viewModel.deviceInoperable) .environment(\.loopStatusColorPalette, viewModel.loopStatusColors) } } @@ -100,6 +103,12 @@ final class LoopStateView: UIView { } } + var deviceInoperable: Bool = false { + didSet { + viewModel.deviceInoperable = deviceInoperable + } + } + private let viewModel = WrappedLoopStateViewModel() private func setupViews() { From a1fb4fab9b6c90d1e4169f117fb5560068091cd0 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 16 Oct 2025 13:34:03 -0300 Subject: [PATCH 301/421] [LOOP-5452] loop status fixes (#843) * when there is no cgm or pump, gray out loop. also remove caption when loop is open * removed isAutomaticDosingAllowed --- Loop/Extensions/UserDefaults+Loop.swift | 20 ------------------- Loop/Managers/LoopAppManager.swift | 4 ++-- Loop/Models/AutomaticDosingStatus.swift | 19 ++---------------- .../StatusTableViewController.swift | 4 +++- Loop/View Models/SettingsViewModel.swift | 2 +- Loop/Views/SettingsView.swift | 4 ++-- LoopUI/Views/LoopCompletionHUDView.swift | 2 +- 7 files changed, 11 insertions(+), 44 deletions(-) diff --git a/Loop/Extensions/UserDefaults+Loop.swift b/Loop/Extensions/UserDefaults+Loop.swift index b0f670d7a8..fe57219067 100644 --- a/Loop/Extensions/UserDefaults+Loop.swift +++ b/Loop/Extensions/UserDefaults+Loop.swift @@ -19,7 +19,6 @@ extension UserDefaults { case inFlightAutomaticDose = "com.loopkit.Loop.inFlightAutomaticDose" case favoriteFoods = "com.loopkit.Loop.favoriteFoods" case automationHistory = "com.loopkit.Loop.automationHistory" - case automaticDosingStatus = "com.loopkit.Loop.automaticDosingStatus" } var legacyPumpManagerRawValue: PumpManager.RawValue? { @@ -131,23 +130,4 @@ extension UserDefaults { } } } - - var automaticDosingStatus: AutomaticDosingStatus? { - get { - let decoder = JSONDecoder() - guard let data = object(forKey: Key.automaticDosingStatus.rawValue) as? Data else { - return nil - } - return try? decoder.decode(AutomaticDosingStatus.self, from: data) - } - set { - do { - let encoder = JSONEncoder() - let data = try encoder.encode(newValue) - set(data, forKey: Key.automaticDosingStatus.rawValue) - } catch { - assertionFailure("Unable to encode automatic dosing status") - } - } - } } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index f748579d36..226324b468 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -307,7 +307,7 @@ class LoopAppManager: NSObject { let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear - self.automaticDosingStatus = UserDefaults.standard.automaticDosingStatus ?? AutomaticDosingStatus(automaticDosingEnabled: UserDefaults.standard.automationHistory.last?.enabled ?? false, isAutomaticDosingAllowed: false) + self.automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: settingsManager.dosingEnabled) crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) @@ -498,7 +498,7 @@ class LoopAppManager: NSObject { analyticsServicesManager.application(didFinishLaunchingWithOptions: launchOptions) - withObservationTracking(of: self.automaticDosingStatus.isAutomaticDosingAllowed && self.settingsManager.dosingEnabled) { [weak self] enabled in + withObservationTracking(of: self.settingsManager.dosingEnabled) { [weak self] enabled in if self?.automaticDosingStatus.automaticDosingEnabled != enabled { self?.automaticDosingStatus.automaticDosingEnabled = enabled } diff --git a/Loop/Models/AutomaticDosingStatus.swift b/Loop/Models/AutomaticDosingStatus.swift index b7a39a6f70..61b9ca9174 100644 --- a/Loop/Models/AutomaticDosingStatus.swift +++ b/Loop/Models/AutomaticDosingStatus.swift @@ -10,24 +10,9 @@ import Foundation @Observable public class AutomaticDosingStatus: Codable { - public var automaticDosingEnabled: Bool { - didSet { - UserDefaults.standard.automaticDosingStatus = self - } - } + public var automaticDosingEnabled: Bool - public var isAutomaticDosingAllowed: Bool { - didSet { - UserDefaults.standard.automaticDosingStatus = self - } - } - - public init(automaticDosingEnabled: Bool, - isAutomaticDosingAllowed: Bool) - { + public init(automaticDosingEnabled: Bool) { self.automaticDosingEnabled = automaticDosingEnabled - self.isAutomaticDosingAllowed = isAutomaticDosingAllowed - - UserDefaults.standard.automaticDosingStatus = self } } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index aabca71bf5..7975998597 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -145,12 +145,14 @@ final class StatusTableViewController: LoopChartsTableViewController { Task { @MainActor [weak self] in self?.registerPumpManager() self?.configurePumpManagerHUDViews() + await self?.reloadData() } }, notificationCenter.addObserver(forName: .CGMManagerChanged, object: deviceManager, queue: nil) { (notification: Notification) in Task { @MainActor [weak self] in self?.registerCGMManager() self?.configureCGMManagerHUDViews() + await self?.reloadData() } }, notificationCenter.addObserver(forName: .PumpEventsAdded, object: deviceManager, queue: nil) { (notification: Notification) in @@ -432,7 +434,7 @@ final class StatusTableViewController: LoopChartsTableViewController { // This should be kept up to date immediately hudView?.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted - hudView?.loopCompletionHUD.deviceInoperable = basalDeliveryState == .pumpInoperable + hudView?.loopCompletionHUD.deviceInoperable = deviceManager.cgmManager == nil || deviceManager.pumpManager == nil || basalDeliveryState == .pumpInoperable hudView?.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate hudView?.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 44cf6654db..d96b65799a 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -229,7 +229,7 @@ extension SettingsViewModel { criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: MockCriticalEventLogExporterFactory()), therapySettings: { TherapySettings() }, initialDosingEnabled: true, - automaticDosingStatus: AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true), + automaticDosingStatus: AutomaticDosingStatus(automaticDosingEnabled: true), automaticDosingStrategy: .automaticBolus, lastLoopCompletion: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, mostRecentGlucoseDataDate: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index b4e486e212..7ec7e5f895 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -192,7 +192,7 @@ struct SettingsView: View { private var closedLoopToggleState: Binding { Binding( - get: { self.viewModel.automaticDosingStatus.isAutomaticDosingAllowed && self.viewModel.closedLoopPreference }, + get: { self.viewModel.closedLoopPreference }, set: { self.viewModel.closedLoopPreference = $0 } ) } @@ -271,7 +271,7 @@ extension SettingsView { } } .accessibilityIdentifier("settingsViewClosedLoopToggle") - .disabled(!viewModel.isOnboardingComplete || !viewModel.automaticDosingStatus.isAutomaticDosingAllowed) + .disabled(!viewModel.isOnboardingComplete) .padding(.vertical) } } diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index cef0eb8036..8f02e9942b 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -156,7 +156,7 @@ public final class LoopCompletionHUDView: BaseHUDView { @objc private func updateDisplay(_: Timer?) { lastLoopMessage = "" - caption?.isHidden = false + caption?.isHidden = !loopIconClosed let timeAgoToIncludeTimeStamp: TimeInterval = .minutes(20) let timeAgoToIncludeDate: TimeInterval = .hours(4) if loopIconClosed, let date = lastLoopCompleted { From d6d4c29918419400695b38c67bdc5ff19f4c6118 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 16 Oct 2025 13:57:04 -0300 Subject: [PATCH 302/421] [LOOP-5452] updating unit tests (#844) --- LoopTests/Managers/DeviceDataManagerTests.swift | 2 +- LoopTests/Managers/LoopDataManagerTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopTests/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift index 81a6c31e08..e1620fd10d 100644 --- a/LoopTests/Managers/DeviceDataManagerTests.swift +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -39,7 +39,7 @@ final class DeviceDataManagerTests: XCTestCase { let mockUserNotificationCenter = MockUserNotificationCenter() let mockBluetoothProvider = MockBluetoothProvider() let alertPresenter = MockPresenter() - let automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) + let automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true) let alertManager = AlertManager( alertPresenter: alertPresenter, diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 6d2945914f..79e764e305 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -123,7 +123,7 @@ class LoopDataManagerTests: XCTestCase { doseStore.lastAddedPumpData = now dosingDecisionStore = MockDosingDecisionStore() - automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) + automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true) let temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsProvider, presetHistory: TemporaryScheduleOverrideHistory()) From d049a2708290accb5ca0e2c3af2a2daf4c10adc8 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 20 Oct 2025 02:51:42 -0300 Subject: [PATCH 303/421] [LOOP-5509] removing automaticDosingStatus (#845) --- Loop.xcodeproj/project.pbxproj | 4 --- Loop/Managers/DeviceDataManager.swift | 6 +--- Loop/Managers/ExtensionDataManager.swift | 7 ++--- Loop/Managers/LoopAppManager.swift | 15 --------- Loop/Managers/LoopDataManager.swift | 8 ++--- Loop/Managers/SettingsManager.swift | 2 ++ Loop/Models/AutomaticDosingStatus.swift | 18 ----------- .../CarbAbsorptionViewController.swift | 6 ++-- .../StatusTableViewController.swift | 31 ++++++++++++------- Loop/View Models/SettingsViewModel.swift | 20 +++++++----- Loop/View Models/StatusTableViewModel.swift | 4 +-- .../InsulinDeliveryLogViewModel.swift | 4 +-- Loop/Views/SettingsView.swift | 2 +- Loop/Views/StatusTableView.swift | 6 +--- 14 files changed, 47 insertions(+), 86 deletions(-) delete mode 100644 Loop/Models/AutomaticDosingStatus.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index c6b02fbe3e..1f0bed5c01 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -382,7 +382,6 @@ B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */; }; B4D620D424D9EDB900043B3C /* GuidanceColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D620D324D9EDB900043B3C /* GuidanceColors.swift */; }; B4D904412AA8989100CBD826 /* StatefulPluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */; }; - B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */; }; B4E96D4B248A6B6E002DABAD /* DeviceStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */; }; B4E96D4F248A6E20002DABAD /* CGMStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */; }; B4E96D53248A7386002DABAD /* GlucoseValueHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D52248A7386002DABAD /* GlucoseValueHUDView.swift */; }; @@ -1288,7 +1287,6 @@ B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = ""; }; B4D620D324D9EDB900043B3C /* GuidanceColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidanceColors.swift; sourceTree = ""; }; B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPluginManager.swift; sourceTree = ""; }; - B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticDosingStatus.swift; sourceTree = ""; }; B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusHUDView.swift; sourceTree = ""; }; B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDView.swift; sourceTree = ""; }; B4E96D52248A7386002DABAD /* GlucoseValueHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseValueHUDView.swift; sourceTree = ""; }; @@ -1893,7 +1891,6 @@ isa = PBXGroup; children = ( DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */, - B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */, A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */, C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */, @@ -3608,7 +3605,6 @@ 14BBB3B22C629DB100ECB800 /* Character+IsEmoji.swift in Sources */, C1B80D632AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift in Sources */, DDC389FA2A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift in Sources */, - B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */, C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, A977A2F424ACFECF0059C207 /* CriticalEventLogExportManager.swift in Sources */, 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 33259a907a..3f4b23fa6f 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -70,8 +70,6 @@ final class DeviceDataManager { private var lastCGMLoopTrigger: Date = .distantPast - private let automaticDosingStatus: AutomaticDosingStatus - var closedLoopDisallowedLocalizedDescription: String? { if !cgmHasValidSensorSession { return NSLocalizedString("Closed Loop requires an active CGM Sensor Session", comment: "The description text for the looping enabled switch cell when closed loop is not allowed because the sensor is inactive") @@ -253,7 +251,6 @@ final class DeviceDataManager { activeStatefulPluginsProvider: ActiveStatefulPluginsProvider, bluetoothProvider: BluetoothProvider, alertPresenter: AlertPresenter, - automaticDosingStatus: AutomaticDosingStatus, cacheStore: PersistenceController, localCacheDuration: TimeInterval, displayGlucosePreference: DisplayGlucosePreference, @@ -273,7 +270,6 @@ final class DeviceDataManager { self.analyticsServicesManager = analyticsServicesManager self.bluetoothProvider = bluetoothProvider self.alertPresenter = alertPresenter - self.automaticDosingStatus = automaticDosingStatus self.cacheStore = cacheStore self.crashRecoveryManager = crashRecoveryManager self.activeStatefulPluginsProvider = activeStatefulPluginsProvider @@ -1099,7 +1095,7 @@ extension DeviceDataManager: PumpManagerDelegate { } var automaticDosingEnabled: Bool { - automaticDosingStatus.automaticDosingEnabled + settingsManager.automaticDosingEnabled } } diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift index b541f7215f..c7b035dea3 100644 --- a/Loop/Managers/ExtensionDataManager.swift +++ b/Loop/Managers/ExtensionDataManager.swift @@ -17,11 +17,9 @@ final class ExtensionDataManager { unowned let loopDataManager: LoopDataManager unowned let settingsManager: SettingsManager unowned let temporaryPresetsManager: TemporaryPresetsManager - private let automaticDosingStatus: AutomaticDosingStatus init(deviceDataManager: DeviceDataManager, loopDataManager: LoopDataManager, - automaticDosingStatus: AutomaticDosingStatus, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager ) { @@ -29,7 +27,6 @@ final class ExtensionDataManager { self.loopDataManager = loopDataManager self.settingsManager = settingsManager self.temporaryPresetsManager = temporaryPresetsManager - self.automaticDosingStatus = automaticDosingStatus NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .LoopDataUpdated, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .PumpManagerChanged, object: nil) @@ -122,9 +119,9 @@ final class ExtensionDataManager { context.mostRecentGlucoseDataDate = loopDataManager.mostRecentGlucoseDataDate context.mostRecentPumpDataDate = loopDataManager.mostRecentPumpDataDate - context.isClosedLoop = self.automaticDosingStatus.automaticDosingEnabled + context.isClosedLoop = self.settingsManager.automaticDosingEnabled - context.preMealPresetAllowed = self.automaticDosingStatus.automaticDosingEnabled && self.settingsManager.settings.preMealTargetRange != nil + context.preMealPresetAllowed = self.settingsManager.automaticDosingEnabled && self.settingsManager.settings.preMealTargetRange != nil context.preMealPresetActive = self.temporaryPresetsManager.isPreMealTargetActive() context.customPresetActive = self.temporaryPresetsManager.isNonPreMealOverrideActive() diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 226324b468..35d51cd082 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -112,8 +112,6 @@ class LoopAppManager: NSObject { private let log = DiagnosticLog(category: "LoopAppManager") private let widgetLog = DiagnosticLog(category: "LoopWidgets") - private var automaticDosingStatus: AutomaticDosingStatus! - lazy private var cancellables = Set() func initialize(windowProvider: WindowProvider, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { @@ -307,8 +305,6 @@ class LoopAppManager: NSObject { let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear - self.automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: settingsManager.dosingEnabled) - crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) loopDataManager = LoopDataManager( @@ -320,7 +316,6 @@ class LoopAppManager: NSObject { carbStore: carbStore, crashRecoveryManager: crashRecoveryManager, dosingDecisionStore: dosingDecisionStore, - automaticDosingStatus: automaticDosingStatus, trustedTimeOffset: { self.trustedTimeChecker.detectedSystemTimeOffset }, analyticsServicesManager: analyticsServicesManager, carbAbsorptionModel: carbModel, @@ -395,7 +390,6 @@ class LoopAppManager: NSObject { activeStatefulPluginsProvider: statefulPluginManager, bluetoothProvider: bluetoothStateManager, alertPresenter: self, - automaticDosingStatus: automaticDosingStatus, cacheStore: cacheStore, localCacheDuration: localCacheDuration, displayGlucosePreference: displayGlucosePreference, @@ -413,7 +407,6 @@ class LoopAppManager: NSObject { statusExtensionManager = ExtensionDataManager( deviceDataManager: deviceDataManager, loopDataManager: loopDataManager, - automaticDosingStatus: automaticDosingStatus, settingsManager: settingsManager, temporaryPresetsManager: temporaryPresetsManager ) @@ -498,12 +491,6 @@ class LoopAppManager: NSObject { analyticsServicesManager.application(didFinishLaunchingWithOptions: launchOptions) - withObservationTracking(of: self.settingsManager.dosingEnabled) { [weak self] enabled in - if self?.automaticDosingStatus.automaticDosingEnabled != enabled { - self?.automaticDosingStatus.automaticDosingEnabled = enabled - } - } - state = state.next await loopDataManager.updateDisplayState() @@ -579,7 +566,6 @@ class LoopAppManager: NSObject { criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: criticalEventLogExportManager), therapySettings: { [weak self] in self?.settingsManager.therapySettings ?? TherapySettings() }, initialDosingEnabled: self.settingsManager.settings.dosingEnabled, - automaticDosingStatus: self.automaticDosingStatus, automaticDosingStrategy: self.settingsManager.settings.automaticDosingStrategy, lastLoopCompletion: loopDataManager.$lastLoopCompleted, mostRecentGlucoseDataDate: loopDataManager.$publishedMostRecentGlucoseDataDate, @@ -603,7 +589,6 @@ class LoopAppManager: NSObject { let viewModel = StatusTableViewModel( alertPermissionsChecker: alertPermissionsChecker, alertMuter: alertManager.alertMuter, - automaticDosingStatus: automaticDosingStatus, deviceDataManager: deviceDataManager, onboardingManager: onboardingManager, supportManager: supportManager, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 25a9b19aa6..09ebae2f60 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -133,8 +133,6 @@ final class LoopDataManager: ObservableObject { private let now: () -> Date - let automaticDosingStatus: AutomaticDosingStatus - // References to registered notification center observers private var notificationObservers: [Any] = [] @@ -179,7 +177,6 @@ final class LoopDataManager: ObservableObject { crashRecoveryManager: CrashRecoveryManager, dosingDecisionStore: DosingDecisionStoreProtocol, now: @escaping () -> Date = { Date() }, - automaticDosingStatus: AutomaticDosingStatus, trustedTimeOffset: @escaping () async -> TimeInterval, analyticsServicesManager: AnalyticsServicesManager?, carbAbsorptionModel: CarbAbsorptionModel, @@ -196,7 +193,6 @@ final class LoopDataManager: ObservableObject { self.crashRecoveryManager = crashRecoveryManager self.dosingDecisionStore = dosingDecisionStore self.now = now - self.automaticDosingStatus = automaticDosingStatus self.trustedTimeOffset = trustedTimeOffset self.analyticsServicesManager = analyticsServicesManager self.carbAbsorptionModel = carbAbsorptionModel @@ -265,7 +261,7 @@ final class LoopDataManager: ObservableObject { // 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. - withObservationTracking(of: automaticDosingStatus.automaticDosingEnabled) { [weak self] enabled in + withObservationTracking(of: settingsProvider.automaticDosingEnabled) { [weak self] enabled in if self?.automationHistory.last?.enabled != enabled { self?.automationHistory.append(AutomationHistoryEntry(startDate: Date(), enabled: enabled)) @@ -635,7 +631,7 @@ final class LoopDataManager: ObservableObject { dosingDecision.updateFrom(input: input, output: output) - if self.automaticDosingStatus.automaticDosingEnabled { + if self.settingsProvider.automaticDosingEnabled { if deliveryDelegate.basalDeliveryState == .pumpInoperable { throw LoopError.pumpInoperable } diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index 55b9407454..63373d18a4 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -401,6 +401,7 @@ extension SettingsManager { @MainActor protocol SettingsProvider { var settings: StoredSettings { get } + var automaticDosingEnabled: Bool { get } func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] @@ -412,6 +413,7 @@ protocol SettingsProvider { extension SettingsManager: SettingsProvider { var settings: StoredSettings { storedSettings } + var automaticDosingEnabled: Bool { dosingEnabled } func executeSettingsQuery(fromQueryAnchor queryAnchor: SettingsStore.QueryAnchor?, limit: Int, completion: @escaping (SettingsStore.SettingsQueryResult) -> Void) { settingsStore!.executeSettingsQuery(fromQueryAnchor: queryAnchor, limit: limit, completion: completion) diff --git a/Loop/Models/AutomaticDosingStatus.swift b/Loop/Models/AutomaticDosingStatus.swift deleted file mode 100644 index 61b9ca9174..0000000000 --- a/Loop/Models/AutomaticDosingStatus.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// AutomaticDosingStatus.swift -// Loop -// -// Created by Nathaniel Hamming on 2021-05-28. -// Copyright © 2021 LoopKit Authors. All rights reserved. -// - -import Foundation - -@Observable -public class AutomaticDosingStatus: Codable { - public var automaticDosingEnabled: Bool - - public init(automaticDosingEnabled: Bool) { - self.automaticDosingEnabled = automaticDosingEnabled - } -} diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift index 92c81baceb..5ed4ee1146 100644 --- a/Loop/View Controllers/CarbAbsorptionViewController.swift +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -28,7 +28,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif var isOnboardingComplete: Bool = true - var automaticDosingStatus: AutomaticDosingStatus! + var automaticDosingEnabled: Bool! var loopDataManager: LoopDataManager! var carbStore: CarbStore! @@ -70,7 +70,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif navigationItem.rightBarButtonItem?.isEnabled = isOnboardingComplete - allowEditing = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled + allowEditing = automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled if allowEditing { navigationItem.rightBarButtonItems?.append(editButtonItem) @@ -494,7 +494,7 @@ final class CarbAbsorptionViewController: LoopChartsTableViewController, Identif // MARK: - Navigation @IBAction func presentCarbEntryScreen() { - if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { + if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingEnabled { let displayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) let viewModel = SimpleBolusViewModel(delegate: loopDataManager, displayMealEntry: true, displayGlucosePreference: displayGlucosePreference) let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(displayGlucosePreference) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 7975998597..9c37a2eb78 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -41,8 +41,6 @@ final class StatusTableViewController: LoopChartsTableViewController { var testingScenariosManager: TestingScenariosManager! - var automaticDosingStatus: AutomaticDosingStatus! - var alertPermissionsChecker: AlertPermissionsChecker! var settingsManager: SettingsManager! @@ -163,7 +161,7 @@ final class StatusTableViewController: LoopChartsTableViewController { }, ] - withObservationTracking(of: self.automaticDosingStatus.automaticDosingEnabled) { [weak self] enabled in + withObservationTracking(of: self.settingsManager.dosingEnabled) { [weak self] enabled in self?.automaticDosingStatusChanged(enabled) } @@ -475,7 +473,7 @@ final class StatusTableViewController: LoopChartsTableViewController { var carbsOnBoard: LoopQuantity? let startDate = charts.startDate let basalDeliveryState = self.basalDeliveryState - let automaticDosingEnabled = automaticDosingStatus.automaticDosingEnabled + let automaticDosingEnabled = settingsManager.dosingEnabled let state = await loopManager.algorithmDisplayState predictedGlucoseValues = state.output?.predictedGlucose ?? [] @@ -1003,7 +1001,7 @@ final class StatusTableViewController: LoopChartsTableViewController { }) cell.setTitleLabelText(label: NSLocalizedString("Glucose", comment: "The title of the glucose and prediction graph")) cell.setTitleTextColor(color: ChartColorPalette.primary.glucoseTint) - cell.doesNavigate = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled + cell.doesNavigate = settingsManager.dosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled case .iob: cell.setSupplementalChartGenerator(generator: { [weak self] (frame) in return self?.statusCharts.doseChart(withFrame: frame)?.view @@ -1177,7 +1175,7 @@ final class StatusTableViewController: LoopChartsTableViewController { cell.setSubtitleLabel(label: nil) cell.setTitleLabelAccessibilityIdentifier("Glucose") } - cell.doesNavigate = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled + cell.doesNavigate = settingsManager.dosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled case .iob: if let currentIOB = currentIOBDescription { cell.setSubtitleLabel(label: currentIOB) @@ -1304,7 +1302,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case .charts: switch ChartRow(rawValue: indexPath.row)! { case .glucose: - if automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled { + if settingsManager.dosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled { performSegue(withIdentifier: PredictionTableViewController.className, sender: indexPath) } case .iob: @@ -1419,7 +1417,7 @@ final class StatusTableViewController: LoopChartsTableViewController { switch targetViewController { case let vc as CarbAbsorptionViewController: vc.isOnboardingComplete = onboardingManager.isComplete - vc.automaticDosingStatus = automaticDosingStatus + vc.automaticDosingEnabled = settingsManager.dosingEnabled vc.deviceManager = deviceManager vc.loopDataManager = loopManager vc.analyticsServicesManager = analyticsServicesManager @@ -1450,7 +1448,7 @@ final class StatusTableViewController: LoopChartsTableViewController { func presentCarbEntryScreen(_ activity: NSUserActivity?, value: LoopQuantity? = nil) { let navigationWrapper: UINavigationController - if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { + if FeatureFlags.simpleBolusCalculatorEnabled && !settingsManager.dosingEnabled { let viewModel = SimpleBolusViewModel(delegate: loopManager, displayMealEntry: true, displayGlucosePreference: deviceManager.displayGlucosePreference) if let activity = activity { viewModel.restoreUserActivityState(activity) @@ -1485,7 +1483,7 @@ final class StatusTableViewController: LoopChartsTableViewController { @ViewBuilder func bolusEntryView(enableManualGlucoseEntry: Bool = false) -> some View { - if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { + if FeatureFlags.simpleBolusCalculatorEnabled && !settingsManager.dosingEnabled { SimpleBolusView( viewModel: SimpleBolusViewModel( delegate: loopManager, @@ -1622,7 +1620,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.loopIconClosed = settingsManager.dosingEnabled hudView.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted hudView.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate hudView.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate @@ -2111,6 +2109,17 @@ extension StatusTableViewController: BluetoothObserver { // MARK: - SettingsViewModel delegation extension StatusTableViewController: SettingsViewModelDelegate { + var automaticDosingEnabled: Bool { + get { + settingsManager.dosingEnabled + } + set { + if settingsManager.dosingEnabled != newValue { + settingsManager.dosingEnabled = newValue + } + } + } + var closedLoopDescriptiveText: String? { return deviceManager.closedLoopDisallowedLocalizedDescription } diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index d96b65799a..db25378fd2 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -54,6 +54,7 @@ public protocol SettingsViewModelDelegate: AnyObject { func dosingStrategyChanged(_: AutomaticDosingStrategy) func didTapIssueReport() var closedLoopDescriptiveText: String? { get } + var automaticDosingEnabled: Bool { get set } } @Observable @@ -81,17 +82,23 @@ class SettingsViewModel { let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? let presetHistory: TemporaryScheduleOverrideHistory - private(set) var automaticDosingStatus: AutomaticDosingStatus + private(set) var automaticDosingEnabled: Bool { + get { + delegate?.automaticDosingEnabled ?? closedLoopPreference + } + set { + delegate?.automaticDosingEnabled = newValue + } + } private(set) var lastLoopCompletion: Date? private(set) var mostRecentGlucoseDataDate: Date? private(set) var mostRecentPumpDataDate: Date? var closedLoopDescriptiveText: String? { - return delegate?.closedLoopDescriptiveText + delegate?.closedLoopDescriptiveText } - var automaticDosingStrategy: AutomaticDosingStrategy { didSet { delegate?.dosingStrategyChanged(automaticDosingStrategy) @@ -104,7 +111,6 @@ class SettingsViewModel { } } - var preMealGuardrail: Guardrail? @ObservationIgnored weak var favoriteFoodInsightsDelegate: FavoriteFoodInsightsViewModelDelegate? @@ -117,7 +123,7 @@ class SettingsViewModel { var loopStatusCircleFreshness: LoopCompletionFreshness { var age: TimeInterval - if automaticDosingStatus.automaticDosingEnabled { + if automaticDosingEnabled { let lastLoopCompletion = lastLoopCompletion ?? Date().addingTimeInterval(.minutes(16)) age = abs(min(0, lastLoopCompletion.timeIntervalSinceNow)) } else { @@ -141,7 +147,6 @@ class SettingsViewModel { criticalEventLogExportViewModel: CriticalEventLogExportViewModel, therapySettings: @escaping () -> TherapySettings, initialDosingEnabled: Bool, - automaticDosingStatus: AutomaticDosingStatus, automaticDosingStrategy: AutomaticDosingStrategy, lastLoopCompletion: Published.Publisher, mostRecentGlucoseDataDate: Published.Publisher, @@ -161,7 +166,6 @@ class SettingsViewModel { self.criticalEventLogExportViewModel = criticalEventLogExportViewModel self.therapySettings = therapySettings self.closedLoopPreference = initialDosingEnabled - self.automaticDosingStatus = automaticDosingStatus self.automaticDosingStrategy = automaticDosingStrategy self.lastLoopCompletion = nil self.mostRecentGlucoseDataDate = nil @@ -193,6 +197,7 @@ extension SettingsViewModel { fileprivate class FakeSettingsProvider: SettingsProvider { let settings = StoredSettings() + let automaticDosingEnabled = true func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { [] @@ -229,7 +234,6 @@ extension SettingsViewModel { criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: MockCriticalEventLogExporterFactory()), therapySettings: { TherapySettings() }, initialDosingEnabled: true, - automaticDosingStatus: AutomaticDosingStatus(automaticDosingEnabled: true), automaticDosingStrategy: .automaticBolus, lastLoopCompletion: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, mostRecentGlucoseDataDate: FakeLastLoopCompletionPublisher().$mockLastLoopCompletion, diff --git a/Loop/View Models/StatusTableViewModel.swift b/Loop/View Models/StatusTableViewModel.swift index 62e3713575..06756c006c 100644 --- a/Loop/View Models/StatusTableViewModel.swift +++ b/Loop/View Models/StatusTableViewModel.swift @@ -27,17 +27,15 @@ class StatusTableViewModel { let criticalEventLogExportManager: CriticalEventLogExportManager let bluetoothStateManager: BluetoothStateManager let settingsManager: SettingsManager - let automaticDosingStatus: AutomaticDosingStatus let onboardingManager: OnboardingManager let temporaryPresetsManager: TemporaryPresetsManager let settingsViewModel: SettingsViewModel var pendingPreset: SelectablePreset? - init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, settingsViewModel: SettingsViewModel) { + init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, settingsViewModel: SettingsViewModel) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter - self.automaticDosingStatus = automaticDosingStatus self.deviceDataManager = deviceDataManager self.onboardingManager = onboardingManager self.supportManager = supportManager diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index 2785a4a0ed..e6ce0989cf 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -204,7 +204,7 @@ class InsulinDeliveryLogViewModel { insulinSuspended = true } - let automationEnabled = loopDataManager.automaticDosingStatus.automaticDosingEnabled + let automationEnabled = loopDataManager.settingsProvider.automaticDosingEnabled let automatedTreatmentState = pumpManager.pumpManagerDelegate?.automatedTreatmentState ?? .neutralNoOverride if insulinSuspended { @@ -267,7 +267,7 @@ class InsulinDeliveryLogViewModel { } private func handleBasalEvent(dose: DoseEntry, decision: LightDosingDecision?, events: inout [InsulinDeliveryLogEvent]) { - let automationEnabledDuringDose = loopDataManager.automationHistory.automationEnabled(at: dose.startDate) ?? loopDataManager.automaticDosingStatus.automaticDosingEnabled + let automationEnabledDuringDose = loopDataManager.automationHistory.automationEnabled(at: dose.startDate) ?? loopDataManager.settingsProvider.automaticDosingEnabled if dose.type == .tempBasal && dose.automatic == false { events.append( diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 7ec7e5f895..c1e109c77f 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -253,7 +253,7 @@ extension SettingsView { ) { HStack(spacing: 12) { LoopCircleView( - closedLoop: viewModel.automaticDosingStatus.automaticDosingEnabled, + closedLoop: viewModel.automaticDosingEnabled, freshness: viewModel.loopStatusCircleFreshness ) .frame(width: 36, height: 36) diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 33e6dc0a91..4e040e189c 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -16,7 +16,6 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { private let alertPermissionsChecker: AlertPermissionsChecker private let alertMuter: AlertMuter - private let automaticDosingStatus: AutomaticDosingStatus private let deviceDataManager: DeviceDataManager private let onboardingManager: OnboardingManager private let supportManager: SupportManager @@ -36,10 +35,9 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { let viewController: StatusTableViewController - init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, automaticDosingStatus: AutomaticDosingStatus, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, statusTableViewModel: StatusTableViewModel) { + init(alertPermissionsChecker: AlertPermissionsChecker, alertMuter: AlertMuter, deviceDataManager: DeviceDataManager, onboardingManager: OnboardingManager, supportManager: SupportManager, testingScenariosManager: TestingScenariosManager?, settingsManager: SettingsManager, temporaryPresetsManager: TemporaryPresetsManager, loopDataManager: LoopDataManager, diagnosticReportGenerator: DiagnosticReportGenerator, simulatedData: SimulatedData, analyticsServicesManager: AnalyticsServicesManager, servicesManager: ServicesManager, carbStore: CarbStore, doseStore: DoseStore, criticalEventLogExportManager: CriticalEventLogExportManager, bluetoothStateManager: BluetoothStateManager, statusTableViewModel: StatusTableViewModel) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter - self.automaticDosingStatus = automaticDosingStatus self.deviceDataManager = deviceDataManager self.onboardingManager = onboardingManager self.supportManager = supportManager @@ -61,7 +59,6 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { let statusTableViewController = storyboard.instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController statusTableViewController.alertPermissionsChecker = alertPermissionsChecker statusTableViewController.alertMuter = alertMuter - statusTableViewController.automaticDosingStatus = automaticDosingStatus statusTableViewController.deviceManager = deviceDataManager statusTableViewController.onboardingManager = onboardingManager statusTableViewController.supportManager = supportManager @@ -108,7 +105,6 @@ struct StatusTableView: View { self.wrapped = WrappedStatusTableViewController( alertPermissionsChecker: viewModel.alertPermissionsChecker, alertMuter: viewModel.alertMuter, - automaticDosingStatus: viewModel.automaticDosingStatus, deviceDataManager: viewModel.deviceDataManager, onboardingManager: viewModel.onboardingManager, supportManager: viewModel.supportManager, From c1e734293b68ab1e1112e93a9ab12f9b073999c0 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 20 Oct 2025 13:51:35 -0300 Subject: [PATCH 304/421] [LOOP-5509] correcting unit tests (#847) * correcting unit tests * correcting observation --- Loop/Managers/DeviceDataManager.swift | 2 +- Loop/Managers/ExtensionDataManager.swift | 4 ++-- Loop/Managers/LoopDataManager.swift | 4 ++-- Loop/Managers/SettingsManager.swift | 5 ++--- Loop/View Models/SettingsViewModel.swift | 2 +- .../InsulinDeliveryLogViewModel.swift | 4 ++-- LoopTests/Managers/DeviceDataManagerTests.swift | 2 -- LoopTests/Managers/LoopDataManagerTests.swift | 12 ++++-------- LoopTests/Mocks/MockSettingsProvider.swift | 6 +++++- 9 files changed, 19 insertions(+), 22 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 3f4b23fa6f..8aa3818032 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1095,7 +1095,7 @@ extension DeviceDataManager: PumpManagerDelegate { } var automaticDosingEnabled: Bool { - settingsManager.automaticDosingEnabled + settingsManager.dosingEnabled } } diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift index c7b035dea3..63c84df095 100644 --- a/Loop/Managers/ExtensionDataManager.swift +++ b/Loop/Managers/ExtensionDataManager.swift @@ -119,9 +119,9 @@ final class ExtensionDataManager { context.mostRecentGlucoseDataDate = loopDataManager.mostRecentGlucoseDataDate context.mostRecentPumpDataDate = loopDataManager.mostRecentPumpDataDate - context.isClosedLoop = self.settingsManager.automaticDosingEnabled + context.isClosedLoop = self.settingsManager.dosingEnabled - context.preMealPresetAllowed = self.settingsManager.automaticDosingEnabled && self.settingsManager.settings.preMealTargetRange != nil + context.preMealPresetAllowed = self.settingsManager.dosingEnabled && self.settingsManager.settings.preMealTargetRange != nil context.preMealPresetActive = self.temporaryPresetsManager.isPreMealTargetActive() context.customPresetActive = self.temporaryPresetsManager.isNonPreMealOverrideActive() diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 09ebae2f60..1126097d28 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -261,7 +261,7 @@ final class LoopDataManager: ObservableObject { // 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. - withObservationTracking(of: settingsProvider.automaticDosingEnabled) { [weak self] enabled in + withObservationTracking(of: settingsProvider.dosingEnabled) { [weak self] enabled in if self?.automationHistory.last?.enabled != enabled { self?.automationHistory.append(AutomationHistoryEntry(startDate: Date(), enabled: enabled)) @@ -631,7 +631,7 @@ final class LoopDataManager: ObservableObject { dosingDecision.updateFrom(input: input, output: output) - if self.settingsProvider.automaticDosingEnabled { + if self.settingsProvider.dosingEnabled { if deliveryDelegate.basalDeliveryState == .pumpInoperable { throw LoopError.pumpInoperable } diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index 63373d18a4..bf47f600bb 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -399,9 +399,9 @@ extension SettingsManager { } @MainActor -protocol SettingsProvider { +protocol SettingsProvider: Observable { var settings: StoredSettings { get } - var automaticDosingEnabled: Bool { get } + var dosingEnabled: Bool { get } func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] func getCarbRatioHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] @@ -413,7 +413,6 @@ protocol SettingsProvider { extension SettingsManager: SettingsProvider { var settings: StoredSettings { storedSettings } - var automaticDosingEnabled: Bool { dosingEnabled } func executeSettingsQuery(fromQueryAnchor queryAnchor: SettingsStore.QueryAnchor?, limit: Int, completion: @escaping (SettingsStore.SettingsQueryResult) -> Void) { settingsStore!.executeSettingsQuery(fromQueryAnchor: queryAnchor, limit: limit, completion: completion) diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index db25378fd2..1d23ab994e 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -197,7 +197,7 @@ extension SettingsViewModel { fileprivate class FakeSettingsProvider: SettingsProvider { let settings = StoredSettings() - let automaticDosingEnabled = true + var dosingEnabled: Bool { settings.dosingEnabled } func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { [] diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index e6ce0989cf..55813fd320 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -204,7 +204,7 @@ class InsulinDeliveryLogViewModel { insulinSuspended = true } - let automationEnabled = loopDataManager.settingsProvider.automaticDosingEnabled + let automationEnabled = loopDataManager.settingsProvider.dosingEnabled let automatedTreatmentState = pumpManager.pumpManagerDelegate?.automatedTreatmentState ?? .neutralNoOverride if insulinSuspended { @@ -267,7 +267,7 @@ class InsulinDeliveryLogViewModel { } private func handleBasalEvent(dose: DoseEntry, decision: LightDosingDecision?, events: inout [InsulinDeliveryLogEvent]) { - let automationEnabledDuringDose = loopDataManager.automationHistory.automationEnabled(at: dose.startDate) ?? loopDataManager.settingsProvider.automaticDosingEnabled + let automationEnabledDuringDose = loopDataManager.automationHistory.automationEnabled(at: dose.startDate) ?? loopDataManager.settingsProvider.dosingEnabled if dose.type == .tempBasal && dose.automatic == false { events.append( diff --git a/LoopTests/Managers/DeviceDataManagerTests.swift b/LoopTests/Managers/DeviceDataManagerTests.swift index e1620fd10d..b3725a8545 100644 --- a/LoopTests/Managers/DeviceDataManagerTests.swift +++ b/LoopTests/Managers/DeviceDataManagerTests.swift @@ -39,7 +39,6 @@ final class DeviceDataManagerTests: XCTestCase { let mockUserNotificationCenter = MockUserNotificationCenter() let mockBluetoothProvider = MockBluetoothProvider() let alertPresenter = MockPresenter() - let automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true) let alertManager = AlertManager( alertPresenter: alertPresenter, @@ -99,7 +98,6 @@ final class DeviceDataManagerTests: XCTestCase { activeStatefulPluginsProvider: self, bluetoothProvider: mockBluetoothProvider, alertPresenter: alertPresenter, - automaticDosingStatus: automaticDosingStatus, cacheStore: persistenceController, localCacheDuration: .days(1), displayGlucosePreference: DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter), diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 79e764e305..f77c8953e2 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -77,7 +77,6 @@ class LoopDataManagerTests: XCTestCase { var glucoseStore = MockGlucoseStore() var carbStore = MockCarbStore() var dosingDecisionStore: MockDosingDecisionStore! - var automaticDosingStatus: AutomaticDosingStatus! var loopDataManager: LoopDataManager! var deliveryDelegate: MockDeliveryDelegate! var settingsProvider: MockSettingsProvider! @@ -105,7 +104,7 @@ class LoopDataManagerTests: XCTestCase { )! let settings = StoredSettings( - dosingEnabled: false, + dosingEnabled: true, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, maximumBasalRatePerHour: 6, maximumBolus: 5, @@ -123,7 +122,6 @@ class LoopDataManagerTests: XCTestCase { doseStore.lastAddedPumpData = now dosingDecisionStore = MockDosingDecisionStore() - automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true) let temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsProvider, presetHistory: TemporaryScheduleOverrideHistory()) @@ -137,7 +135,6 @@ class LoopDataManagerTests: XCTestCase { crashRecoveryManager: CrashRecoveryManager(alertIssuer: MockAlertIssuer()), dosingDecisionStore: dosingDecisionStore, now: { [weak self] in self?.now ?? Date() }, - automaticDosingStatus: automaticDosingStatus, trustedTimeOffset: { 0 }, analyticsServicesManager: nil, carbAbsorptionModel: .piecewiseLinear @@ -200,7 +197,7 @@ class LoopDataManagerTests: XCTestCase { )! settingsProvider.settings = StoredSettings( - dosingEnabled: false, + dosingEnabled: true, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, maximumBasalRatePerHour: 10, maximumBolus: 5, @@ -349,8 +346,7 @@ class LoopDataManagerTests: XCTestCase { deliveryDelegate.basalDeliveryState = .tempBasal(dose) dosingDecisionStore.storeExpectation = expectation(description: #function) - - automaticDosingStatus.automaticDosingEnabled = false + settingsProvider.dosingEnabled = false await fulfillment(of: [dosingDecisionStore.storeExpectation!], timeout: 1.0) @@ -429,7 +425,7 @@ class LoopDataManagerTests: XCTestCase { glucoseStore.storedGlucose = [ StoredGlucoseSample(startDate: d(.minutes(-1)), quantity: .glucose(value: 150)), ] - automaticDosingStatus.automaticDosingEnabled = false + settingsProvider.dosingEnabled = false settingsProvider.settings.automaticDosingStrategy = .tempBasalOnly await loopDataManager.loop() diff --git a/LoopTests/Mocks/MockSettingsProvider.swift b/LoopTests/Mocks/MockSettingsProvider.swift index c73041511c..7f9774ccee 100644 --- a/LoopTests/Mocks/MockSettingsProvider.swift +++ b/LoopTests/Mocks/MockSettingsProvider.swift @@ -11,7 +11,10 @@ import LoopKit import LoopAlgorithm @testable import Loop +@Observable class MockSettingsProvider: SettingsProvider { + var dosingEnabled: Bool = false + var basalHistory: [AbsoluteScheduleValue]? func getBasalHistory(startDate: Date, endDate: Date) async throws -> [AbsoluteScheduleValue] { return basalHistory ?? settings.basalRateSchedule?.between(start: startDate, end: endDate) ?? [] @@ -46,7 +49,8 @@ class MockSettingsProvider: SettingsProvider { var settings: StoredSettings - init(settings: StoredSettings) { + init(settings: StoredSettings, dosingEnabled: Bool = true) { self.settings = settings + self.dosingEnabled = dosingEnabled } } From fbc690d818eb27eb04d47c4060d24f65d5ebb2a9 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 21 Oct 2025 12:19:52 -0300 Subject: [PATCH 305/421] [LOOP-5458] adding new line to modal copy (#850) --- Loop/Managers/TemporaryPresetsManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index e4d03c9ef8..202d5696f0 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -345,7 +345,7 @@ class TemporaryPresetsManager { let title = NSLocalizedString("Start Scheduled Preset?", comment: "Scheduled preset reminder title") let body = String( - format: NSLocalizedString("Would you like to start your %1$@ preset? This will end any active preset.", comment: "Scheduled preset reminder alert body. (1: preset name)"), + format: NSLocalizedString("Would you like to start your %1$@ preset?\n\nThis will end any active preset.", comment: "Scheduled preset reminder alert body. (1: preset name)"), preset.name ) From 9f31161e2340b99d714ed54bb79ae7461a3761f0 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 21 Oct 2025 11:27:32 -0500 Subject: [PATCH 306/421] LOOP-5495 ios26 fixes (#848) * Reduce navigation layers to fix ios26 dismissal issue * Resolve conflicts * Fix crash on displaying notifications warning * Bypass unneeded menuitem -> id mapping --- Loop/Managers/AlertPermissionsChecker.swift | 8 +-- Loop/Views/SettingsView.swift | 66 ++++++++------------- 2 files changed, 28 insertions(+), 46 deletions(-) diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index 211d498123..e83df9008a 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -234,11 +234,11 @@ extension AlertPermissionsChecker { } } + @MainActor static func constructUnsafeNotificationPermissionsInAppAlert(alert: UnsafeNotificationPermissionAlert) async -> UIAlertController { - dispatchPrecondition(condition: .onQueue(.main)) - let alertController = await UIAlertController(title: alert.alertTitle, - message: alert.alertBody, - preferredStyle: .alert) + 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) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index c1e109c77f..5afaf35e23 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -51,7 +51,6 @@ struct SettingsView: View { } case favoriteFoods - case therapySettings case presets } } @@ -97,8 +96,8 @@ struct SettingsView: View { servicesSection } - ForEach(customSections) { customSectionName in - menuItemsForSection(name: customSectionName) + ForEach(pluginMenuItems) { item in + item.view } supportSection @@ -141,16 +140,8 @@ struct SettingsView: View { .sheet(item: $sheet) { sheet in Group { switch sheet { - case .therapySettings: - TherapySettingsView( - mode: .settings, - viewModel: TherapySettingsViewModel( - therapySettings: viewModel.therapySettings(), - delegate: viewModel.therapySettingsViewModelDelegate - ) - ) case .presets: - presetsView + PresetsView() case .favoriteFoods: FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) } @@ -168,10 +159,6 @@ struct SettingsView: View { .navigationViewStyle(.stack) } - public var presetsView: some View { - PresetsView() - } - private func menuItemsForSection(name: String) -> some View { Section(header: SectionHeader(label: name)) { ForEach(pluginMenuItems.filter {$0.section.customLocalizedTitle == name}) { item in @@ -180,16 +167,6 @@ struct SettingsView: View { } } - private var customSections: [String] { - pluginMenuItems.compactMap { item in - if case .custom(let name) = item.section { - return name - } else { - return nil - } - } - } - private var closedLoopToggleState: Binding { Binding( get: { self.viewModel.closedLoopPreference }, @@ -198,13 +175,6 @@ struct SettingsView: View { } } -extension String: Identifiable { - public typealias ID = Int - public var id: Int { - return hash - } -} - struct PluginMenuItem: Identifiable { var id: String { return pluginIdentifier + String(describing: offset) @@ -331,16 +301,28 @@ extension SettingsView { } } } - + + private var therapySettingsView: some View { + TherapySettingsView( + mode: .settings, + viewModel: TherapySettingsViewModel( + therapySettings: viewModel.therapySettings(), + delegate: viewModel.therapySettingsViewModelDelegate + ) + ) + } + private var therapySection: some View { Section { - LargeButton(action: { sheet = .therapySettings }, - includeArrow: true, - imageView: Image("Therapy Icon"), - label: NSLocalizedString("Therapy Settings", comment: "Title text for button to Therapy Settings"), - descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) - .accessibilityIdentifier("button_TherapySettings") - + NavigationLink(destination: therapySettingsView) { + LargeButton(action: {}, + includeArrow: false, + imageView: Image("Therapy Icon"), + label: NSLocalizedString("Therapy Settings", comment: "Title text for button to Therapy Settings"), + descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + .accessibilityIdentifier("button_TherapySettings") + } + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in item.view } @@ -350,7 +332,7 @@ extension SettingsView { } } } - + private var presetsSection: some View { Section { LargeButton( From ae55e6c94dc67dd694fdb1955fe5bd6f620cd9e2 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 21 Oct 2025 11:34:53 -0500 Subject: [PATCH 307/421] LOOP-5430 (#846) * Last Bolus and Current Delivery for watch * Add suspended and no delivery states to watch insulin delivery state * Remove need to add AutomatedTreatmentState to loopkit watch framework * Remove debug print * Allow bidirectional scrolling * Reduce navigation layers to fix ios26 dismissal issue --- Loop.xcodeproj/project.pbxproj | 10 +++ Loop/Managers/DeviceDataManager.swift | 9 ++- Loop/Managers/LoopDataManager.swift | 27 +++++--- Loop/Managers/WatchDataManager.swift | 23 ++++++- .../StatusTableViewController.swift | 16 ++--- Loop/View Models/BolusEntryViewModel.swift | 2 +- Loop/Views/SettingsView.swift | 2 +- LoopCore/Models/LastManualBolus.swift | 32 ++++++++++ LoopCore/Models/WatchContext.swift | 28 +++++++++ LoopCore/SelectablePreset.swift | 2 +- .../Managers/LoopDataManager.swift | 9 ++- .../Views/ActiveOverrideView.swift | 14 ++--- WatchApp Extension/Views/ChartPageView.swift | 62 ++++++++++++++----- .../Extensions/AutomatedTreatmentState.swift | 45 ++++++++++++++ WatchApp Extension/Views/LabelValueRow.swift | 14 +++-- .../Views/PresetActivateCrownConfirm.swift | 14 ++--- .../Views/PresetWatchCard.swift | 10 +++ 17 files changed, 255 insertions(+), 64 deletions(-) create mode 100644 LoopCore/Models/LastManualBolus.swift create mode 100644 WatchApp Extension/Views/Extensions/AutomatedTreatmentState.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 1f0bed5c01..192beac8b8 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -516,6 +516,9 @@ C1DA43552B193BCB00CBD33F /* MockUploadEventListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43542B193BCB00CBD33F /* MockUploadEventListener.swift */; }; C1DA43572B1A70BE00CBD33F /* SettingsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */; }; C1DA43592B1A784900CBD33F /* MockDeliveryDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA43582B1A784900CBD33F /* MockDeliveryDelegate.swift */; }; + C1DCEDDD2E983A22001A7BB0 /* AutomatedTreatmentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEDDC2E983A1B001A7BB0 /* AutomatedTreatmentState.swift */; }; + C1DCEDF42E999D5E001A7BB0 /* LastManualBolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */; }; + C1DCEDF52E999D5E001A7BB0 /* LastManualBolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */; }; C1DE5D23251BFC4D00439E49 /* SimpleBolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */; }; C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1E2773D224177C000354103 /* ClockKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */; }; @@ -1552,6 +1555,8 @@ C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerTests.swift; sourceTree = ""; }; C1DA43582B1A784900CBD33F /* MockDeliveryDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDeliveryDelegate.swift; sourceTree = ""; }; C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistedProperty.swift; sourceTree = ""; }; + C1DCEDDC2E983A1B001A7BB0 /* AutomatedTreatmentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomatedTreatmentState.swift; sourceTree = ""; }; + C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastManualBolus.swift; sourceTree = ""; }; C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SimpleBolusView.swift; sourceTree = ""; }; C1E2773D224177C000354103 /* ClockKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ClockKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/ClockKit.framework; sourceTree = DEVELOPER_DIR; }; C1E2774722433D7A00354103 /* MKRingProgressView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MKRingProgressView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2617,6 +2622,7 @@ 895788B4242E69C8002CB114 /* Extensions */ = { isa = PBXGroup; children = ( + C1DCEDDC2E983A1B001A7BB0 /* AutomatedTreatmentState.swift */, 89E08FC7242E76E9000D719B /* AnyTransition.swift */, 895788A9242E69A1002CB114 /* Color.swift */, 89F9118E24352F1600ECCAF3 /* DigitalCrownRotation.swift */, @@ -2837,6 +2843,7 @@ C1ED6C632E7C6DA6002F91C2 /* Models */ = { isa = PBXGroup; children = ( + C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */, C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */, A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */, 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */, @@ -3847,6 +3854,7 @@ 89A605E72432860C009C1096 /* PeriodicPublisher.swift in Sources */, 895788AE242E69A2002CB114 /* CarbAndBolusFlow.swift in Sources */, 89A605F12432BD18009C1096 /* BolusConfirmationVisual.swift in Sources */, + C1DCEDDD2E983A22001A7BB0 /* AutomatedTreatmentState.swift in Sources */, 898ECA65218ABD9B001E9D35 /* CGRect.swift in Sources */, 43CB2B2B1D924D450079823D /* WCSession.swift in Sources */, 43BFF0B51E45C1E700FF19A9 /* NumberFormatter.swift in Sources */, @@ -3876,6 +3884,7 @@ C1ED6C622E79BBA5002F91C2 /* NotificationManager.swift in Sources */, C1ED6C7B2E7C6FE6002F91C2 /* WatchContextRequestUserInfo.swift in Sources */, C1ED6C6F2E7C6EDE002F91C2 /* IntentExtensionInfo.swift in Sources */, + C1DCEDF42E999D5E001A7BB0 /* LastManualBolus.swift in Sources */, C1ED6C752E7C6F36002F91C2 /* WatchContext.swift in Sources */, 43D9002021EB209400AF44BF /* NSTimeInterval.swift in Sources */, C1ED6C712E7C6F07002F91C2 /* SetBolusUserInfo.swift in Sources */, @@ -3911,6 +3920,7 @@ C1ED6C612E79BBA5002F91C2 /* NotificationManager.swift in Sources */, C1ED6C7A2E7C6FE5002F91C2 /* WatchContextRequestUserInfo.swift in Sources */, C1ED6C6E2E7C6EDE002F91C2 /* IntentExtensionInfo.swift in Sources */, + C1DCEDF52E999D5E001A7BB0 /* LastManualBolus.swift in Sources */, C1ED6C742E7C6F36002F91C2 /* WatchContext.swift in Sources */, 43D9FFFB21EAF3D300AF44BF /* NSTimeInterval.swift in Sources */, C1ED6C702E7C6F07002F91C2 /* SetBolusUserInfo.swift in Sources */, diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 8aa3818032..a37e5c3f12 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1359,7 +1359,14 @@ extension DeviceDataManager: DeliveryDelegate { var isSuspended: Bool { return pumpManager?.status.basalDeliveryState?.isSuspended ?? false } - + + var isPumpInoperable: Bool { + guard let basalDeliveryState else { + return true + } + return basalDeliveryState == .pumpInoperable + } + func enact(bolus: Double?, tempBasal: TempBasalRecommendation?, decisionId: UUID?) async throws { guard let pumpManager = pumpManager else { throw LoopError.configurationError(.pumpManager) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 1126097d28..4304a8b6a0 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -113,6 +113,8 @@ final class LoopDataManager: ObservableObject { @Published private(set) var lastLoopCompleted: Date? @Published private(set) var publishedMostRecentGlucoseDataDate: Date? @Published private(set) var publishedMostRecentPumpDataDate: Date? + @Published private(set) var lastManualBolus: LastManualBolus? + var deliveryDelegate: DeliveryDelegate? @@ -213,7 +215,6 @@ final class LoopDataManager: ObservableObject { queue: nil ) { (note) -> Void in Task { @MainActor in - self.logger.default("Received notification of carb entries changing") await self.updateDisplayState() self.notify(forChange: .carbs) } @@ -224,19 +225,17 @@ final class LoopDataManager: ObservableObject { queue: nil ) { (note) in Task { @MainActor in - self.logger.default("Received notification of glucose samples changing") self.restartGlucoseValueStalenessTimer() await self.updateDisplayState() self.notify(forChange: .glucose) } }, NotificationCenter.default.addObserver( - forName: nil, + forName: DoseStore.valuesDidChange, object: self.doseStore, queue: OperationQueue.main ) { (note) in Task { @MainActor in - self.logger.default("Received notification of dosing changing") await self.updateDisplayState() self.notify(forChange: .insulin) } @@ -249,7 +248,6 @@ final class LoopDataManager: ObservableObject { 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() self.notify(forChange: .forecast) } @@ -496,15 +494,26 @@ final class LoopDataManager: ObservableObject { } func updateDisplayState() async { + var newState = AlgorithmDisplayState() do { - let midnight = Calendar.current.startOfDay(for: Date()) + let lastManualBolusVisibilityWindowStartDate = Date().addingTimeInterval(.days(-1)) - var input = try await fetchData(for: now(), ensureDosingCoverageStart: midnight) + var input = try await fetchData(for: now(), ensureDosingCoverageStart: lastManualBolusVisibilityWindowStartDate) input.recommendationType = .manualBolus newState.input = input newState.output = LoopAlgorithm.run(input: input) + let lastStoredManualBolus = input.doses.last( + where: { + $0.startDate >= lastManualBolusVisibilityWindowStartDate && $0.deliveryType == .bolus && $0.automatic == false + }) + + if let lastStoredManualBolus, + self.lastManualBolus == nil || lastStoredManualBolus.startDate >= self.lastManualBolus!.startDate + { + self.lastManualBolus = LastManualBolus(amount: lastStoredManualBolus.volume, startDate: lastStoredManualBolus.startDate) + } } catch { let loopError = error as? LoopError ?? .unknownError(error) logger.error("Error updating Loop state: %{public}@", String(describing: loopError)) @@ -1256,7 +1265,9 @@ extension LoopDataManager: SimpleBolusViewModelDelegate { } func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws { + let startDate = Date() try await deliveryDelegate?.enactBolus(units: units, decisionId: decisionId, activationType: activationType) + lastManualBolus = LastManualBolus(amount: units, startDate: startDate) } } @@ -1585,7 +1596,7 @@ extension LoopDataManager: LoopControl { return deliveryDelegate?.basalDeliveryState?.currentBasalRate(currentScheduledBasalRate: scheduledBasalRate) } - var automatedTreatmentState: LoopKit.AutomatedTreatmentState? { + var automatedTreatmentState: AutomatedTreatmentState? { guard let input = displayState.input else { return nil } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index abbaa25ed2..02dd87cfe4 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -312,7 +312,7 @@ final class WatchDataManager: NSObject { dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate( recommendation: recommendedBolus, date: Date()) - log.debug("*** watch bolus recommended: %{public}@ (with carb entry: %{public}@", String(describing: recommendedBolus.amount), String(describing: potentialCarbEntry)) + log.debug("watch bolus recommended: %{public}@ (with carb entry: %{public}@", String(describing: recommendedBolus.amount), String(describing: potentialCarbEntry)) } var historicalGlucose: [HistoricalGlucoseValue]? @@ -336,6 +336,27 @@ final class WatchDataManager: NSObject { context.iob = loopDataManager.activeInsulin?.value + if deviceManager.isPumpInoperable { + context.insulinDeliveryState = .noDelivery + } else if deviceManager.isSuspended { + context.insulinDeliveryState = .suspended + } else if let automatedTreatmentState = loopDataManager.automatedTreatmentState { + switch automatedTreatmentState { + case .neutralNoOverride: + context.insulinDeliveryState = .neutralNoOverride + case .neutralOverride: + context.insulinDeliveryState = .neutralOverride + case .increasedInsulin: + context.insulinDeliveryState = .increasedInsulin + case .decreasedInsulin: + context.insulinDeliveryState = .decreasedInsulin + case .minimumDelivery: + context.insulinDeliveryState = .minimumDelivery + } + } + + context.lastManualBolus = loopDataManager.lastManualBolus + dosingDecision.historicalGlucose = historicalGlucose dosingDecision.insulinOnBoard = loopDataManager.activeInsulin diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 9c37a2eb78..18f6068b73 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -421,8 +421,6 @@ final class StatusTableViewController: LoopChartsTableViewController { charts.updateEndDate(charts.maxEndDate) } - private var lastDoseEntry: DoseEntry? - override func reloadData(animated: Bool = false) async { dispatchPrecondition(condition: .onQueue(.main)) @@ -510,8 +508,6 @@ final class StatusTableViewController: LoopChartsTableViewController { if currentContext.contains(.insulin) { doseEntries = try? await loopManager.doseStore.getNormalizedDoseEntries(start: startDate, end: nil) - lastDoseEntry = try? await loopManager.doseStore.getNormalizedDoseEntries(start: Date().addingTimeInterval(.days(-1)), end: nil).filter({ $0.automatic == false }).last - iobValues = loopManager.iobValues.filterDateRange(startDate, nil) totalDelivery = await loopManager.totalDeliveredToday()?.value } @@ -1117,15 +1113,13 @@ final class StatusTableViewController: LoopChartsTableViewController { } } } - + @ViewBuilder private func iobFooterViewContent() -> some View { - let formatter = QuantityFormatter(for: .internationalUnit) - - if let lastManualDose = lastDoseEntry, let formattedBolusValue = formatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: lastManualDose.deliveredUnits ?? lastManualDose.value)), lastManualDose.endDate <= Date() { - - let hoursDifference = Date().timeIntervalSince(lastManualDose.endDate) / 3600 - + if let lastManualDose = loopManager.lastManualBolus, let formattedBolusValue = insulinFormatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: lastManualDose.amount)) { + + let hoursDifference = Date().timeIntervalSince(lastManualDose.startDate) / 3600 + let lastBolusLabel = Text("Last Bolus: ") let lastBolusValue = Text("\(formattedBolusValue) ").fontWeight(.semibold) let icon = Text(Image(systemName: "hourglass.bottomhalf.filled")).foregroundStyle(.secondary) diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 27a211049e..3396dd2fdf 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -405,7 +405,7 @@ final class BolusEntryViewModel: ObservableObject { do { try await delegate.enactBolus(units: amountToDeliver, decisionId: dosingDecision.id, activationType: activationType) } catch { - log.error("Failed to store bolus: %{public}@", String(describing: error)) + log.error("Failed to enact bolus: %{public}@", String(describing: error)) } self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 5afaf35e23..c87e4d4013 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -322,7 +322,7 @@ extension SettingsView { descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) .accessibilityIdentifier("button_TherapySettings") } - + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in item.view } diff --git a/LoopCore/Models/LastManualBolus.swift b/LoopCore/Models/LastManualBolus.swift new file mode 100644 index 0000000000..58185173b4 --- /dev/null +++ b/LoopCore/Models/LastManualBolus.swift @@ -0,0 +1,32 @@ +// +// LastManualBolus.swift +// Loop +// +// Created by Pete Schwamb on 10/10/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +public struct LastManualBolus: RawRepresentable { + public typealias RawValue = [String: Any] + + public let amount: Double + public let startDate: Date + + public init (amount: Double, startDate: Date) { + self.amount = amount + self.startDate = startDate + } + + public init?(rawValue: RawValue) { + guard let amount = rawValue["amount"] as? Double, + let startDate = rawValue["startDate"] as? Date else { + return nil + } + self.amount = amount + self.startDate = startDate + } + + public var rawValue: [String : Any] { + ["amount": amount, "startDate": startDate] + } +} diff --git a/LoopCore/Models/WatchContext.swift b/LoopCore/Models/WatchContext.swift index 691b01d6cb..b1073ccebd 100644 --- a/LoopCore/Models/WatchContext.swift +++ b/LoopCore/Models/WatchContext.swift @@ -10,6 +10,15 @@ import Foundation import LoopKit import LoopAlgorithm +public enum InsulinDeliveryWatchState: Int, Equatable { + case neutralNoOverride + case neutralOverride + case increasedInsulin + case decreasedInsulin + case minimumDelivery + case suspended + case noDelivery +} public final class WatchContext: RawRepresentable { public typealias RawValue = [String: Any] @@ -38,6 +47,8 @@ public final class WatchContext: RawRepresentable { public var lastNetTempBasalDose: Double? public var lastNetTempBasalDate: Date? public var recommendedBolusDose: Double? + public var insulinDeliveryState: InsulinDeliveryWatchState? + public var lastManualBolus: LastManualBolus? public var potentialCarbEntry: NewCarbEntry? @@ -74,6 +85,8 @@ public final class WatchContext: RawRepresentable { reservoirPercentage: Double? = nil, batteryPercentage: Double? = nil, cgmManagerState: CGMManager.RawStateValue? = nil, + insulinDeliveryState: InsulinDeliveryWatchState? = nil, + lastManualBolus: LastManualBolus? = nil, isClosedLoop: Bool? = nil ) { self.creationDate = creationDate @@ -98,6 +111,8 @@ public final class WatchContext: RawRepresentable { self.reservoirPercentage = reservoirPercentage self.batteryPercentage = batteryPercentage self.cgmManagerState = cgmManagerState + self.insulinDeliveryState = insulinDeliveryState + self.lastManualBolus = lastManualBolus self.isClosedLoop = isClosedLoop } @@ -138,6 +153,15 @@ public final class WatchContext: RawRepresentable { loopLastRunDate = rawValue["ld"] as? Date lastNetTempBasalDose = rawValue["ba"] as? Double lastNetTempBasalDate = rawValue["bad"] as? Date + + if let rawInsulinDeliveryState = rawValue["ids"] as? InsulinDeliveryWatchState.RawValue { + insulinDeliveryState = InsulinDeliveryWatchState(rawValue: rawInsulinDeliveryState) + } + + if let rawLastManualBolus = rawValue["lmb"] as? LastManualBolus.RawValue { + lastManualBolus = LastManualBolus(rawValue: rawLastManualBolus) + } + recommendedBolusDose = rawValue["rbo"] as? Double if let rawPotentialCarbEntry = rawValue["pce"] as? NewCarbEntry.RawValue { potentialCarbEntry = NewCarbEntry(rawValue: rawPotentialCarbEntry) @@ -184,6 +208,10 @@ public final class WatchContext: RawRepresentable { raw["iob"] = iob raw["ld"] = loopLastRunDate raw["r"] = reservoir + + raw["ids"] = insulinDeliveryState?.rawValue + raw["lmb"] = lastManualBolus?.rawValue + raw["rbo"] = recommendedBolusDose raw["pce"] = potentialCarbEntry?.rawValue raw["rp"] = reservoirPercentage diff --git a/LoopCore/SelectablePreset.swift b/LoopCore/SelectablePreset.swift index 6c096ecf3e..9450e94b69 100644 --- a/LoopCore/SelectablePreset.swift +++ b/LoopCore/SelectablePreset.swift @@ -126,7 +126,7 @@ public enum SelectablePreset: Hashable, Identifiable { switch self { case .custom(let preset): return preset.symbol case .preMeal: return .image("Pre-Meal-symbol", tint: .preMeal) - case .activity(let activity): return activity.preset.symbol + case .activity(let activity): return activity.activityType.symbol } } diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index 3123648eba..ec799864a9 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -283,7 +283,14 @@ extension LoopDataManager { ActivityPreset.ActivityType.allCases.forEach { activityType in if !settings.overridePresets.contains(where: { $0.id == activityType.id }) { - presets.append(.activity(ActivityPreset(activityType: activityType, preset: activityType.defaultPreset(duration: .finite(.minutes(90)))))) + presets.append( + .activity( + ActivityPreset( + activityType: activityType, + preset: activityType.defaultPreset(duration: .finite(.minutes(90))) + ) + ) + ) } } diff --git a/WatchApp Extension/Views/ActiveOverrideView.swift b/WatchApp Extension/Views/ActiveOverrideView.swift index a47009a2da..9560e61326 100644 --- a/WatchApp Extension/Views/ActiveOverrideView.swift +++ b/WatchApp Extension/Views/ActiveOverrideView.swift @@ -18,8 +18,6 @@ struct ActiveOverrideView: View { @State private var endingPreset: Bool = false @State private var lastInteractionTime: Date? // Tracks last crown interaction - private let threshold: CGFloat = 20 // Rotation threshold to trigger action - private let maxProgress: CGFloat = 20 // Max progress for the bar private let resetDelay: TimeInterval = 0.25 // pause for reset let override: TemporaryScheduleOverride @@ -92,7 +90,7 @@ struct ActiveOverrideView: View { if endingPreset { return 1 } else { - return min(crownValue, maxProgress)/threshold + return abs(crownValue) } } @@ -123,11 +121,9 @@ struct ActiveOverrideView: View { .focusable() // Required for Digital Crown interaction .digitalCrownRotation( $crownValue, - from: 0, - through: threshold, - by: 1, - sensitivity: .medium, - isContinuous: false + over: -1...1, + sensitivity: .low, + scalingRotationBy: 4 ) .onChange(of: crownValue) { (oldValue, newValue) in lastInteractionTime = Date() @@ -141,7 +137,7 @@ struct ActiveOverrideView: View { } } - if newValue >= threshold && !endingPreset { + if abs(newValue) >= 1 && !endingPreset { withAnimation(.spring(response: 0.2, dampingFraction: 0.5)) { endingPreset = true Task { diff --git a/WatchApp Extension/Views/ChartPageView.swift b/WatchApp Extension/Views/ChartPageView.swift index e0781317dc..a2ed88c942 100644 --- a/WatchApp Extension/Views/ChartPageView.swift +++ b/WatchApp Extension/Views/ChartPageView.swift @@ -9,6 +9,7 @@ import SwiftUI import LoopKit import LoopCore +import LoopAlgorithm import SpriteKit struct ChartPageView: View { @@ -18,6 +19,8 @@ struct ChartPageView: View { @State private var isShowingCarbList: Bool = false + @ScaledMetric private var iconSize: Double = 26 + var presetActive: Bool { return loopManager.watchInfo.scheduleOverride?.isActive() == true } @@ -86,6 +89,28 @@ struct ChartPageView: View { return carbFormatter.string(from: activeCarbohydrates) } + var lastBolus: Text { + guard let lastBolus = loopManager.activeContext?.lastManualBolus else { + return Text("-") + } + + let bolusFormatter = QuantityFormatter(for: .internationalUnit) + bolusFormatter.numberFormatter.minimumFractionDigits = 1 + bolusFormatter.numberFormatter.maximumFractionDigits = 1 + + + let dateFormatter = DateFormatter() + dateFormatter.timeStyle = .short + dateFormatter.dateStyle = .none + + let bolusVolume = bolusFormatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: lastBolus.amount))! + let bolusTime = dateFormatter.string(from: lastBolus.startDate) + + return + Text("\(bolusVolume)") + + Text(" at \(bolusTime)").font(.caption).foregroundColor(.secondary) + } + var netTempBasalDose: String? { guard let activeContext = loopManager.activeContext, let tempBasal = activeContext.lastNetTempBasalDose @@ -132,29 +157,32 @@ struct ChartPageView: View { LoopHeader() chartView - VStack { - LabelValueRow( - label: "Active Insulin", - value: activeInsulin - ) + VStack(spacing: 8) { + LabelValueRow("Active Insulin") { + Text(activeInsulin ?? "-") + } Divider() - LabelValueRow( - label: "Active Carbs", - value: activeCarbohydrates - ) + LabelValueRow("Active Carbs") { + Text(activeCarbohydrates ?? "-") + } .onTapGesture { isShowingCarbList = true } Divider() - LabelValueRow( - label: "Net Basal Rate", - value: netTempBasalDose - ) + LabelValueRow("Last Bolus") { + lastBolus + } + if let currentDelivery = loopManager.activeContext?.insulinDeliveryState { + Divider() + LabelValueRow("Current Delivery") { + Text(currentDelivery.iconImage) + + Text(" " + currentDelivery.shortDescription) + } + } Divider() - LabelValueRow( - label: "Reservoir Volume", - value: reservoirVolume - ) + LabelValueRow("Reservoir Volume") { + Text(reservoirVolume ?? "-") + } } .padding(.horizontal) } diff --git a/WatchApp Extension/Views/Extensions/AutomatedTreatmentState.swift b/WatchApp Extension/Views/Extensions/AutomatedTreatmentState.swift new file mode 100644 index 0000000000..7e9c6a65c6 --- /dev/null +++ b/WatchApp Extension/Views/Extensions/AutomatedTreatmentState.swift @@ -0,0 +1,45 @@ +// +// AutomatedTreatmentState.swift +// Loop +// +// Created by Pete Schwamb on 10/9/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopKit +import SwiftUI +import LoopCore + +extension InsulinDeliveryWatchState { + var shortDescription: String { + switch self { + case .neutralNoOverride: + return NSLocalizedString("Scheduled", comment: "Title for neutral delivery state") + case .neutralOverride: + return NSLocalizedString("Preset Delivery", comment: "Title for neutral delivery state state with preset adjusting basal") + case .increasedInsulin: + return NSLocalizedString("Increased", comment: "Title for increased insulin delivery state state") + case .decreasedInsulin, .minimumDelivery: + return NSLocalizedString("Decreased", comment: "Title for increased insulin delivery state") + case .suspended: + return NSLocalizedString("Suspended", comment: "Title for increased insulin delivery state state") + case .noDelivery: + return NSLocalizedString("No Delivery", comment: "Title for increased insulin delivery state state") + } + } + + var iconImage: Image { + switch self { + case .neutralNoOverride, .neutralOverride: + Image(systemName: "arrow.right.square.fill") + case .increasedInsulin: + Image(systemName: "arrow.up.square.fill") + case .decreasedInsulin, .minimumDelivery: + Image(systemName: "arrow.down.square.fill") + case .suspended: + Image(systemName: "pause.circle.fill") + case .noDelivery: + Image(systemName: "x.circle.fill") + } + } +} diff --git a/WatchApp Extension/Views/LabelValueRow.swift b/WatchApp Extension/Views/LabelValueRow.swift index d84933ad4d..748c97779f 100644 --- a/WatchApp Extension/Views/LabelValueRow.swift +++ b/WatchApp Extension/Views/LabelValueRow.swift @@ -8,17 +8,23 @@ import SwiftUI -struct LabelValueRow: View { + +struct LabelValueRow: View { let label: LocalizedStringKey - let value: String? + let value: ValueView + + init(_ label: LocalizedStringKey, @ViewBuilder value: () -> ValueView) { + self.label = label + self.value = value() + } var body: some View { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 4) { Text(label) .font(.footnote) .foregroundStyle(.secondary) .textCase(.uppercase) - Text(value ?? "–") + value .font(.title3) .foregroundStyle(.primary) } diff --git a/WatchApp Extension/Views/PresetActivateCrownConfirm.swift b/WatchApp Extension/Views/PresetActivateCrownConfirm.swift index eb870cdacc..03f5ff55db 100644 --- a/WatchApp Extension/Views/PresetActivateCrownConfirm.swift +++ b/WatchApp Extension/Views/PresetActivateCrownConfirm.swift @@ -18,8 +18,6 @@ struct PresetActivateCrownConfirm: View { @State private var startingPreset: Bool = false @State private var lastInteractionTime: Date? // Tracks last crown interaction - private let threshold: CGFloat = 20 // Rotation threshold to trigger action - private let maxProgress: CGFloat = 20 // Max progress for the bar private let resetDelay: TimeInterval = 0.25 // pause for reset let preset: SelectablePreset @@ -28,7 +26,7 @@ struct PresetActivateCrownConfirm: View { if startingPreset { return 1 } else { - return min(crownValue, maxProgress)/threshold + return abs(crownValue) } } @@ -56,11 +54,9 @@ struct PresetActivateCrownConfirm: View { .focusable() // Required for Digital Crown interaction .digitalCrownRotation( $crownValue, - from: 0, - through: threshold, - by: 1, - sensitivity: .medium, - isContinuous: false + over: -1...1, + sensitivity: .low, + scalingRotationBy: 4 ) .onDisappear() { if let reminder = loopManager.pendingPresetReminder, reminder.presetIdentifier == preset.id { @@ -84,7 +80,7 @@ struct PresetActivateCrownConfirm: View { } } - if newValue >= threshold && !startingPreset { + if abs(newValue) >= 1 && !startingPreset { withAnimation(.spring(response: 0.2, dampingFraction: 0.5)) { startingPreset = true Task { diff --git a/WatchApp Extension/Views/PresetWatchCard.swift b/WatchApp Extension/Views/PresetWatchCard.swift index 980fb9de74..90367e4330 100644 --- a/WatchApp Extension/Views/PresetWatchCard.swift +++ b/WatchApp Extension/Views/PresetWatchCard.swift @@ -69,6 +69,16 @@ struct PresetWatchCard: View { let correctionRange: ClosedRange? let isScheduled: Bool + init(presetId: String, icon: PresetSymbol?, presetName: String, duration: PresetDuration, insulinMultiplier: Double?, correctionRange: ClosedRange?, isScheduled: Bool) { + self.presetId = presetId + self.icon = icon + self.presetName = presetName + self.duration = duration + self.insulinMultiplier = insulinMultiplier + self.correctionRange = correctionRange + self.isScheduled = isScheduled + } + private var numberFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .percent From c9c8f6c1b3d522dd43eeaceb5e72a5d5f187aa16 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 21 Oct 2025 14:01:25 -0300 Subject: [PATCH 308/421] [LOOP-5515] truncate time ago for loop status caption (#849) * truncate time ago for loop status caption * addressing PR comments about pluralized strings --- LocalizablePlural.xcstrings | 75 ++++++++++++++++++++++++ Loop.xcodeproj/project.pbxproj | 7 +++ LoopUI/Views/LoopCompletionHUDView.swift | 43 ++++++++++---- 3 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 LocalizablePlural.xcstrings diff --git a/LocalizablePlural.xcstrings b/LocalizablePlural.xcstrings new file mode 100644 index 0000000000..ef7bb6d41d --- /dev/null +++ b/LocalizablePlural.xcstrings @@ -0,0 +1,75 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%d day" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d day" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d days" + } + } + } + } + } + } + }, + "%d hr" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d hr" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d hrs" + } + } + } + } + } + } + }, + "%d min" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d min" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d mins" + } + } + } + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 192beac8b8..2a8eae33fe 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -378,6 +378,7 @@ B491B0A424D0B675004CBE8F /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B11E45C18400FF19A9 /* UIColor.swift */; }; B4AC0D3F24B9005300CDB0A1 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEE31CDE5C0A003C8C80 /* UIImage.swift */; }; B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */; }; + B4C6D2422EA7E38C006F5755 /* LocalizablePlural.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */; }; B4C9859425D5A3BB009FD9CA /* StatusBadgeHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */; }; B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */; }; B4D620D424D9EDB900043B3C /* GuidanceColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D620D324D9EDB900043B3C /* GuidanceColors.swift */; }; @@ -1286,6 +1287,7 @@ B490A04024D0559D00F509FA /* DeviceLifecycleProgressState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLifecycleProgressState.swift; sourceTree = ""; }; B490A04224D055D900F509FA /* DeviceStatusHighlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusHighlight.swift; sourceTree = ""; }; B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDViewModelTests.swift; sourceTree = ""; }; + B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = LocalizablePlural.xcstrings; sourceTree = ""; }; B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadgeHUDView.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 = ""; }; @@ -1939,6 +1941,7 @@ A900531928D60852000BC15B /* Shortcuts */, 968DCD53F724DE56FFE51920 /* Frameworks */, 43776F8D1B8022E90074EA36 /* Products */, + B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */, 437D9BA11D7B5203007245E8 /* Loop.xcconfig */, A951C5FF23E8AB51003E26DC /* Version.xcconfig */, ); @@ -3281,6 +3284,7 @@ B405E35A24D2B1A400DD058D /* HUDAssets.xcassets in Resources */, A966152623EA5A26005D8B29 /* DefaultAssets.xcassets in Resources */, A966152723EA5A26005D8B29 /* DerivedAssets.xcassets in Resources */, + B4C6D2422EA7E38C006F5755 /* LocalizablePlural.xcstrings in Resources */, 7D70764F1FE06EE1004AC8EA /* InfoPlist.strings in Resources */, 7D7076631FE06EE4004AC8EA /* Localizable.strings in Resources */, 43776F971B8022E90074EA36 /* Main.storyboard in Resources */, @@ -4792,6 +4796,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -4901,6 +4906,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -5304,6 +5310,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index 8f02e9942b..683a943a2a 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -129,15 +129,32 @@ public final class LoopCompletionHUDView: BaseHUDView { private var lastLoopMessage: String = "" - private lazy var timeAgoFormatter: DateComponentsFormatter = { - let formatter = DateComponentsFormatter() - - formatter.allowedUnits = [.day, .hour, .minute] - formatter.maximumUnitCount = 1 - formatter.unitsStyle = .short - - return formatter - }() + /// Formats a time interval as a truncated "time ago" string (e.g., "1 hr", "2 mins") + private func truncatedTimeAgoString(from interval: TimeInterval) -> String? { + let calendar = Calendar.current + let now = Date() + let past = now.addingTimeInterval(-interval) + + let components = calendar.dateComponents([.day, .hour, .minute], from: past, to: now) + if let days = components.day, days > 0 { + return String.localizedStringWithFormat( + NSLocalizedString("%d day", tableName: "LocalizablePlural", bundle: .main, value: "%d day", comment: "Singular/plural day count"), + days + ) + } else if let hours = components.hour, hours > 0 { + return String.localizedStringWithFormat( + NSLocalizedString("%d hr", tableName: "LocalizablePlural", bundle: .main, value: "%d hr", comment: "Singular/plural hour count"), + hours + ) + } else if let minutes = components.minute { + return String.localizedStringWithFormat( + NSLocalizedString("%d min", tableName: "LocalizablePlural", bundle: .main, value: "%d min", comment: "Singular/plural minute count"), + minutes + ) + } else { + return nil + } + } private lazy var timeFormatter: DateFormatter = { let formatter = DateFormatter() @@ -165,7 +182,7 @@ public final class LoopCompletionHUDView: BaseHUDView { freshness = LoopCompletionFreshness(age: ago) - if let timeString = timeAgoFormatter.string(from: ago) { + if let timeString = truncatedTimeAgoString(from: ago) { switch traitCollection.preferredContentSizeCategory { case UIContentSizeCategory.extraSmall, UIContentSizeCategory.small, @@ -183,11 +200,11 @@ public final class LoopCompletionHUDView: BaseHUDView { if ago >= timeAgoToIncludeDate { fullTimeStr = String(format: LocalizedString("was at %1$@", comment: "Format string describing last completion. (1: the date"), timeDateFormatter.string(from: date)) } else if ago >= timeAgoToIncludeTimeStamp { - fullTimeStr = String(format: LocalizedString("%1$@ ago at %2$@", comment: "Format string describing last completion. (1: time ago, (2: the date"), timeAgoFormatter.string(from: ago)!, timeFormatter.string(from: date)) + fullTimeStr = String(format: LocalizedString("%1$@ ago at %2$@", comment: "Format string describing last completion. (1: time ago, (2: the date"), truncatedTimeAgoString(from: ago)!, timeFormatter.string(from: date)) } else if ago < .minutes(1) { fullTimeStr = String(format: LocalizedString("<1 min ago", comment: "Format string describing last completion")) } else { - fullTimeStr = String(format: LocalizedString("%1$@ ago", comment: "Format string describing last completion. (1: time ago"), timeAgoFormatter.string(from: ago)!) + fullTimeStr = String(format: LocalizedString("%1$@ ago", comment: "Format string describing last completion. (1: time ago"), truncatedTimeAgoString(from: ago)!) } lastLoopMessage = String(format: LocalizedString("Last completed loop %1$@.", comment: "Last loop time completed message (1: last loop time string)"), fullTimeStr) } else { @@ -199,7 +216,7 @@ public final class LoopCompletionHUDView: BaseHUDView { freshness = LoopCompletionFreshness(age: ago) - if let timeString = timeAgoFormatter.string(from: ago) { + if let timeString = truncatedTimeAgoString(from: ago) { switch traitCollection.preferredContentSizeCategory { case UIContentSizeCategory.extraSmall, UIContentSizeCategory.small, From b6704955019e145593a8a44825c879dace31ab4a Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 24 Oct 2025 16:38:53 -0300 Subject: [PATCH 309/421] [LOOP-5396-5484] corrected copy (#852) --- .../Insulin Delivery Log/InsulinDeliveryOverview.swift | 8 ++++++-- .../Presets/Training Content/PresetsTrainingContent.swift | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift index 34d79ebbe3..0d9a78cc77 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryOverview.swift @@ -92,10 +92,14 @@ struct InsulinDeliveryOverview: View { var statusTitle: Text { switch state { - case .automationOn(let basalStatus, _): + case .automationOn(let basalStatus, let preset): switch basalStatus { case .scheduled: - Text("Scheduled Basal") + if let preset, preset.insulinNeedsScaleFactor != 1.0 { + Text("Preset Delivery") + } else { + Text("Scheduled Basal") + } case .increased: Text("Increased Delivery") case .decreased: diff --git a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift index 8e15b11e7a..407ecd85e3 100644 --- a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift +++ b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift @@ -842,7 +842,7 @@ extension PresetsTraining.Step: PresetsTrainingContent { TintedContent( tint: .orange, icon: Image(systemName: "fork.knife"), - title: Text("Active Insulin") + title: Text("Meal Timing") ) { Text("If you often experience low glucose, you may need to reduce how much insulin you deliver for meals eaten 1-2 hours before exercising.") From e31c3c21534ad9eba715477d3028d4c72429d407 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 24 Oct 2025 17:07:02 -0300 Subject: [PATCH 310/421] [LOOP-5496] automation modal (#851) * added the modal view and most elements. message is still missing * added messaging. --- Loop.xcodeproj/project.pbxproj | 10 + .../DeviceDataManager+DeviceStatus.swift | 4 + Loop/Extensions/TimeInterval.swift | 37 +++ .../StatusTableViewController.swift | 38 ++- Loop/Views/LoopStatusModalView.swift | 232 ++++++++++++++++++ LoopTests/Mocks/MockCGMManager.swift | 4 + LoopTests/Mocks/MockPumpManager.swift | 4 + LoopUI/Views/LoopCompletionHUDView.swift | 135 +--------- 8 files changed, 320 insertions(+), 144 deletions(-) create mode 100644 Loop/Extensions/TimeInterval.swift create mode 100644 Loop/Views/LoopStatusModalView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 2a8eae33fe..7b87c72724 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -363,6 +363,7 @@ B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4001CED28CBBC82002FB414 /* AlertManagementView.swift */; }; B405E35A24D2B1A400DD058D /* HUDAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */; }; B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */; }; + B429CAB42E97C97000FA988E /* LoopStatusModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B429CAB22E97C7F300FA988E /* LoopStatusModalView.swift */; }; B42C951424A3C76000857C73 /* CGMStatusHUDViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */; }; B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42D124228D371C400E43D22 /* AlertMuter.swift */; }; B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */; }; @@ -379,6 +380,8 @@ B4AC0D3F24B9005300CDB0A1 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEE31CDE5C0A003C8C80 /* UIImage.swift */; }; B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */; }; B4C6D2422EA7E38C006F5755 /* LocalizablePlural.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */; }; + B4C6D2442EAA2AC2006F5755 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C6D2432EAA2AB4006F5755 /* TimeInterval.swift */; }; + B4C6D2452EAA2C83006F5755 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C6D2432EAA2AB4006F5755 /* TimeInterval.swift */; }; B4C9859425D5A3BB009FD9CA /* StatusBadgeHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */; }; B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */; }; B4D620D424D9EDB900043B3C /* GuidanceColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D620D324D9EDB900043B3C /* GuidanceColors.swift */; }; @@ -1275,6 +1278,7 @@ A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDosingDecision.swift; sourceTree = ""; }; B4001CED28CBBC82002FB414 /* AlertManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertManagementView.swift; sourceTree = ""; }; B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseDisplay.swift; sourceTree = ""; }; + B429CAB22E97C7F300FA988E /* LoopStatusModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStatusModalView.swift; sourceTree = ""; }; B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDViewModel.swift; sourceTree = ""; }; B42D124228D371C400E43D22 /* AlertMuter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertMuter.swift; sourceTree = ""; }; B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowMuteAlertWorkView.swift; sourceTree = ""; }; @@ -1288,6 +1292,7 @@ B490A04224D055D900F509FA /* DeviceStatusHighlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusHighlight.swift; sourceTree = ""; }; B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDViewModelTests.swift; sourceTree = ""; }; B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = LocalizablePlural.xcstrings; sourceTree = ""; }; + B4C6D2432EAA2AB4006F5755 /* TimeInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeInterval.swift; sourceTree = ""; }; B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadgeHUDView.swift; sourceTree = ""; }; B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = ""; }; B4D620D324D9EDB900043B3C /* GuidanceColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidanceColors.swift; sourceTree = ""; }; @@ -2172,6 +2177,7 @@ A9CBE45B248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift */, C1FB428B217806A300FAB378 /* StateColorPalette.swift */, C1F2CAA92B76B3EE00D7F581 /* TempBasalRecommendation.swift */, + B4C6D2432EAA2AB4006F5755 /* TimeInterval.swift */, 43F89CA222BDFBBC006BB54E /* UIActivityIndicatorView.swift */, 43F41C361D3BF32400C11ED6 /* UIAlertController.swift */, A9F66FC2247F451500096EA7 /* UIDevice+Loop.swift */, @@ -2241,6 +2247,7 @@ DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */, 84D1F1A62D09053A00CB271F /* StatusTableView.swift */, + B429CAB22E97C7F300FA988E /* LoopStatusModalView.swift */, ); path = Views; sourceTree = ""; @@ -3549,6 +3556,7 @@ 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, 4372E48B213CB5F00068E043 /* Double.swift in Sources */, 430B29932041F5B300BA9F93 /* UserDefaults+Loop.swift in Sources */, + B429CAB42E97C97000FA988E /* LoopStatusModalView.swift in Sources */, 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */, E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */, C1F7822627CC056900C0919A /* SettingsManager.swift in Sources */, @@ -3662,6 +3670,7 @@ 4328E0351CFC0AE100E199AA /* WatchDataManager.swift in Sources */, 4345E3FC21F04911009E00E5 /* UIColor+HIG.swift in Sources */, 1D4A3E2E2478628500FD601B /* StoredAlert+CoreDataProperties.swift in Sources */, + B4C6D2442EAA2AC2006F5755 /* TimeInterval.swift in Sources */, C10509752D8B309400118A37 /* Environment+SettingsManager.swift in Sources */, E95D380124EADE7C005E2F50 /* DoseStoreProtocol.swift in Sources */, 43D381621EBD9759007F8C8F /* HeaderValuesTableViewCell.swift in Sources */, @@ -4014,6 +4023,7 @@ B43DA44124D9C12100CAFF4E /* DismissibleHostingController.swift in Sources */, 4F7528A51DFE208C00C322D6 /* NSTimeInterval.swift in Sources */, A9C62D8E2331708700535612 /* AuthenticationTableViewCell+NibLoadable.swift in Sources */, + B4C6D2452EAA2C83006F5755 /* TimeInterval.swift in Sources */, B490A04324D055D900F509FA /* DeviceStatusHighlight.swift in Sources */, B4AC0D3F24B9005300CDB0A1 /* UIImage.swift in Sources */, B4E96D5B248A8229002DABAD /* StatusBarHUDView.swift in Sources */, diff --git a/Loop/Extensions/DeviceDataManager+DeviceStatus.swift b/Loop/Extensions/DeviceDataManager+DeviceStatus.swift index 5f08105b2e..4b610d7cd9 100644 --- a/Loop/Extensions/DeviceDataManager+DeviceStatus.swift +++ b/Loop/Extensions/DeviceDataManager+DeviceStatus.swift @@ -11,6 +11,10 @@ import LoopKitUI import LoopCore extension DeviceDataManager { + var hasBluetoothIssue: Bool { + bluetoothProvider.bluetoothState == .poweredOff || bluetoothProvider.bluetoothState == .unauthorized || bluetoothProvider.bluetoothState == .unsupported + } + var cgmStatusHighlight: DeviceStatusHighlight? { let bluetoothState = bluetoothProvider.bluetoothState if bluetoothState == .unsupported || bluetoothState == .unauthorized { diff --git a/Loop/Extensions/TimeInterval.swift b/Loop/Extensions/TimeInterval.swift new file mode 100644 index 0000000000..51ce4fc433 --- /dev/null +++ b/Loop/Extensions/TimeInterval.swift @@ -0,0 +1,37 @@ +// +// TimeInterval.swift +// Loop +// +// Created by Nathaniel Hamming on 2025-10-23. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// +import Foundation + +extension TimeInterval { + /// Formats a time interval as a truncated "time ago" string (e.g., "1 hr", "2 mins") + var truncatedTimeAgoString: String? { + let calendar = Calendar.current + let now = Date() + let past = now.addingTimeInterval(-self) + + let components = calendar.dateComponents([.day, .hour, .minute], from: past, to: now) + if let days = components.day, days > 0 { + return String.localizedStringWithFormat( + NSLocalizedString("%d day", tableName: "LocalizablePlural", bundle: .main, value: "%d day", comment: "Singular/plural day count"), + days + ) + } else if let hours = components.hour, hours > 0 { + return String.localizedStringWithFormat( + NSLocalizedString("%d hr", tableName: "LocalizablePlural", bundle: .main, value: "%d hr", comment: "Singular/plural hour count"), + hours + ) + } else if let minutes = components.minute { + return String.localizedStringWithFormat( + NSLocalizedString("%d min", tableName: "LocalizablePlural", bundle: .main, value: "%d min", comment: "Singular/plural minute count"), + minutes + ) + } else { + return nil + } + } +} diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 18f6068b73..8d629b9e34 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -430,7 +430,7 @@ final class StatusTableViewController: LoopChartsTableViewController { // This should be kept up to date immediately hudView?.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted - hudView?.loopCompletionHUD.deviceInoperable = deviceManager.cgmManager == nil || deviceManager.pumpManager == nil || basalDeliveryState == .pumpInoperable + hudView?.loopCompletionHUD.deviceInoperable = deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true || deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true || deviceManager.hasBluetoothIssue hudView?.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate hudView?.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate @@ -1661,18 +1661,30 @@ final class StatusTableViewController: LoopChartsTableViewController { } @objc private func showLoopCompletionMessage(_: Any) { - guard let loopCompletionMessage = hudView?.loopCompletionHUD.loopCompletionMessage else { return } - presentLoopCompletionMessage(title: loopCompletionMessage.title, message: loopCompletionMessage.message) - } - - private func presentLoopCompletionMessage(title: String, message: String) { - let action = UIAlertAction(title: NSLocalizedString("Dismiss", comment: "The button label of the action used to dismiss an error alert"), - style: .default) - let alertController = UIAlertController(title: title, - message: message, - preferredStyle: .alert) - alertController.addAction(action) - present(alertController, animated: true) + let viewModel = LoopStatusModalViewModel( + lastLoopCompleted: loopManager.lastLoopCompleted, + loopIconClosed: automaticDosingEnabled, + hasBluetoothIssue: deviceManager.hasBluetoothIssue, + isDeliverySuspended: deviceManager.isSuspended, + isPumpInSignalLoss: deviceManager.pumpManager?.inSignalLoss == true, + isPumpInoperable: deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true, + isCGMInWarmup: deviceManager.cgmManager?.cgmManagerStatus.inSensorWarmup == true, + isCGMInSignalLoss: deviceManager.cgmManager?.inSignalLoss == true, + isCGMInoperable: deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true) + + let modalVC = UIHostingController( + rootView: LoopStatusModalView(viewModel: viewModel, + onDismiss: { [weak self] in + self?.dismiss(animated: false) + }) + .environment(\.loopStatusColorPalette, .loopStatus) + ) + modalVC.modalPresentationStyle = .overCurrentContext + modalVC.view.backgroundColor = UIColor.black.withAlphaComponent(0.4) + modalVC.view.frame = view.bounds + modalVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + present(modalVC, animated: false) } @objc private func showLastError(_: Any) { diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift new file mode 100644 index 0000000000..083800959c --- /dev/null +++ b/Loop/Views/LoopStatusModalView.swift @@ -0,0 +1,232 @@ +// +// LoopStatusModalView.swift +// Loop +// +// Created by Nathaniel Hamming on 2025-10-09. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct LoopStatusModalView: View { + @Environment(\.loopStatusColorPalette) private var loopStatusColors + + @State private var appear = false + + let viewModel: LoopStatusModalViewModel + var onDismiss: () -> Void + + private var freshnessColor: Color { + switch viewModel.freshness { + case .fresh: return .primary + case .aging: return Color(loopStatusColors.warning) + case .stale: return Color(loopStatusColors.error) + } + } + + var body: some View { + VStack { + closeButton + .padding(5) + .frame(maxWidth: .infinity, alignment: .trailing) + + LoopCircleView(closedLoop: viewModel.loopIconClosed, freshness: viewModel.freshness) + .environment(\.loopStatusColorPalette, loopStatusColors) + .padding(.bottom) + + if viewModel.loopIconClosed, + let lastLoopCompletedFormattedTime = viewModel.lastLoopCompletedFormattedTime + { + lastLoopCompleted(lastLoopCompletedString: lastLoopCompletedFormattedTime) + } + + automationDetails + .padding([.top, .horizontal]) + .padding(.bottom, 10) + } + .padding(10) + .background(Color.white) + .cornerRadius(10) + .shadow(radius: 5) + .frame(maxWidth: 340) + .animation(.spring(), value: appear) + .onAppear { + withAnimation { + appear = true + } + } + } + + private var closeButton: some View { + Button("\(Image(systemName: "xmark"))") { + withAnimation(.spring()) { + appear = false + } + // Dismiss after animation delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + onDismiss() + } + } + .foregroundStyle(.primary) + } + + private func lastLoopCompleted(lastLoopCompletedString: String) -> some View { + Group { + Text("Last loop completed") + Text("\(Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90")) \(lastLoopCompletedString)") + .foregroundStyle(freshnessColor) + if viewModel.includeTimeStamp { + Text(viewModel.formattedLastLoopCompleted) + .foregroundStyle(freshnessColor) + } + } + .font(.footnote) + .fontWeight(.semibold) + } + + private var automationDetails: some View { + VStack(alignment: .center) { + automationTitle + automationMessage + } + } + + private var automationTitle: some View { + Text(viewModel.copy.title) + .font(.title2) + .bold() + .multilineTextAlignment(.center) + } + + private var automationMessage: some View { + Text(viewModel.copy.message) + .multilineTextAlignment(.center) + } +} + +struct LoopStatusModalViewModel { + private var timeDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + formatter.locale = Locale.current + return formatter + }() + + var lastLoopCompleted: Date? + var freshness: LoopCompletionFreshness { + LoopCompletionFreshness(age: ago) + } + var ago: TimeInterval? { + guard let lastLoopCompleted else { return nil } + return abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) + } + var includeTimeStamp: Bool { // only include if last loop was before today + guard let lastLoopCompleted else { return false } + let startOfToday = Calendar.current.startOfDay(for: Date()) + return lastLoopCompleted < startOfToday + } + var formattedLastLoopCompleted: String { + guard let lastLoopCompleted else { return "Unknown" } + return String(format: NSLocalizedString("at %1$@", comment: "when adding a timestamp. (1: the formatted timestamp)"), timeDateFormatter.string(from: lastLoopCompleted)) + } + + var loopIconClosed: Bool + + var hasBluetoothIssue: Bool + + var isPumpInSignalLoss: Bool + var isPumpInoperable: Bool + var isDeliverySuspended: Bool + + var isCGMInWarmup: Bool + var isCGMInSignalLoss: Bool + var isCGMInoperable: Bool + + var copy: (title: String, message: String) { + guard loopIconClosed else { + if hasBluetoothIssue || isPumpInoperable || isPumpInSignalLoss { + return (titleDeviceIssue, NSLocalizedString("Tap your CGM or insulin pump status icons right away for more information and steps to resolve the issue.", comment: "message when automation is off and there is a bluetooth or pump issue")) + } else if isDeliverySuspended { + return (titleAutomationOff, NSLocalizedString("Resume insulin if you wish for the app to restart insulin delivery.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on.", comment: "message when automation is off and insulin delivery is suspended")) + } else if isCGMInoperable { + return (titleDeviceIssue, NSLocalizedString("Tap your CGM status icon right away for more information and steps to resolve the issue.\n\nIn the meantime, your pump is still able to deliver insulin.", comment: "message when automation is off and CGM is inoperable")) + } else if isCGMInSignalLoss { + return (titleDeviceIssue, NSLocalizedString("Check for potential communication issues with your CGM.\n\nIn the meantime, your pump is still able to deliver insulin.", comment: "message when automation is off and CGM is in signal loss")) + } else if isCGMInWarmup { + return (titleAutomationOff, NSLocalizedString("Your CGM sensor is warming up.\n\nIn the meantime, your pump is still able to deliver insulin.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on.", comment: "message when automation is off and CGM is in warmup")) + } else { + return (titleAutomationOff, NSLocalizedString("Your pump and CGM will continue to operate, but the app will not adjust insulin dosing automatically.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on.", comment: "message when automation is off and devices are good")) + } + } + + if freshness == .fresh { + return (titleAutomationOn, NSLocalizedString("Tidepool Loop will actively adjust your insulin dosing in response to your glucose as often as every 5 minutes.", comment: "message when automation is on and the glucose value is fresh")) + } else if hasBluetoothIssue || isPumpInoperable { + return (titleUnavailable, NSLocalizedString("Tap your CGM or insulin pump status icons right away for more information and steps to resolve the issue.", comment: "message when automation is on and there is a bluetooth or pump issue")) + } else if isPumpInSignalLoss { + return (titleUnsucessful, NSLocalizedString("Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM or insulin pump.", comment: "message when automation is on and pump is in signal loss")) + } else if isDeliverySuspended { + return (titleUnavailable, NSLocalizedString("Automation is unavailable while your insulin is suspended.\n\nResume insulin if you wish for the app to automate insulin delivery.", comment: "message when automation is on and insulin delivery is suspended")) + } else if isCGMInoperable { + return (titleUnavailable, NSLocalizedString("Tap your CGM status icon right away for more information and steps to resolve the issue.\n\nIn the meantime, your pump is still able to deliver insulin.", comment: "message when automation is on and CGM is inoperable")) + } else if isCGMInSignalLoss { + return (titleUnsucessful, NSLocalizedString("Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM.\n\nIn the meantime, your pump is still able to deliver insulin.", comment: "message when automation is on and CGM is in signal loss")) + } else if isCGMInWarmup { + return (titleUnavailable, NSLocalizedString("Automation is unavailable while your CGM sensor is warming up.\n\nIn the meantime, your pump is still able to deliver insulin.\n\nAutomation will resume when CGM readings are received.", comment: "message when automation is on and CGM is in warmup")) + } else { + return (titleUnsucessful, NSLocalizedString("Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM or insulin pump.", comment: "message when automation is on and the glucose value is not fresh")) + } + } + + var titleDeviceIssue: String { + return NSLocalizedString("Device Issue", comment: "title for when automation is off and there is a device issue") + } + + var titleAutomationOff: String { + return NSLocalizedString("Automation is off", comment: "title for when automation is off") + } + + var titleUnavailable: String { + return NSLocalizedString("Automation is unavailable", comment: "title for when automation is unavailable") + } + + var titleUnsucessful: String { + return NSLocalizedString("Automation was unsuccessful", comment: "title for when automation was unsuccessful") + } + + var titleAutomationOn: String { + return NSLocalizedString("Automation is on", comment: "title for when automation is on") + } + + var lastLoopCompletedFormattedTime: String? { + guard let ago, + let timeString = ago.truncatedTimeAgoString + else { return nil } + + return NSLocalizedString("\(timeString) ago", comment: "last loop completed string") + } + + init(lastLoopCompleted: Date? = nil, + loopIconClosed: Bool, + hasBluetoothIssue: Bool, + isDeliverySuspended: Bool, + isPumpInSignalLoss: Bool, + isPumpInoperable: Bool, + isCGMInWarmup: Bool, + isCGMInSignalLoss: Bool, + isCGMInoperable: Bool) + { + self.lastLoopCompleted = lastLoopCompleted + self.loopIconClosed = loopIconClosed + self.hasBluetoothIssue = hasBluetoothIssue + self.isDeliverySuspended = isDeliverySuspended + self.isPumpInSignalLoss = isPumpInSignalLoss + self.isPumpInoperable = isPumpInoperable + self.isCGMInWarmup = isCGMInWarmup + self.isCGMInSignalLoss = isCGMInSignalLoss + self.isCGMInoperable = isCGMInoperable + } +} diff --git a/LoopTests/Mocks/MockCGMManager.swift b/LoopTests/Mocks/MockCGMManager.swift index 736f509ca1..756a6ba765 100644 --- a/LoopTests/Mocks/MockCGMManager.swift +++ b/LoopTests/Mocks/MockCGMManager.swift @@ -10,6 +10,10 @@ import Foundation import LoopKit class MockCGMManager: CGMManager { + var inSignalLoss: Bool = false + + var isInoperable: Bool = false + var cgmManagerDelegate: LoopKit.CGMManagerDelegate? var providesBLEHeartbeat: Bool = false diff --git a/LoopTests/Mocks/MockPumpManager.swift b/LoopTests/Mocks/MockPumpManager.swift index fed34a11b3..2010d141cb 100644 --- a/LoopTests/Mocks/MockPumpManager.swift +++ b/LoopTests/Mocks/MockPumpManager.swift @@ -13,6 +13,10 @@ import HealthKit @testable import Loop class MockPumpManager: PumpManager { + var inSignalLoss: Bool = false + + var isInoperable: Bool = false + var enactBolusCalled: ((Double, BolusActivationType) -> Void)? var enactTempBasalCalled: ((Double, TimeInterval) -> Void)? diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index 683a943a2a..5f39825cfe 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -129,33 +129,6 @@ public final class LoopCompletionHUDView: BaseHUDView { private var lastLoopMessage: String = "" - /// Formats a time interval as a truncated "time ago" string (e.g., "1 hr", "2 mins") - private func truncatedTimeAgoString(from interval: TimeInterval) -> String? { - let calendar = Calendar.current - let now = Date() - let past = now.addingTimeInterval(-interval) - - let components = calendar.dateComponents([.day, .hour, .minute], from: past, to: now) - if let days = components.day, days > 0 { - return String.localizedStringWithFormat( - NSLocalizedString("%d day", tableName: "LocalizablePlural", bundle: .main, value: "%d day", comment: "Singular/plural day count"), - days - ) - } else if let hours = components.hour, hours > 0 { - return String.localizedStringWithFormat( - NSLocalizedString("%d hr", tableName: "LocalizablePlural", bundle: .main, value: "%d hr", comment: "Singular/plural hour count"), - hours - ) - } else if let minutes = components.minute { - return String.localizedStringWithFormat( - NSLocalizedString("%d min", tableName: "LocalizablePlural", bundle: .main, value: "%d min", comment: "Singular/plural minute count"), - minutes - ) - } else { - return nil - } - } - private lazy var timeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .none @@ -182,7 +155,7 @@ public final class LoopCompletionHUDView: BaseHUDView { freshness = LoopCompletionFreshness(age: ago) - if let timeString = truncatedTimeAgoString(from: ago) { + if let timeString = ago.truncatedTimeAgoString { switch traitCollection.preferredContentSizeCategory { case UIContentSizeCategory.extraSmall, UIContentSizeCategory.small, @@ -200,11 +173,11 @@ public final class LoopCompletionHUDView: BaseHUDView { if ago >= timeAgoToIncludeDate { fullTimeStr = String(format: LocalizedString("was at %1$@", comment: "Format string describing last completion. (1: the date"), timeDateFormatter.string(from: date)) } else if ago >= timeAgoToIncludeTimeStamp { - fullTimeStr = String(format: LocalizedString("%1$@ ago at %2$@", comment: "Format string describing last completion. (1: time ago, (2: the date"), truncatedTimeAgoString(from: ago)!, timeFormatter.string(from: date)) + fullTimeStr = String(format: LocalizedString("%1$@ ago at %2$@", comment: "Format string describing last completion. (1: time ago, (2: the date"), ago.truncatedTimeAgoString!, timeFormatter.string(from: date)) } else if ago < .minutes(1) { fullTimeStr = String(format: LocalizedString("<1 min ago", comment: "Format string describing last completion")) } else { - fullTimeStr = String(format: LocalizedString("%1$@ ago", comment: "Format string describing last completion. (1: time ago"), truncatedTimeAgoString(from: ago)!) + fullTimeStr = String(format: LocalizedString("%1$@ ago", comment: "Format string describing last completion. (1: time ago"), ago.truncatedTimeAgoString!) } lastLoopMessage = String(format: LocalizedString("Last completed loop %1$@.", comment: "Last loop time completed message (1: last loop time string)"), fullTimeStr) } else { @@ -216,7 +189,7 @@ public final class LoopCompletionHUDView: BaseHUDView { freshness = LoopCompletionFreshness(age: ago) - if let timeString = truncatedTimeAgoString(from: ago) { + if let timeString = ago.truncatedTimeAgoString { switch traitCollection.preferredContentSizeCategory { case UIContentSizeCategory.extraSmall, UIContentSizeCategory.small, @@ -276,103 +249,3 @@ public final class LoopCompletionHUDView: BaseHUDView { assertTimer() } } - -extension LoopCompletionHUDView { - public var loopCompletionMessage: (title: String, message: String) { - switch freshness { - case .fresh: - if !loopIconClosed { - let reason = closedLoopDisallowedLocalizedDescription ?? LocalizedString( - "Tap Settings to toggle Closed Loop ON if you wish for the app to automate your insulin.", - comment: "Instructions for user to close loop if it is allowed." - ) - - return ( - title: LocalizedString( - "Closed Loop OFF", - comment: "Title of fresh loop OFF message" - ), - message: String( - format: LocalizedString( - "\n%1$@ is operating with Closed Loop in the OFF position. Your pump and CGM will continue operating, but the app will not adjust dosing automatically.\n\n%2$@", - comment: "Fresh closed loop OFF message (1: app name)(2: reason for open loop)" - ), - Bundle.main.bundleDisplayName, - reason - ) - ) - } else { - return ( - title: LocalizedString( - "Closed Loop ON", - comment: "Title of 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: - if !loopIconClosed { - return ( - title: LocalizedString( - "Caution", - comment: "Title of aging open loop message" - ), - message: LocalizedString( - "Tap your CGM and insulin pump status icons for more information. Check for potential communication issues with your pump and CGM.", - comment: "Aging open loop message" - ) - ) - } else { - return ( - title: LocalizedString( - "Loop Warning", - comment: "Title of aging closed loop message" - ), - message: String( - format: LocalizedString( - "\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but watch for potential communication issues with your pump and CGM.", - comment: "Aging loop message (1: last loop string) (2: app name)" - ), - lastLoopMessage, - Bundle.main.bundleDisplayName - ) - ) - } - case .stale: - if !loopIconClosed { - return ( - title: LocalizedString( - "Device Error", - comment: "Title of stale loop message" - ), - message: LocalizedString( - "Tap your CGM and insulin pump status icons for more information. Check for potential communication issues with your pump and CGM.", - comment: "Stale open loop message" - ) - ) - } else { - return ( - title: LocalizedString( - "Loop Failure", - comment: "Title of red loop message" - ), - message: String( - format: LocalizedString( - "\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but check for potential communication issues with your pump and CGM.", - comment: "Red loop message (1: last loop string) (2: app name)" - ), - lastLoopMessage, - Bundle.main.bundleDisplayName - ) - ) - } - } - } -} From 49311cd8482f0f02fcd403250f74b101a182f68f Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 28 Oct 2025 09:14:11 -0300 Subject: [PATCH 311/421] [LOOP-5496] updated copy (#853) --- Loop/Views/LoopStatusModalView.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 083800959c..25ee4cc24c 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -157,14 +157,15 @@ struct LoopStatusModalViewModel { return (titleDeviceIssue, NSLocalizedString("Check for potential communication issues with your CGM.\n\nIn the meantime, your pump is still able to deliver insulin.", comment: "message when automation is off and CGM is in signal loss")) } else if isCGMInWarmup { return (titleAutomationOff, NSLocalizedString("Your CGM sensor is warming up.\n\nIn the meantime, your pump is still able to deliver insulin.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on.", comment: "message when automation is off and CGM is in warmup")) + } else if freshness == .fresh { + return (titleAutomationOff, NSLocalizedString("Your pump and CGM will continue to operate, but the app will not adjust insulin dosing automatically.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on.", comment: "message when automation is off, glucose value is fresh and devices are good")) } else { - return (titleAutomationOff, NSLocalizedString("Your pump and CGM will continue to operate, but the app will not adjust insulin dosing automatically.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on.", comment: "message when automation is off and devices are good")) + return (titleAutomationOff, NSLocalizedString("Make sure your devices are connected and within bluetooth range.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on.", comment: "message when automation is off, glucose value is not fresh and devices are good")) } } - if freshness == .fresh { - return (titleAutomationOn, NSLocalizedString("Tidepool Loop will actively adjust your insulin dosing in response to your glucose as often as every 5 minutes.", comment: "message when automation is on and the glucose value is fresh")) - } else if hasBluetoothIssue || isPumpInoperable { + + if hasBluetoothIssue || isPumpInoperable { return (titleUnavailable, NSLocalizedString("Tap your CGM or insulin pump status icons right away for more information and steps to resolve the issue.", comment: "message when automation is on and there is a bluetooth or pump issue")) } else if isPumpInSignalLoss { return (titleUnsucessful, NSLocalizedString("Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM or insulin pump.", comment: "message when automation is on and pump is in signal loss")) @@ -176,6 +177,8 @@ struct LoopStatusModalViewModel { return (titleUnsucessful, NSLocalizedString("Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM.\n\nIn the meantime, your pump is still able to deliver insulin.", comment: "message when automation is on and CGM is in signal loss")) } else if isCGMInWarmup { return (titleUnavailable, NSLocalizedString("Automation is unavailable while your CGM sensor is warming up.\n\nIn the meantime, your pump is still able to deliver insulin.\n\nAutomation will resume when CGM readings are received.", comment: "message when automation is on and CGM is in warmup")) + } else if freshness == .fresh { + return (titleAutomationOn, NSLocalizedString("Tidepool Loop will actively adjust your insulin dosing in response to your glucose as often as every 5 minutes.", comment: "message when automation is on and the glucose value is fresh")) } else { return (titleUnsucessful, NSLocalizedString("Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM or insulin pump.", comment: "message when automation is on and the glucose value is not fresh")) } From 66bba005140bc0c58ce66ec24449eb8dde7d6d6b Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 28 Oct 2025 09:15:48 -0300 Subject: [PATCH 312/421] [LOOP-5437] Update banner style (#854) * added insulin suspended cell * added no recent glucose cell --- Loop.xcodeproj/project.pbxproj | 16 ++++ .../StatusTableViewController.swift | 28 +++---- .../Views/InsulinSuspendedTableViewCell.swift | 28 +++++++ Loop/Views/InsulinSuspendedTableViewCell.xib | 76 +++++++++++++++++ Loop/Views/RecentGlucoseTableViewCell.swift | 27 +++++++ Loop/Views/RecentGlucoseTableViewCell.xib | 81 +++++++++++++++++++ 6 files changed, 239 insertions(+), 17 deletions(-) create mode 100644 Loop/Views/InsulinSuspendedTableViewCell.swift create mode 100644 Loop/Views/InsulinSuspendedTableViewCell.xib create mode 100644 Loop/Views/RecentGlucoseTableViewCell.swift create mode 100644 Loop/Views/RecentGlucoseTableViewCell.xib diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 7b87c72724..c00ba59034 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -366,6 +366,10 @@ B429CAB42E97C97000FA988E /* LoopStatusModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B429CAB22E97C7F300FA988E /* LoopStatusModalView.swift */; }; B42C951424A3C76000857C73 /* CGMStatusHUDViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */; }; B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42D124228D371C400E43D22 /* AlertMuter.swift */; }; + B43B5C502EAFB17D0096A6AE /* InsulinSuspendedTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B43B5C4F2EAFB17D0096A6AE /* InsulinSuspendedTableViewCell.xib */; }; + B43B5C522EAFB1BE0096A6AE /* InsulinSuspendedTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43B5C512EAFB1B60096A6AE /* InsulinSuspendedTableViewCell.swift */; }; + B43B5C542EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B43B5C532EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib */; }; + B43B5C562EAFBF230096A6AE /* RecentGlucoseTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43B5C552EAFBF170096A6AE /* RecentGlucoseTableViewCell.swift */; }; B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */; }; B43DA44124D9C12100CAFF4E /* DismissibleHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */; }; B455C7332BD14E25002B847E /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B455C7322BD14E25002B847E /* Comparable.swift */; }; @@ -1281,6 +1285,10 @@ B429CAB22E97C7F300FA988E /* LoopStatusModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStatusModalView.swift; sourceTree = ""; }; B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDViewModel.swift; sourceTree = ""; }; B42D124228D371C400E43D22 /* AlertMuter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertMuter.swift; sourceTree = ""; }; + B43B5C4F2EAFB17D0096A6AE /* InsulinSuspendedTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InsulinSuspendedTableViewCell.xib; sourceTree = ""; }; + B43B5C512EAFB1B60096A6AE /* InsulinSuspendedTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinSuspendedTableViewCell.swift; sourceTree = ""; }; + B43B5C532EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RecentGlucoseTableViewCell.xib; sourceTree = ""; }; + B43B5C552EAFBF170096A6AE /* RecentGlucoseTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentGlucoseTableViewCell.swift; sourceTree = ""; }; B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowMuteAlertWorkView.swift; sourceTree = ""; }; B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissibleHostingController.swift; sourceTree = ""; }; B455C7322BD14E25002B847E /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = ""; }; @@ -2230,6 +2238,8 @@ B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */, 430D85881F44037000AF2D4F /* HUDViewTableViewCell.swift */, A91D2A3E26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift */, + B43B5C512EAFB1B60096A6AE /* InsulinSuspendedTableViewCell.swift */, + B43B5C4F2EAFB17D0096A6AE /* InsulinSuspendedTableViewCell.xib */, C1742331259BEADC00399C9D /* ManualEntryDoseView.swift */, 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */, 899433B723FE129700FA4BEA /* OverrideBadgeView.swift */, @@ -2237,6 +2247,8 @@ 438D42FA1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift */, 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, 84E8BBAF2CC979300078E6CF /* Presets */, + B43B5C552EAFBF170096A6AE /* RecentGlucoseTableViewCell.swift */, + B43B5C532EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */, C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */, @@ -3291,9 +3303,11 @@ B405E35A24D2B1A400DD058D /* HUDAssets.xcassets in Resources */, A966152623EA5A26005D8B29 /* DefaultAssets.xcassets in Resources */, A966152723EA5A26005D8B29 /* DerivedAssets.xcassets in Resources */, + B43B5C542EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib in Resources */, B4C6D2422EA7E38C006F5755 /* LocalizablePlural.xcstrings in Resources */, 7D70764F1FE06EE1004AC8EA /* InfoPlist.strings in Resources */, 7D7076631FE06EE4004AC8EA /* Localizable.strings in Resources */, + B43B5C502EAFB17D0096A6AE /* InsulinSuspendedTableViewCell.xib in Resources */, 43776F971B8022E90074EA36 /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3578,6 +3592,7 @@ C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */, 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */, 84475E0E2E5F00B900FC5E7C /* TimelineSteps.swift in Sources */, + B43B5C522EAFB1BE0096A6AE /* InsulinSuspendedTableViewCell.swift in Sources */, 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, C16971F92D1231B5001B7DF6 /* PresetRangeEditor.swift in Sources */, @@ -3671,6 +3686,7 @@ 4345E3FC21F04911009E00E5 /* UIColor+HIG.swift in Sources */, 1D4A3E2E2478628500FD601B /* StoredAlert+CoreDataProperties.swift in Sources */, B4C6D2442EAA2AC2006F5755 /* TimeInterval.swift in Sources */, + B43B5C562EAFBF230096A6AE /* RecentGlucoseTableViewCell.swift in Sources */, C10509752D8B309400118A37 /* Environment+SettingsManager.swift in Sources */, E95D380124EADE7C005E2F50 /* DoseStoreProtocol.swift in Sources */, 43D381621EBD9759007F8C8F /* HeaderValuesTableViewCell.swift in Sources */, diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 8d629b9e34..20c6d04af1 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -93,6 +93,8 @@ final class StatusTableViewController: LoopChartsTableViewController { } tableView.register(BolusProgressTableViewCell.nib(), forCellReuseIdentifier: BolusProgressTableViewCell.className) + tableView.register(InsulinSuspendedTableViewCell.nib(), forCellReuseIdentifier: InsulinSuspendedTableViewCell.className) + tableView.register(RecentGlucoseTableViewCell.nib(), forCellReuseIdentifier: RecentGlucoseTableViewCell.className) if FeatureFlags.predictedGlucoseChartClampEnabled { statusCharts.glucose.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayBoundClamped @@ -1069,19 +1071,17 @@ final class StatusTableViewController: LoopChartsTableViewController { 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") - cell.titleLabel.accessibilityIdentifier = "text_InsulinSuspended" - + let cell = tableView.dequeueReusableCell(withIdentifier: InsulinSuspendedTableViewCell.className, for: indexPath) as! InsulinSuspendedTableViewCell + cell.selectionStyle = .default if resuming { - let indicatorView = UIActivityIndicatorView(style: .default) - indicatorView.startAnimating() - cell.accessoryView = indicatorView + cell.activityIndicator.startAnimating() + cell.activityIndicator.isHidden = false } else { - cell.subtitleLabel.text = NSLocalizedString("Tap to Resume", comment: "The subtitle of the cell displaying an action to resume insulin delivery") - cell.subtitleLabel.accessibilityIdentifier = "text_InsulinTapToResume" + cell.tapToResumeLabel.text = NSLocalizedString("Tap to Resume", comment: "The subtitle of the cell displaying an action to resume insulin delivery") + cell.tapToResumeLabel.accessibilityIdentifier = "text_InsulinTapToResume" + cell.activityIndicator.stopAnimating() + cell.activityIndicator.isHidden = true } - cell.selectionStyle = .default return cell case .onboardingSuspended: let cell = tableView.dequeueReusableCell(withIdentifier: IconTitleSubtitleTableViewCell.className, for: indexPath) as! IconTitleSubtitleTableViewCell @@ -1100,14 +1100,8 @@ final class StatusTableViewController: LoopChartsTableViewController { cell.accessoryView = nil return cell case .recommendManualGlucoseEntry: - let cell = getTitleSubtitleCell() - cell.titleLabel.text = NSLocalizedString("No Recent Glucose", comment: "The title of the cell indicating that there is no recent glucose") - cell.subtitleLabel.text = NSLocalizedString("Tap to Add", comment: "The subtitle of the cell displaying an action to add a manually measurement glucose value") + let cell = tableView.dequeueReusableCell(withIdentifier: RecentGlucoseTableViewCell.className, for: indexPath) as! RecentGlucoseTableViewCell cell.selectionStyle = .default - let imageView = UIImageView(image: UIImage(named: "drop.circle")) - imageView.tintColor = .glucoseTintColor - cell.accessoryView = imageView - cell.titleLabel.accessibilityIdentifier = "text_NoRecentGlucose" return cell } } diff --git a/Loop/Views/InsulinSuspendedTableViewCell.swift b/Loop/Views/InsulinSuspendedTableViewCell.swift new file mode 100644 index 0000000000..4fd26027a5 --- /dev/null +++ b/Loop/Views/InsulinSuspendedTableViewCell.swift @@ -0,0 +1,28 @@ +// +// InsulinSuspendedTableViewCell.swift +// Loop +// +// Created by Nathaniel Hamming on 2025-10-27. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopUI + +public class InsulinSuspendedTableViewCell: UITableViewCell { + @IBOutlet weak var activityIndicator: UIActivityIndicatorView! + @IBOutlet weak var paddedView: UIView! + @IBOutlet weak var label: UILabel! + @IBOutlet weak var tapToResumeLabel: UILabel! + + override public func awakeFromNib() { + super.awakeFromNib() + + paddedView.layer.masksToBounds = true + paddedView.layer.cornerRadius = 10 + paddedView.layer.borderWidth = 1 + paddedView.layer.borderColor = UIColor.systemGray5.cgColor + } +} + +extension InsulinSuspendedTableViewCell: NibLoadable { } diff --git a/Loop/Views/InsulinSuspendedTableViewCell.xib b/Loop/Views/InsulinSuspendedTableViewCell.xib new file mode 100644 index 0000000000..8da610b1e2 --- /dev/null +++ b/Loop/Views/InsulinSuspendedTableViewCell.xib @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Loop/Views/RecentGlucoseTableViewCell.swift b/Loop/Views/RecentGlucoseTableViewCell.swift new file mode 100644 index 0000000000..497a679025 --- /dev/null +++ b/Loop/Views/RecentGlucoseTableViewCell.swift @@ -0,0 +1,27 @@ +// +// RecentGlucoseTableViewCell.swift +// Loop +// +// Created by Nathaniel Hamming on 2025-10-27. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopUI + +public class RecentGlucoseTableViewCell: UITableViewCell { + @IBOutlet weak var paddedView: UIView! + @IBOutlet weak var title: UILabel! + @IBOutlet weak var caption: UILabel! + + override public func awakeFromNib() { + super.awakeFromNib() + + paddedView.layer.masksToBounds = true + paddedView.layer.cornerRadius = 10 + paddedView.layer.borderWidth = 1 + paddedView.layer.borderColor = UIColor.systemGray5.cgColor + } +} + +extension RecentGlucoseTableViewCell: NibLoadable { } diff --git a/Loop/Views/RecentGlucoseTableViewCell.xib b/Loop/Views/RecentGlucoseTableViewCell.xib new file mode 100644 index 0000000000..ff2e0e2b98 --- /dev/null +++ b/Loop/Views/RecentGlucoseTableViewCell.xib @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 7596f45178f7f58cb69d774d52996b5ea9a842a9 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 29 Oct 2025 06:29:39 -0300 Subject: [PATCH 313/421] [LOOP-5517] added warning bounds to guardrails and updated preset guardrails (#855) --- .../Components/InsulinNeedsAdjustmentPreview.swift | 2 ++ .../Presets/Components/InsulinScaleAdjustView.swift | 7 +++++-- Loop/Views/Presets/Components/PresetStatsView.swift | 2 ++ Loop/Views/Presets/CreatePresetView.swift | 12 +++++++----- .../Presets/ExistingPresetInsulinNeedsEdit.swift | 2 +- Loop/Views/Presets/ExistingPresetRangeEdit.swift | 4 ++-- Loop/Views/Presets/NewPresetRangeEdit.swift | 4 ++-- 7 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift b/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift index 1a211ce5cd..3384a5eb7c 100644 --- a/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift +++ b/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift @@ -34,6 +34,8 @@ public struct InsulinNeedsAdjustmentPreview: View { switch threshold { case .minimum, .maximum: return guidanceColors.critical + case .belowWarning, .aboveWarning: + return guidanceColors.critical case .belowRecommended, .aboveRecommended: return guidanceColors.warning } diff --git a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift index e17e5f87f8..a3fb2bd82e 100644 --- a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift +++ b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift @@ -19,6 +19,7 @@ public struct InsulinScaleAdjustView: View { @State private var presentInfoView: Bool = false @Binding var insulinMultiplier: Double + let guardrail: Guardrail var insulinPercentage: Double { (insulinMultiplier * 100).rounded() @@ -74,6 +75,8 @@ public struct InsulinScaleAdjustView: View { switch threshold { case .minimum, .maximum: return guidanceColors.critical + case .belowWarning, .aboveWarning: + return guidanceColors.critical case .belowRecommended, .aboveRecommended: return guidanceColors.warning } @@ -108,13 +111,13 @@ public struct InsulinScaleAdjustView: View { } private func decreaseInsulinMultiplier() { - if insulinPercentage > 10 { + if insulinPercentage > guardrail.absoluteBounds.lowerBound.doubleValue(for: .percent) { insulinMultiplier = insulinPercentage.snap(direction: .down) / 100 } } private func increaseInsulinMultiplier() { - if insulinPercentage < 200 { + if insulinPercentage < guardrail.absoluteBounds.upperBound.doubleValue(for: .percent) { insulinMultiplier = insulinPercentage.snap(direction: .up) / 100 } } diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift index 20dcb287b7..efd10c7da3 100644 --- a/Loop/Views/Presets/Components/PresetStatsView.swift +++ b/Loop/Views/Presets/Components/PresetStatsView.swift @@ -58,6 +58,8 @@ struct PresetStatsView: View { switch threshold { case .aboveRecommended, .belowRecommended: return guidanceColors.warning + case .aboveWarning, .belowWarning: + return guidanceColors.critical case .maximum, .minimum: return guidanceColors.critical } diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift index 44d74b8941..520171d581 100644 --- a/Loop/Views/Presets/CreatePresetView.swift +++ b/Loop/Views/Presets/CreatePresetView.swift @@ -84,7 +84,7 @@ struct CreatePresetView: View { NavigationStack(path: $path) { VStack(spacing: 0) { Form { - InsulinScaleAdjustView(insulinMultiplier: $preset.insulinMultiplier) + InsulinScaleAdjustView(insulinMultiplier: $preset.insulinMultiplier, guardrail: Guardrail.presetInsulinNeeds) } actionArea @@ -184,19 +184,21 @@ struct CreatePresetView: View { extension SafetyClassification.Threshold { public var insulinNeedsScaleWarningTitle: Text { switch self { - case .belowRecommended, .minimum: + case .belowRecommended, .belowWarning, .minimum: return Text("Insulin adjustment is below the safety threshold") - case .aboveRecommended, .maximum: + case .aboveRecommended, .aboveWarning, .maximum: return Text("Insulin adjustment is above the safety threshold") } } public var insulinNeedsScaleWarningCaption: Text { switch self { - case .belowRecommended, .minimum: + case .belowRecommended, .belowWarning, .minimum: return Text("Using this adjustment may lead to an under delivery of insulin. Monitor your glucose while this preset is in use.") - case .aboveRecommended, .maximum: + case .aboveRecommended: return Text("Using this adjustment may lead to an over delivery of insulin. Monitor your glucose while this preset is in use.") + case .aboveWarning, .maximum: + return Text("Using this adjustment may lead to an over delivery of insulin and result in serious injury. Monitor your glucose while this preset is in use.") } } diff --git a/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift b/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift index 61191222d1..0af40dcae4 100644 --- a/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift +++ b/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift @@ -30,7 +30,7 @@ struct ExistingPresetInsulinNeedsEdit: View { var body: some View { CardSectionScrollView { CardSection { - InsulinScaleAdjustView(insulinMultiplier: $editedScale) + InsulinScaleAdjustView(insulinMultiplier: $editedScale, guardrail: Guardrail.presetInsulinNeeds) } } actionArea: { if let crossedThreshold { diff --git a/Loop/Views/Presets/ExistingPresetRangeEdit.swift b/Loop/Views/Presets/ExistingPresetRangeEdit.swift index 1d12daf9db..1d076e4674 100644 --- a/Loop/Views/Presets/ExistingPresetRangeEdit.swift +++ b/Loop/Views/Presets/ExistingPresetRangeEdit.swift @@ -124,9 +124,9 @@ private struct CorrectionRangeGuardrailWarning: View { private func singularWarningTitle(for threshold: SafetyClassification.Threshold) -> Text { switch threshold { - case .minimum, .belowRecommended: + case .minimum, .belowWarning, .belowRecommended: return Text("Low Correction Value", comment: "Title text for the low correction value warning") - case .aboveRecommended, .maximum: + case .aboveRecommended, .aboveWarning, .maximum: return Text("High Correction Value", comment: "Title text for the high correction value warning") } } diff --git a/Loop/Views/Presets/NewPresetRangeEdit.swift b/Loop/Views/Presets/NewPresetRangeEdit.swift index 53f74950c0..ef98bdc4fa 100644 --- a/Loop/Views/Presets/NewPresetRangeEdit.swift +++ b/Loop/Views/Presets/NewPresetRangeEdit.swift @@ -117,9 +117,9 @@ private struct CorrectionRangeGuardrailWarning: View { private func singularWarningTitle(for threshold: SafetyClassification.Threshold) -> Text { switch threshold { - case .minimum, .belowRecommended: + case .minimum, .belowWarning, .belowRecommended: return Text("Low Correction Value", comment: "Title text for the low correction value warning") - case .aboveRecommended, .maximum: + case .aboveRecommended, .aboveWarning, .maximum: return Text("High Correction Value", comment: "Title text for the high correction value warning") } } From 3f212ec6ec1a4b2f7c9c8045e7237d2dfce7a61f Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 30 Oct 2025 14:36:01 -0300 Subject: [PATCH 314/421] [LOOP-5533] corrected display of in-app alert for critical alert disabled (#858) --- Loop/Managers/AlertPermissionsChecker.swift | 25 +++++++++------------ Loop/Managers/Alerts/AlertManager.swift | 11 +++++---- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index e83df9008a..391116ae9b 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -235,7 +235,7 @@ extension AlertPermissionsChecker { } @MainActor - static func constructUnsafeNotificationPermissionsInAppAlert(alert: UnsafeNotificationPermissionAlert) async -> UIAlertController { + static func constructUnsafeNotificationPermissionsInAppAlert(alert: UnsafeNotificationPermissionAlert, acknowledgementCompletion: @escaping (UnsafeNotificationPermissionAlert) -> Void) -> UIAlertController { let alertController = UIAlertController(title: alert.alertTitle, message: alert.alertBody, preferredStyle: .alert) @@ -247,19 +247,16 @@ extension AlertPermissionsChecker { titleWithImage.append(NSMutableAttributedString(string: alert.alertTitle, attributes: [.font: UIFont.preferredFont(forTextStyle: .headline)])) alertController.setValue(titleWithImage, forKey: "attributedTitle") - await withCheckedContinuation { continuation in - alertController.addAction(UIAlertAction(title: NSLocalizedString("Settings", comment: "Label of button that navigation user to iOS Settings"), - style: .default, - handler: { _ in - AlertPermissionsChecker.gotoSettings() - continuation.resume() - })) - alertController.addAction(UIAlertAction(title: NSLocalizedString("Close", comment: "The button label of the action used to dismiss the unsafe notification permission alert"), - style: .cancel, - handler: { _ in - continuation.resume() - })) - } + alertController.addAction(UIAlertAction(title: NSLocalizedString("Settings", comment: "Label of button that navigation user to iOS Settings"), + style: .default, + handler: { _ in + AlertPermissionsChecker.gotoSettings() + acknowledgementCompletion(alert) + })) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Close", comment: "The button label of the action used to dismiss the unsafe notification permission alert"), + style: .cancel, + handler: { _ in acknowledgementCompletion(alert) }) + ) return alertController } diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index b9a0fd7870..ba0c81bca3 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -743,16 +743,15 @@ extension AlertManager: AlertPermissionsCheckerDelegate { private func presentUnsafeNotificationPermissionsInAppAlert(_ alert: AlertPermissionsChecker.UnsafeNotificationPermissionAlert) { Task { @MainActor in - let alertController = await AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert(alert: alert) - for alert in AlertPermissionsChecker.UnsafeNotificationPermissionAlert.allCases { + let alertController = AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert(alert: alert) { [weak self] alert in UserDefaults.standard.hasIssuedNotificationPermissionsAlert = false - try await self.acknowledgeAlert( - identifier: alert.alertIdentifier - ) + Task { + try await self?.acknowledgeAlert(identifier: alert.alertIdentifier) + } } await self.alertPresenter.present(alertController, animated: true) - // the completion is called after the alert is presented + // this is called after the alert is presented unsafeNotificationPermissionsAlertController = alertController } } From e89d847a2ca8ad03e5fb5827d15963b8713aa3d1 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 31 Oct 2025 14:27:34 -0300 Subject: [PATCH 315/421] [LOOP-5496] adding link to settings (#856) --- .../StatusTableViewController.swift | 5 ++- Loop/Views/LoopStatusModalView.swift | 32 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 20c6d04af1..f8eb108ced 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1670,7 +1670,10 @@ final class StatusTableViewController: LoopChartsTableViewController { rootView: LoopStatusModalView(viewModel: viewModel, onDismiss: { [weak self] in self?.dismiss(animated: false) - }) + }, + onNavigateToSettings: { [weak self] in + self?.presentSettings() + }) .environment(\.loopStatusColorPalette, .loopStatus) ) modalVC.modalPresentationStyle = .overCurrentContext diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 25ee4cc24c..60d8653e86 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -16,7 +16,8 @@ struct LoopStatusModalView: View { @State private var appear = false let viewModel: LoopStatusModalViewModel - var onDismiss: () -> Void + let onDismiss: () -> Void + let onNavigateToSettings: () -> Void private var freshnessColor: Color { switch viewModel.freshness { @@ -100,9 +101,32 @@ struct LoopStatusModalView: View { .multilineTextAlignment(.center) } + @ViewBuilder private var automationMessage: some View { - Text(viewModel.copy.message) + let message = viewModel.copy.message + + // Use a localized search for the "Settings" word within the message + let settingsWord = viewModel.localizedSettingsWord + + if let range = message.range(of: settingsWord) { + let prefix = String(message[.. Date: Fri, 31 Oct 2025 15:19:21 -0300 Subject: [PATCH 316/421] [LOOP-5486] added indefinite preset reminder (#857) * added indefinite preset reminder * corrected retraction --- Loop/Managers/TemporaryPresetsManager.swift | 85 ++++++++++++++++++--- 1 file changed, 74 insertions(+), 11 deletions(-) diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 202d5696f0..a9c2f5621f 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -89,21 +89,31 @@ class TemporaryPresetsManager { guard oldValue != scheduleOverride else { return } + + presetHistory.recordOverride(scheduleOverride) - if scheduleOverride != oldValue { - presetHistory.recordOverride(scheduleOverride) - - if let oldPreset = oldValue { - for observer in self.presetActivationObservers { - observer.presetDeactivated(context: oldPreset.context) + if let oldPreset = oldValue { + for observer in self.presetActivationObservers { + observer.presetDeactivated(context: oldPreset.context) + } + + if oldPreset.duration == .indefinite { + Task { @MainActor in + await clearIndefinitePresetReminder(oldPreset) } } - if let newPreset = scheduleOverride { - for observer in self.presetActivationObservers { - observer.presetActivated(context: newPreset.context, duration: newPreset.duration) + } + if let newPreset = scheduleOverride { + for observer in self.presetActivationObservers { + observer.presetActivated(context: newPreset.context, duration: newPreset.duration) + } + + scheduleClearOverride(override: newPreset) + + if newPreset.duration == .indefinite { + Task { @MainActor in + await scheduleIndefinitePresetReminder(newPreset) } - - scheduleClearOverride(override: newPreset) } } @@ -172,6 +182,59 @@ class TemporaryPresetsManager { clearOverride() } } + + func scheduleIndefinitePresetReminder(_ override: TemporaryScheduleOverride) async { + let preset = override.createPreset() + let indefinitePresetIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: preset.id) + + let title = String(format: NSLocalizedString("%1$@ Still Active", comment: "The format title for the preset still active alert. (1: preset name)"), preset.name) + + let foregroundBody = String( + format: NSLocalizedString("%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off.", comment: "Active preset reminder alert foreground body. (1: preset name)"), + preset.name + ) + + let backgroundBody = String( + format: NSLocalizedString("%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off in the app.", comment: "Active preset reminder alert background body. (1: preset name)"), + preset.name + ) + + let actions = [ + Alert.UserAlertAction( + label: NSLocalizedString("OK", comment: "Label for acknowledging the preset has been active for 24 hours"), + identifier: "ok", + style: .default + ), + ] + + let foregroundContent = Alert.Content(title: title, + body: foregroundBody, + actions: actions) + + let backgroundContent = Alert.Content(title: title, + body: backgroundBody, + actions: actions) + + let metadata: Alert.Metadata = [LoopNotificationUserInfoKey.presetId.rawValue: Alert.MetadataValue(preset.id)] + + let alert = Alert( + identifier: indefinitePresetIdentifier, + foregroundContent: foregroundContent, + backgroundContent: backgroundContent, + trigger: .repeating(repeatInterval: .hours(24)), + interruptionLevel: .timeSensitive, + metadata: metadata, + categoryIdentifier: LoopNotificationCategory.presetReminder.rawValue + ) + + await alertIssuer?.issueAlert(alert) + } + + func clearIndefinitePresetReminder(_ override: TemporaryScheduleOverride) async { + let preset = override.createPreset() + let indefinitePresetIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: preset.id) + await alertIssuer?.retractAlert(identifier: indefinitePresetIdentifier) + } public func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool = false) -> GlucoseRangeSchedule? { From 8d3171cbcef57c1f085dd1c216c2000fe76efbc1 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 3 Nov 2025 13:01:28 -0600 Subject: [PATCH 317/421] LOOP-5439 high insulin needs preset mitigation (#859) * High insulin needs mitigation * shared methods for checking high insulin needs mitigation * Add test for fetchData with high insulin needs preset mitigation * Remove debug print * FIx tests, and make sure LDM is using mocked dates during tests --- Loop.xcodeproj/project.pbxproj | 4 + Loop/Managers/LoopDataManager.swift | 106 ++++++++++-------- Loop/Managers/SettingsManager.swift | 2 +- Loop/Managers/TemporaryPresetsManager.swift | 57 ++++++---- Loop/Managers/WatchDataManager.swift | 2 +- Loop/Models/LoopError.swift | 3 + .../PredictionTableViewController.swift | 3 + Loop/View Models/BolusEntryViewModel.swift | 2 +- .../Components/CorrectionRangePreview.swift | 30 ++--- .../InsulinNeedsAdjustmentPreview.swift | 9 +- .../Presets/Components/PresetDetentView.swift | 27 ++++- .../Presets/Components/PresetStatsView.swift | 28 +++-- Loop/Views/Presets/EditPresetView.swift | 8 +- .../Presets/ExistingPresetRangeEdit.swift | 16 ++- Loop/Views/Presets/NewCustomPreset.swift | 4 + Loop/Views/Presets/NewPresetRangeEdit.swift | 9 ++ Loop/Views/Presets/PresetsView.swift | 4 +- Loop/Views/Presets/ReviewNewPresetView.swift | 3 +- Loop/Views/WarningPanel.swift | 37 ++++++ LoopCore/SelectablePreset.swift | 52 ++++----- LoopTests/Managers/LoopDataManagerTests.swift | 38 ++++++- .../TemporaryPresetsManagerTests.swift | 8 +- .../ViewModels/BolusEntryViewModelTests.swift | 4 +- .../Scenes/GlucoseChartValueHashable.swift | 6 +- 24 files changed, 316 insertions(+), 146 deletions(-) create mode 100644 Loop/Views/WarningPanel.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index c00ba59034..4c27ade0f8 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -527,6 +527,7 @@ C1DCEDDD2E983A22001A7BB0 /* AutomatedTreatmentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEDDC2E983A1B001A7BB0 /* AutomatedTreatmentState.swift */; }; C1DCEDF42E999D5E001A7BB0 /* LastManualBolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */; }; C1DCEDF52E999D5E001A7BB0 /* LastManualBolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */; }; + C1DCEE452EB16662001A7BB0 /* WarningPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEE442EB1665F001A7BB0 /* WarningPanel.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 */; }; @@ -1572,6 +1573,7 @@ C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistedProperty.swift; sourceTree = ""; }; C1DCEDDC2E983A1B001A7BB0 /* AutomatedTreatmentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomatedTreatmentState.swift; sourceTree = ""; }; C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastManualBolus.swift; sourceTree = ""; }; + C1DCEE442EB1665F001A7BB0 /* WarningPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WarningPanel.swift; sourceTree = ""; }; C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SimpleBolusView.swift; sourceTree = ""; }; C1E2773D224177C000354103 /* ClockKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ClockKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/ClockKit.framework; sourceTree = DEVELOPER_DIR; }; C1E2774722433D7A00354103 /* MKRingProgressView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MKRingProgressView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2220,6 +2222,7 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( + C1DCEE442EB1665F001A7BB0 /* WarningPanel.swift */, 84213C732D932EF400642E78 /* Insulin Delivery Log */, B4001CED28CBBC82002FB414 /* AlertManagementView.swift */, 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */, @@ -3593,6 +3596,7 @@ 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */, 84475E0E2E5F00B900FC5E7C /* TimelineSteps.swift in Sources */, B43B5C522EAFB1BE0096A6AE /* InsulinSuspendedTableViewCell.swift in Sources */, + C1DCEE452EB16662001A7BB0 /* WarningPanel.swift in Sources */, 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, C16971F92D1231B5001B7DF6 /* PresetRangeEditor.swift in Sources */, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 4304a8b6a0..09b14ac336 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -133,7 +133,7 @@ final class LoopDataManager: ObservableObject { private let trustedTimeOffset: () async -> TimeInterval - private let now: () -> Date + private var now: Date { TestingDate.currentTestingDate() } // References to registered notification center observers private var notificationObservers: [Any] = [] @@ -178,7 +178,6 @@ final class LoopDataManager: ObservableObject { carbStore: CarbStoreProtocol, crashRecoveryManager: CrashRecoveryManager, dosingDecisionStore: DosingDecisionStoreProtocol, - now: @escaping () -> Date = { Date() }, trustedTimeOffset: @escaping () async -> TimeInterval, analyticsServicesManager: AnalyticsServicesManager?, carbAbsorptionModel: CarbAbsorptionModel, @@ -194,7 +193,6 @@ final class LoopDataManager: ObservableObject { self.carbStore = carbStore self.crashRecoveryManager = crashRecoveryManager self.dosingDecisionStore = dosingDecisionStore - self.now = now self.trustedTimeOffset = trustedTimeOffset self.analyticsServicesManager = analyticsServicesManager self.carbAbsorptionModel = carbAbsorptionModel @@ -260,14 +258,18 @@ final class LoopDataManager: ObservableObject { // The dispatch is necessary in case this is coming from a didSet already on the settings struct. withObservationTracking(of: settingsProvider.dosingEnabled) { [weak self] enabled in - if self?.automationHistory.last?.enabled != enabled { - self?.automationHistory.append(AutomationHistoryEntry(startDate: Date(), enabled: enabled)) + if let self, self.automationHistory.last?.enabled != enabled { + self.automationHistory.append(AutomationHistoryEntry(startDate: self.now, enabled: enabled)) // Clean up entries older than 36 hours; we should not be interpolating basal data before then. - let now = Date() - self?.automationHistory = self?.automationHistory.filter({ entry in + let now = now + self.automationHistory = self.automationHistory.filter({ entry in now.timeIntervalSince(entry.startDate) < .hours(36) - }) ?? [] + }) + + Task { + await self.updateDisplayState() + } } if !enabled { @@ -275,10 +277,6 @@ final class LoopDataManager: ObservableObject { Task { try? await self?.cancelActiveTempBasal(for: .automaticDosingDisabled) } - } else { - Task { - await self?.updateDisplayState() - } } } } @@ -320,13 +318,15 @@ final class LoopDataManager: ObservableObject { } func fetchData( - for baseTime: Date = Date(), + for baseTime: Date? = nil, presumePresetEndingNow: Bool = false, ensureDosingCoverageStart: Date? = nil ) async throws -> StoredDataAlgorithmInput { // Need to fetch doses back as far as t - (DIA + DCA) for Dynamic carbs let dosesInputHistory = CarbMath.maximumAbsorptionTimeInterval + InsulinMath.defaultInsulinActivityDuration + let baseTime = baseTime ?? now + var dosesStart = baseTime.addingTimeInterval(-dosesInputHistory) // Ensure dosing data goes back before ensureDosingCoverageStart, if specified @@ -394,8 +394,6 @@ final class LoopDataManager: ObservableObject { endDate: neededSensitivityTimeline.end ) - var target = try await settingsProvider.getTargetRangeHistory(startDate: baseTime, endDate: forecastEndTime) - let dosingLimits = try await settingsProvider.getDosingLimits(at: baseTime) guard let maxBolus = dosingLimits.maxBolus else { @@ -431,16 +429,25 @@ final class LoopDataManager: ObservableObject { } let carbRatioWithOverrides = overrides.applyCarbRatio(over: carbRatio) - guard !target.isEmpty else { - throw LoopError.configurationError(.glucoseTargetRangeSchedule) + + var target: [AbsoluteScheduleValue>] + + guard var suspendThreshold = dosingLimits.suspendThreshold else { + throw LoopError.configurationError(.suspendThreshold) } // If we have an active override, and it's not a preMeal override that should be disabled, - // then override the target for the entire forecast. + // or ended for other reasons (like comparing effects without preset), then override the + // target for the entire forecast. if let activeOverride = temporaryPresetsManager.activeOverride, - let overriddenTargetRange = activeOverride.settings.targetRange, !presumePresetEndingNow { + guard let schedule = settingsProvider.settings.glucoseTargetRangeSchedule else + { + throw LoopError.configurationError(.glucoseTargetRangeSchedule) + } + let scheduledRange = schedule.quantityRange(at: baseTime) + let overriddenTargetRange = activeOverride.effectiveCorrectionRangeDuring(scheduledRange: scheduledRange) target = [ AbsoluteScheduleValue( startDate: baseTime, @@ -448,8 +455,20 @@ final class LoopDataManager: ObservableObject { value: overriddenTargetRange ) ] + + if activeOverride.veryHighInsulinNeeds { + suspendThreshold = max(TemporaryScheduleOverride.highInsulinNeedsMitigationCorrrectionRangeLimit, suspendThreshold) + } + + } else { + target = try await settingsProvider.getTargetRangeHistory(startDate: baseTime, endDate: forecastEndTime) + } + + guard !target.isEmpty else { + throw LoopError.configurationError(.glucoseTargetRangeSchedule) } + // Create dosing strategy based on user setting let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled ? GlucoseBasedApplicationFactorStrategy() @@ -477,7 +496,7 @@ final class LoopDataManager: ObservableObject { sensitivity: sensitivityWithOverrides, carbRatio: carbRatioWithOverrides, target: target, - suspendThreshold: dosingLimits.suspendThreshold, + suspendThreshold: suspendThreshold, maxBolus: maxBolus, maxBasalRate: maxBasalRate, useIntegralRetrospectiveCorrection: UserDefaults.standard.integralRetrospectiveCorrectionEnabled, @@ -497,9 +516,9 @@ final class LoopDataManager: ObservableObject { var newState = AlgorithmDisplayState() do { - let lastManualBolusVisibilityWindowStartDate = Date().addingTimeInterval(.days(-1)) + let lastManualBolusVisibilityWindowStartDate = now.addingTimeInterval(.days(-1)) - var input = try await fetchData(for: now(), ensureDosingCoverageStart: lastManualBolusVisibilityWindowStartDate) + var input = try await fetchData(for: now, ensureDosingCoverageStart: lastManualBolusVisibilityWindowStartDate) input.recommendationType = .manualBolus newState.input = input newState.output = LoopAlgorithm.run(input: input) @@ -553,7 +572,7 @@ final class LoopDataManager: ObservableObject { } func loop() async { - let loopBaseTime = now() + let loopBaseTime = now var dosingDecision = StoredDosingDecision( date: loopBaseTime, @@ -654,7 +673,7 @@ final class LoopDataManager: ObservableObject { try await deliveryDelegate.enact(bolus: recommendationToEnact.bolusUnits, tempBasal: basalAdjustment, decisionId: dosingDecision.id) logger.default("loop() completed successfully.") - lastLoopCompleted = Date() + lastLoopCompleted = now let duration = lastLoopCompleted!.timeIntervalSince(loopBaseTime) dosingDecision.enactedTempBasal = basalAdjustment @@ -698,7 +717,7 @@ final class LoopDataManager: ObservableObject { endingPremealOverride = true } - var input = try await self.fetchData(for: now(), presumePresetEndingNow: truncatingActiveOverride || endingPremealOverride) + var input = try await self.fetchData(for: now, presumePresetEndingNow: truncatingActiveOverride || endingPremealOverride) .addingGlucoseSample(sample: manualGlucoseSample?.asStoredGlucoseSample) .removingCarbEntry(carbEntry: originalCarbEntry) .addingCarbEntry(carbEntry: potentialCarbEntry?.asStoredCarbEntry) @@ -760,7 +779,7 @@ final class LoopDataManager: ObservableObject { lastManualBolusRecommendation = displayState.output?.recommendation?.manual if let output = displayState.output { - var dosingDecision = StoredDosingDecision(date: Date(), reason: "updateRemoteRecommendation") + var dosingDecision = StoredDosingDecision(date: now, reason: "updateRemoteRecommendation") dosingDecision.predictedGlucose = output.predictedGlucose dosingDecision.insulinOnBoard = displayState.activeInsulin dosingDecision.carbsOnBoard = displayState.activeCarbs @@ -970,7 +989,7 @@ extension LoopDataManager { guard let iob = displayState.activeInsulin?.value, let suspendThreshold = settingsProvider.settings.suspendThreshold?.quantity, let carbRatioSchedule = temporaryPresetsManager.carbRatioScheduleApplyingOverrideHistory, - let correctionRangeSchedule = temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: mealCarbs != nil), + let correctionRangeSchedule = temporaryPresetsManager.effectiveCorrectionRangeSchedule(presumingMealEntry: mealCarbs != nil), let sensitivitySchedule = temporaryPresetsManager.insulinSensitivityScheduleApplyingOverrideHistory else { // Settings incomplete; should never get here; remove when therapy settings non-optional @@ -1005,8 +1024,10 @@ extension LoopDataManager { sensitivitySchedule: sensitivitySchedule, at: date) - dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit), notice: notice), - date: Date()) + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate( + recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit), notice: notice), + date: now + ) return dosingDecision } @@ -1201,16 +1222,16 @@ extension LoopDataManager: ServicesManagerDelegate { } if let startDate = startDate { - let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) - let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) + let maxStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) + let minStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) guard startDate <= maxStartDate && startDate >= minStartDate else { throw CarbActionError.invalidStartDate(startDate) } } let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) - let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) - + let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? now, foodType: foodType, absorptionTime: absorptionTime) + let _ = try await carbStore.addCarbEntry(candidateCarbEntry) } @@ -1265,7 +1286,7 @@ extension LoopDataManager: SimpleBolusViewModelDelegate { } func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws { - let startDate = Date() + let startDate = now try await deliveryDelegate?.enactBolus(units: units, decisionId: decisionId, activationType: activationType) lastManualBolus = LastManualBolus(amount: units, startDate: startDate) } @@ -1291,7 +1312,7 @@ extension LoopDataManager: BolusEntryViewModelDelegate { } func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? { - temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: presumingMealEntry) + temporaryPresetsManager.effectiveCorrectionRangeSchedule(presumingMealEntry: presumingMealEntry) } func generatePrediction( @@ -1300,7 +1321,6 @@ extension LoopDataManager: BolusEntryViewModelDelegate { potentialDose: SimpleInsulinDose?, manualGlucose: NewGlucoseSample? ) async throws -> (historicGlucose: [StoredGlucoseSample], predictedGlucose: [PredictedGlucoseValue]) { - let startDate = now() var endingPremealOverride = false @@ -1311,9 +1331,7 @@ extension LoopDataManager: BolusEntryViewModelDelegate { endingPremealOverride = true } - var input = try await fetchData(for: startDate, presumePresetEndingNow: endingPremealOverride, ensureDosingCoverageStart: nil) - - let insulinModel = insulinModel(for: deliveryDelegate?.pumpInsulinType) + var input = try await fetchData(for: now, presumePresetEndingNow: endingPremealOverride, ensureDosingCoverageStart: nil) // Add potential bolus, carbs, manual glucose input = input @@ -1584,12 +1602,12 @@ extension LoopDataManager: DiagnosticReportGenerator { extension LoopDataManager: LoopControl { - func scheduledBasalRate(at date: Date = Date()) -> Double? { - settings.basalRateSchedule?.value(at: date) + func scheduledBasalRate(at date: Date? = nil) -> Double? { + settings.basalRateSchedule?.value(at: date ?? now) } - func currentBasalRate(at date: Date = Date()) -> Double? { - guard let scheduledBasalRate = scheduledBasalRate(at: date) else { + func currentBasalRate(at date: Date? = nil) -> Double? { + guard let scheduledBasalRate = scheduledBasalRate(at: date ?? now) else { return nil } @@ -1601,7 +1619,7 @@ extension LoopDataManager: LoopControl { return nil } - let now = Date() + let now = now guard let neutralBasal = input.basal.closestPrior(to: now)?.value, let currentBasalRate = currentBasalRate(at: now) else { return nil diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index bf47f600bb..5ef8affca8 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -332,7 +332,7 @@ extension SettingsManager { } } - public func guardrailForPreset(_ preset: SelectablePreset) -> Guardrail { + public func correctionRangeGuardrailForPreset(_ preset: SelectablePreset) -> Guardrail { switch preset { case .preMeal: return preMealGuardrail diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index a9c2f5621f..cee6730e4d 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -10,7 +10,7 @@ import Foundation import LoopKit import os.log import LoopCore - +import LoopAlgorithm protocol PresetActivationObserver: AnyObject { func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) @@ -35,6 +35,8 @@ class TemporaryPresetsManager { @ObservationIgnored private var overrideIntentObserver: NSKeyValueObservation? = nil + private var now: Date { TestingDate.currentTestingDate() } + init(settingsProvider: SettingsProvider, alertIssuer: AlertIssuer? = nil, presetHistory: TemporaryScheduleOverrideHistory? = nil) { self.settingsProvider = settingsProvider self.alertIssuer = alertIssuer @@ -42,7 +44,7 @@ class TemporaryPresetsManager { self.presetHistory = presetHistory ?? TemporaryScheduleOverrideHistoryContainer.shared.fetch() TemporaryScheduleOverrideHistory.relevantTimeWindow = Bundle.main.localCacheDuration - _scheduleOverride = self.presetHistory.activeOverride(at: Date()) + _scheduleOverride = self.presetHistory.activeOverride(at: now) overrideIntentObserver = UserDefaults.appGroup?.observe( \.intentExtensionOverrideToSet, @@ -122,7 +124,7 @@ class TemporaryPresetsManager { } public var activeOverride: TemporaryScheduleOverride? { - if scheduleOverride?.isActive() == true { + if scheduleOverride?.isActive(at: now) == true { return scheduleOverride } else { return nil @@ -167,9 +169,12 @@ class TemporaryPresetsManager { public func scheduleClearOverride(override: TemporaryScheduleOverride) { clearOverrideTimer?.invalidate() if override.duration.isInfinite { return } + if override.scheduledEndDate < now { return } + log.default("Scheduling override end timer %{public}@", String(describing: override)) - clearOverrideTimer = Timer.scheduledTimer(withTimeInterval: override.scheduledEndDate.timeIntervalSince(Date()), repeats: false, block: { [weak self] _ in + + clearOverrideTimer = Timer.scheduledTimer(withTimeInterval: override.scheduledEndDate.timeIntervalSince(now), repeats: false, block: { [weak self] _ in Task { self?.log.default("override end timer fired for %{public}@", String(describing: override)) await self?.endOverride(override) @@ -236,7 +241,7 @@ class TemporaryPresetsManager { await alertIssuer?.retractAlert(identifier: indefinitePresetIdentifier) } - public func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool = false) -> GlucoseRangeSchedule? { + public func effectiveCorrectionRangeSchedule(presumingMealEntry: Bool = false) -> GlucoseRangeSchedule? { guard let glucoseTargetRangeSchedule = settingsProvider.settings.glucoseTargetRangeSchedule else { return nil @@ -255,35 +260,47 @@ class TemporaryPresetsManager { } } - public func isScheduleOverrideActive(at date: Date = Date()) -> Bool { - return scheduleOverride?.isActive(at: date) == true + public func effectiveCorrectionRange() -> ClosedRange? { + guard let schedule = settingsProvider.settings.glucoseTargetRangeSchedule else { return nil } + + let scheduledRange = schedule.quantityRange(at: now) + + if let override = activeOverride, override.veryHighInsulinNeeds { + return override.effectiveCorrectionRangeDuring(scheduledRange: scheduledRange) + } + + return scheduledRange + } + + public func isScheduleOverrideActive(at date: Date? = nil) -> Bool { + return scheduleOverride?.isActive(at: date ?? now) == true } - public func isNonPreMealOverrideActive(at date: Date = Date()) -> Bool { - return isScheduleOverrideActive(at: date) == true && scheduleOverride?.context != .preMeal + public func isNonPreMealOverrideActive(at date: Date? = nil) -> Bool { + return isScheduleOverrideActive(at: date ?? now) == true && scheduleOverride?.context != .preMeal } - public func isPreMealTargetActive(at date: Date = Date()) -> Bool { - return isScheduleOverrideActive(at: date) == true && scheduleOverride?.context == .preMeal + public func isPreMealTargetActive(at date: Date? = nil) -> Bool { + return isScheduleOverrideActive(at: date ?? now) == true && scheduleOverride?.context == .preMeal } - public func futureOverrideEnabled(relativeTo date: Date = Date()) -> Bool { + public func futureOverrideEnabled(relativeTo date: Date? = nil) -> Bool { guard let scheduleOverride = scheduleOverride else { return false } - return scheduleOverride.startDate > date + return scheduleOverride.startDate > date ?? now } - public func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { - scheduleOverride = makePreMealOverride(beginningAt: date, for: duration) + public func enablePreMealOverride(at date: Date? = nil, for duration: TimeInterval) { + scheduleOverride = makePreMealOverride(beginningAt: date ?? now, for: duration) } - private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + private func makePreMealOverride(beginningAt date: Date? = nil, for duration: TimeInterval) -> TemporaryScheduleOverride? { guard let preMealTargetRange = settingsProvider.settings.preMealTargetRange else { return nil } return TemporaryScheduleOverride( context: .preMeal, settings: TemporaryPresetSettings(targetRange: preMealTargetRange), - startDate: date, + startDate: date ?? now, duration: .finite(duration), enactTrigger: .local, syncIdentifier: UUID() @@ -350,7 +367,7 @@ class TemporaryPresetsManager { func updateActivePresetDuration(newEndDate: Date) { if var scheduleOverride { - if newEndDate > Date() { + if newEndDate > now { scheduleOverride.scheduledEndDate = newEndDate } else { scheduleOverride.scheduledEndDate = newEndDate.addingTimeInterval(.days(1)) @@ -365,7 +382,7 @@ class TemporaryPresetsManager { func lastUsed(id: String) -> Date? { if lastUsed == nil { - let enacts = presetHistory.getOverrideHistory(startDate: .distantPast, endDate: Date()) + let enacts = presetHistory.getOverrideHistory(startDate: .distantPast, endDate: now) lastUsed = [:] for enact in enacts { var id: String @@ -385,7 +402,7 @@ class TemporaryPresetsManager { let settings = settingsProvider.settings - let now = Date() + let now = now let preset = settings.overridePresets.reduce(into: nil as TemporaryPreset?) { result, preset in if let nextScheduledTime = preset.nextScheduledStartAfter(now) { diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 02dd87cfe4..4c02fe577b 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -390,7 +390,7 @@ final class WatchDataManager: NSObject { dosingDecision.scheduleOverride = scheduleOverride if scheduleOverride != nil || preMealOverride != nil { - dosingDecision.glucoseTargetRangeSchedule = self.temporaryPresetsManager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) + dosingDecision.glucoseTargetRangeSchedule = self.temporaryPresetsManager.effectiveCorrectionRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) } else { dosingDecision.glucoseTargetRangeSchedule = settings.glucoseTargetRangeSchedule } diff --git a/Loop/Models/LoopError.swift b/Loop/Models/LoopError.swift index 23e71dd7d4..c857f856ea 100644 --- a/Loop/Models/LoopError.swift +++ b/Loop/Models/LoopError.swift @@ -17,6 +17,7 @@ enum ConfigurationErrorDetail: String, Codable { case insulinSensitivitySchedule case maximumBasalRatePerHour case maximumBolus + case suspendThreshold func localized() -> String { switch self { @@ -34,6 +35,8 @@ enum ConfigurationErrorDetail: String, Codable { return NSLocalizedString("Maximum Basal Rate Per Hour", comment: "Details for configuration error when maximum basal rate per hour is missing") case .maximumBolus: return NSLocalizedString("Maximum Bolus", comment: "Details for configuration error when maximum bolus is missing") + case .suspendThreshold: + return NSLocalizedString("Suspend Threshold", comment: "Details for configuration error when suspend threshold is missing") } } } diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index 948f956cb0..df7f672d07 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -154,6 +154,9 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable self.glucoseChart.targetGlucoseSchedule = self.settingsManager.settings.glucoseTargetRangeSchedule } + self.glucoseChart.scheduleOverride = loopDataManager.scheduleOverride + self.glucoseChart.preMealOverride = loopDataManager.preMealOverride + if let glucoseSamples = glucoseSamples { self.glucoseChart.setGlucoseValues(glucoseSamples) } diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index 3396dd2fdf..d876b335ca 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, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput + func fetchData(for baseTime: Date?, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool) -> GlucoseRangeSchedule? func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?) async throws -> StoredCarbEntry diff --git a/Loop/Views/Presets/Components/CorrectionRangePreview.swift b/Loop/Views/Presets/Components/CorrectionRangePreview.swift index 75aae6cd5c..d327af310b 100644 --- a/Loop/Views/Presets/Components/CorrectionRangePreview.swift +++ b/Loop/Views/Presets/Components/CorrectionRangePreview.swift @@ -14,16 +14,19 @@ import LoopKitUI public struct CorrectionRangePreview: View { @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference @Environment(\.guidanceColors) private var guidanceColors + @Environment(\.appName) private var appName - var range: ClosedRange? - var guardrail: Guardrail + private var range: ClosedRange? + private var guardrail: Guardrail private var scheduledRange: ClosedRange - var showDisclosure: Bool + private var showDisclosure: Bool + private var veryHighInsulinNeeds: Bool - init(range: ClosedRange?, guardrail: Guardrail, scheduledRange: ClosedRange, showDisclosure: Bool = false) { + init(range: ClosedRange?, guardrail: Guardrail, scheduledRange: ClosedRange, veryHighInsulinNeeds: Bool, showDisclosure: Bool = false) { self.range = range self.guardrail = guardrail self.scheduledRange = scheduledRange + self.veryHighInsulinNeeds = veryHighInsulinNeeds self.showDisclosure = showDisclosure } @@ -72,22 +75,23 @@ public struct CorrectionRangePreview: View { return thresholds } + var highInsulinNeedsWarningText: String { + String(format: NSLocalizedString("%1$@ will set your correction range to 110 mg/dL or higher when this preset is enabled.", comment: "The format string for the high insulin needs preset warning text on the preset preview screen. (1: app name)"), appName) + } + private var guardrailWarningIfNecessary: some View { let crossedThresholds = self.correctionRangeCrossedThresholds - let severity = crossedThresholds.map { $0.severity }.max() return Group { - if let severity, !crossedThresholds.isEmpty { - let color: Color = severity > .default ? guidanceColors.critical : guidanceColors.warning - HStack(alignment: .top, spacing: 12) { - Text(Image(systemName: "exclamationmark.triangle.fill")) - .foregroundColor(color) + if !crossedThresholds.isEmpty { + WarningPanel(severity: crossedThresholds.map { $0.severity }.max()!) { Text(SafetyClassification.captionForCrossedThresholds(crossedThresholds, isRange: true)) .accessibilityIdentifier("text_CorrectionRangeWarning"); } - .padding(12) - .background(color.opacity(0.1)) - .cornerRadius(12) + } else if veryHighInsulinNeeds { + WarningPanel { + Text(highInsulinNeedsWarningText) + } } } } diff --git a/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift b/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift index 3384a5eb7c..13a1ff708a 100644 --- a/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift +++ b/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift @@ -48,17 +48,10 @@ public struct InsulinNeedsAdjustmentPreview: View { return Group { if case .outsideRecommendedRange(let threshold) = classification { - let severity = threshold.severity - let color: Color = severity > .default ? guidanceColors.critical : guidanceColors.warning - HStack(alignment: .top, spacing: 12) { - Text(Image(systemName: "exclamationmark.triangle.fill")) - .foregroundColor(color) + WarningPanel(severity: threshold.severity) { Text(SafetyClassification.captionForCrossedThresholds([threshold], isRange: true)) .accessibilityIdentifier("text_InsulinNeedsWarning"); } - .padding(12) - .background(color.opacity(0.1)) - .cornerRadius(12) } } } diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 4eb3e19f23..d1ff711cc4 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -22,7 +22,8 @@ struct PresetDetentView: View { @Environment(\.settingsManager) private var settingsManager @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @Environment(\.dismiss) private var dismiss - + @Environment(\.appName) private var appName + let preset: SelectablePreset let didTapEdit: () -> Void @@ -113,6 +114,15 @@ struct PresetDetentView: View { settingsManager.therapySettings.impact(for: preset.insulinNeedsScaleFactor) } + var highInsulinNeedsWarningText: String { + switch operation { + case .start: + String(format: NSLocalizedString("%1$@ will set your correction range to 110 mg/dL or higher when this preset is enabled.", comment: "The format string for the high insulin needs preset warning text on the preset detent screen when starting a preset. (1: app name)"), appName) + case .end: + String(format: NSLocalizedString("%1$@ has set your correction range to 110 mg/dL or higher.", comment: "The format string for the high insulin needs preset warning text on the preset detent screen when stopping a preset. (1: app name)"), appName) + } + } + var body: some View { NavigationStack { VStack(spacing: 24) { @@ -142,12 +152,13 @@ struct PresetDetentView: View { PresetStatsView( insulinMultiplier: preset.insulinNeedsScaleFactor, correctionRange: preset.correctionRange, - guardrail: settingsManager.guardrailForPreset(preset), + guardrail: settingsManager.correctionRangeGuardrailForPreset(preset), therapySettingsImpactDisplayState: operation == .end ? .show(settingsImpact) : .hide, isScheduled: false, isActive: temporaryPresetsManager.activePreset?.id == preset.id ) - + .padding(.horizontal) + if case let .activity(activityPreset) = preset, !activityPreset.isModifiedFromDefault { Text("\(Image(systemName: "checkmark.seal.fill")) Recommended starting values") .font(.subheadline) @@ -155,7 +166,15 @@ struct PresetDetentView: View { .frame(maxWidth: .infinity) .padding(.bottom, 4) } - + + if preset.veryHighInsulinNeeds { + WarningPanel { + Text(highInsulinNeedsWarningText) + .font(.subheadline) + .fontWeight(.semibold) + } + } + actionArea } .toolbar(.hidden) diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift index efd10c7da3..ca142af5cc 100644 --- a/Loop/Views/Presets/Components/PresetStatsView.swift +++ b/Loop/Views/Presets/Components/PresetStatsView.swift @@ -34,7 +34,12 @@ struct PresetStatsView: View { formatter.numberStyle = .percent return formatter } - + + private var insulinMultiplierSafetyClassification: SafetyClassification? { + guard let insulinMultiplier else { return nil } + return Guardrail.presetInsulinNeeds.classification(for: LoopQuantity(unit: .percent, doubleValue: insulinMultiplier * 100)) + } + var overallInsulinView: some View { VStack(alignment: .leading, spacing: 8) { Text("Overall Insulin") @@ -43,9 +48,18 @@ struct PresetStatsView: View { .accessibilitySortPriority(2) let percent = numberFormatter.string(from: insulinMultiplier ?? 1)! - Group { Text(percent).bold() + Text(" of scheduled") } - .font(.subheadline) - .accessibilitySortPriority(1) + let color = guidanceColor(for: insulinMultiplierSafetyClassification) ?? .primary + HStack(alignment: .top) { + if insulinMultiplierSafetyClassification != .withinRecommendedRange { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(color) + } + + Group { Text(percent).bold() + Text(" of scheduled") } + .font(.subheadline) + .accessibilitySortPriority(1) + .foregroundStyle(color) + } } .accessibilityElement(children: .contain) } @@ -125,10 +139,10 @@ struct PresetStatsView: View { .accessibilitySortPriority(2) Group { - if let target = correctionRange { + if !isActive, let target = correctionRange { annotatedRangeText(target: target) - } else if isActive, let therapySettingsCorrectionRange = settingsManager.therapySettings.glucoseTargetRangeSchedule?.quantityRange(at: Date()) { - annotatedRangeText(target: therapySettingsCorrectionRange) + } else if isActive, let range = temporaryPresetsManager.effectiveCorrectionRange() { + annotatedRangeText(target: range) } else { Text("Scheduled Range") .bold() diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 45a9d53f92..92cd27e6cc 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -94,8 +94,9 @@ struct EditPresetView: View { } label: { CorrectionRangePreview( range: preset.correctionRange, - guardrail: settingsManager.guardrailForPreset(preset), + guardrail: settingsManager.correctionRangeGuardrailForPreset(preset), scheduledRange: scheduledRange, + veryHighInsulinNeeds: preset.veryHighInsulinNeeds, showDisclosure: true ) }.accessibilityIdentifier("button_CorrectionRange") @@ -334,11 +335,12 @@ struct EditPresetView: View { case .editCorrectionRange: ExistingPresetRangeEdit( range: $preset.correctionRange, - guardrail: settingsManager.guardrailForPreset(preset), + guardrail: settingsManager.correctionRangeGuardrailForPreset(preset), scheduledRange: scheduledRange, allowsScheduledRange: preset.canAdjustSensitivity, isPreMeal: preset.isPreMeal, - presetAdjustsInsulinNeeds: preset.insulinNeedsScaleFactor != 1 + presetAdjustsInsulinNeeds: preset.insulinNeedsScaleFactor != 1, + requiresHighInsulinNeedsMitigation: preset.veryHighInsulinNeeds ) } } diff --git a/Loop/Views/Presets/ExistingPresetRangeEdit.swift b/Loop/Views/Presets/ExistingPresetRangeEdit.swift index 1d076e4674..818d193d73 100644 --- a/Loop/Views/Presets/ExistingPresetRangeEdit.swift +++ b/Loop/Views/Presets/ExistingPresetRangeEdit.swift @@ -13,6 +13,7 @@ import LoopKitUI struct ExistingPresetRangeEdit: View { @Environment(\.dismiss) private var dismiss + @Environment(\.appName) private var appName @Binding var range: ClosedRange? var guardrail: Guardrail @@ -20,7 +21,8 @@ struct ExistingPresetRangeEdit: View { @State private var editedRange: ClosedRange? private var allowsScheduledRange: Bool private var isPreMeal: Bool = false - private var presetAdjustsInsulinNeeds: Bool = false + private var presetAdjustsInsulinNeeds: Bool + private var requiresHighInsulinNeedsMitigation: Bool init( range: Binding?>, @@ -28,7 +30,8 @@ struct ExistingPresetRangeEdit: View { scheduledRange: ClosedRange, allowsScheduledRange: Bool = true, isPreMeal: Bool = false, - presetAdjustsInsulinNeeds: Bool + presetAdjustsInsulinNeeds: Bool, + requiresHighInsulinNeedsMitigation: Bool ) { self._range = range self.editedRange = range.wrappedValue @@ -37,6 +40,11 @@ struct ExistingPresetRangeEdit: View { self.allowsScheduledRange = allowsScheduledRange self.isPreMeal = isPreMeal self.presetAdjustsInsulinNeeds = presetAdjustsInsulinNeeds + self.requiresHighInsulinNeedsMitigation = requiresHighInsulinNeedsMitigation + } + + var highInsulinNeedsWarningText: String { + String(format: NSLocalizedString("For presets with insulin needs of 170%% or greater, %1$@ will set your correction range to 110 mg/dL or higher when this preset is enabled.", comment: "The format string for the high insulin needs preset warning text. (1: app name)"), appName) } var body: some View { @@ -57,6 +65,10 @@ struct ExistingPresetRangeEdit: View { NoticeView( title: Text("Set an Adjusted Correction Range"), caption: Text("With overall insulin needs at 100%, an adjusted correction range is required.")) + } else if requiresHighInsulinNeedsMitigation { + WarningView( + title: Text("Correction range adjustment when preset is enabled"), + caption: Text(highInsulinNeedsWarningText)) } actionButton } diff --git a/Loop/Views/Presets/NewCustomPreset.swift b/Loop/Views/Presets/NewCustomPreset.swift index 5e3ac6151e..c09b19a406 100644 --- a/Loop/Views/Presets/NewCustomPreset.swift +++ b/Loop/Views/Presets/NewCustomPreset.swift @@ -65,6 +65,10 @@ struct NewCustomPreset { self.startDate = startDate self.repeatOptions = repeatOptions } + + var veryHighInsulinNeeds: Bool { + return TemporaryScheduleOverride.isInMitigationRange(insulinNeedsScaleFactor: insulinMultiplier) + } } extension NewCustomPreset { diff --git a/Loop/Views/Presets/NewPresetRangeEdit.swift b/Loop/Views/Presets/NewPresetRangeEdit.swift index ef98bdc4fa..9949853507 100644 --- a/Loop/Views/Presets/NewPresetRangeEdit.swift +++ b/Loop/Views/Presets/NewPresetRangeEdit.swift @@ -13,6 +13,7 @@ import LoopKitUI struct NewPresetRangeEdit: View { @Environment(\.dismiss) private var dismiss + @Environment(\.appName) private var appName @Binding var preset: NewCustomPreset @Binding var path: NavigationPath @@ -31,6 +32,10 @@ struct NewPresetRangeEdit: View { self.onCancel = onCancel } + var highInsulinNeedsWarningText: String { + String(format: NSLocalizedString("For presets with insulin needs of 170%% or greater, %1$@ will set your correction range to 110 mg/dL or higher when this is preset enabled.", comment: "The format string for the high insulin needs preset warning text. (1: app name)"), appName) + } + var body: some View { CardSectionScrollView { CardSection { @@ -48,6 +53,10 @@ struct NewPresetRangeEdit: View { NoticeView( title: Text("Set an Adjusted Correction Range"), caption: Text("With overall insulin needs at 100%, an adjusted correction range is required.")) + } else if preset.veryHighInsulinNeeds { + WarningView( + title: Text("Correction range adjustment when preset is enabled"), + caption: Text(highInsulinNeedsWarningText)) } actionButton } diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index fe0ee31b71..11c7f37c8c 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -99,7 +99,7 @@ struct PresetsView: View { { PresetCard( activePreset, - guardrail: settingsManager.guardrailForPreset(activePreset), + guardrail: settingsManager.correctionRangeGuardrailForPreset(activePreset), expectedEndTime: temporaryPresetsManager.activeOverride?.expectedEndTime ) .onTapGesture { @@ -142,7 +142,7 @@ struct PresetsView: View { ForEach(presetsSorted) { preset in PresetCard( preset, - guardrail: settingsManager.guardrailForPreset(preset) + guardrail: settingsManager.correctionRangeGuardrailForPreset(preset) ) .cornerRadius(12) diff --git a/Loop/Views/Presets/ReviewNewPresetView.swift b/Loop/Views/Presets/ReviewNewPresetView.swift index c8c81e5134..997411b1f5 100644 --- a/Loop/Views/Presets/ReviewNewPresetView.swift +++ b/Loop/Views/Presets/ReviewNewPresetView.swift @@ -62,7 +62,8 @@ struct ReviewNewPresetView: View { CorrectionRangePreview( range: preset.correctionRange, guardrail: Guardrail.temporaryPresetCorrectionRange, - scheduledRange: scheduledRange + scheduledRange: scheduledRange, + veryHighInsulinNeeds: preset.veryHighInsulinNeeds ) } diff --git a/Loop/Views/WarningPanel.swift b/Loop/Views/WarningPanel.swift new file mode 100644 index 0000000000..69b8e144e8 --- /dev/null +++ b/Loop/Views/WarningPanel.swift @@ -0,0 +1,37 @@ +// +// WarningPanel.swift +// Loop +// +// Created by Pete Schwamb on 10/28/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct WarningPanel: View { + @Environment(\.guidanceColors) private var guidanceColors + + let severity: WarningSeverity + @ViewBuilder let content: () -> Content + + init(severity: WarningSeverity = .default, @ViewBuilder _ content: @escaping () -> Content) { + self.severity = severity + self.content = content + } + + var body: some View { + let color: Color = severity > .default ? guidanceColors.critical : guidanceColors.warning + + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(color) + + content() + } + .padding(12) + .background(color.opacity(0.1)) + .cornerRadius(12) + } +} diff --git a/LoopCore/SelectablePreset.swift b/LoopCore/SelectablePreset.swift index 9450e94b69..7aff2796cf 100644 --- a/LoopCore/SelectablePreset.swift +++ b/LoopCore/SelectablePreset.swift @@ -61,6 +61,28 @@ extension TemporaryScheduleOverride { case .preset(let preset): return preset.id } } + + public func createPreset() -> SelectablePreset { + let range = settings.targetRange + + switch context { + case .preMeal: + return .preMeal(range: range!) + case .activity(let activity): + return .activity(activity) + case .custom: + let preset = TemporaryPreset( + id: syncIdentifier.uuidString, + symbol: nil, + name: NSLocalizedString("Single Use Preset", comment: "The title shown for a single use preset"), + settings: settings, + duration: duration + ) + return .custom(preset) + case .preset(let preset): + return .custom(preset) + } + } } extension ActivityPreset { @@ -375,6 +397,11 @@ public enum SelectablePreset: Hashable, Identifiable { return .distantPast } } + + public var veryHighInsulinNeeds: Bool { + return TemporaryScheduleOverride.isInMitigationRange(insulinNeedsScaleFactor: insulinNeedsScaleFactor) + } + } extension SelectablePreset { @@ -398,31 +425,6 @@ extension SelectablePreset { } } -extension TemporaryScheduleOverride { - public func createPreset() -> SelectablePreset { - let range = settings.targetRange - - switch context { - case .preMeal: - return .preMeal(range: range!) - case .activity(let activity): - return .activity(activity) - case .custom: - let preset = TemporaryPreset( - id: syncIdentifier.uuidString, - symbol: nil, - name: NSLocalizedString("Single Use Preset", comment: "The title shown for a single use preset"), - settings: settings, - duration: duration - ) - return .custom(preset) - case .preset(let preset): - return .custom(preset) - } - } -} - - extension PresetExpectedEndTime { private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index f77c8953e2..1d40f9ab73 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -71,7 +71,6 @@ class LoopDataManagerTests: XCTestCase { } // MARK: Stores - var now: Date! let persistenceController = PersistenceController.mock() var doseStore = MockDoseStore() var glucoseStore = MockGlucoseStore() @@ -80,9 +79,12 @@ class LoopDataManagerTests: XCTestCase { var loopDataManager: LoopDataManager! var deliveryDelegate: MockDeliveryDelegate! var settingsProvider: MockSettingsProvider! + var temporaryPresetsManager: TemporaryPresetsManager! + + private var now: Date { TestingDate.currentTestingDate() } func d(_ interval: TimeInterval) -> Date { - return now.addingTimeInterval(interval) + TestingDate.currentTestingDate().addingTimeInterval(interval) } override func setUp() async throws { @@ -117,13 +119,13 @@ class LoopDataManagerTests: XCTestCase { settingsProvider = MockSettingsProvider(settings: settings) - now = dateFormatter.date(from: "2023-07-29T19:21:00Z")! + TestingDate.setFixedTestingDate(dateFormatter.date(from: "2023-07-29T19:21:00Z")!) doseStore.lastAddedPumpData = now dosingDecisionStore = MockDosingDecisionStore() - let temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsProvider, presetHistory: TemporaryScheduleOverrideHistory()) + temporaryPresetsManager = TemporaryPresetsManager(settingsProvider: settingsProvider, presetHistory: TemporaryScheduleOverrideHistory()) loopDataManager = LoopDataManager( lastLoopCompleted: now, @@ -134,7 +136,6 @@ class LoopDataManagerTests: XCTestCase { carbStore: carbStore, crashRecoveryManager: CrashRecoveryManager(alertIssuer: MockAlertIssuer()), dosingDecisionStore: dosingDecisionStore, - now: { [weak self] in self?.now ?? Date() }, trustedTimeOffset: { 0 }, analyticsServicesManager: nil, carbAbsorptionModel: .piecewiseLinear @@ -211,7 +212,7 @@ class LoopDataManagerTests: XCTestCase { glucoseStore.storedGlucose = predictionInput.glucoseHistory.map { StoredGlucoseSample.from(fixture: $0) } let currentDate = glucoseStore.latestGlucose!.startDate - now = currentDate + TestingDate.setFixedTestingDate(currentDate) doseStore.doseHistory = predictionInput.doses.map { DoseEntry.from(fixture: $0) } doseStore.lastAddedPumpData = predictionInput.doses.last!.startDate @@ -458,7 +459,32 @@ class LoopDataManagerTests: XCTestCase { } + func testFetchDataWithHighInsulinNeedsPresetMitigation() async throws { + var input = try await loopDataManager.fetchData(for: now) + XCTAssertEqual(input.target.count, 1) + XCTAssertEqual(input.target[0].value.doubleRange(for: .milligramsPerDeciliter), DoubleRange(minValue: 90, maxValue: 100)) + XCTAssertEqual(input.suspendThreshold?.doubleValue(for: .milligramsPerDeciliter), 75.0) + + let override = TemporaryScheduleOverride( + context: .custom, + settings: TemporaryPresetSettings( + targetRange: nil, + insulinNeedsScaleFactor: 1.75 + ), + startDate: now.addingTimeInterval(.minutes(-1)), + duration: .finite(.hours(2)), + enactTrigger: .local, + syncIdentifier: UUID() + ) + + temporaryPresetsManager.scheduleOverride = override + input = try await loopDataManager.fetchData(for: now) + XCTAssertEqual(input.target.count, 1) + XCTAssertEqual(input.target[0].value.doubleRange(for: .milligramsPerDeciliter), DoubleRange(minValue: 110, maxValue: 110)) + XCTAssertEqual(input.suspendThreshold?.doubleValue(for: .milligramsPerDeciliter), 110.0) + + } } extension LoopDataManagerTests { diff --git a/LoopTests/Managers/TemporaryPresetsManagerTests.swift b/LoopTests/Managers/TemporaryPresetsManagerTests.swift index 3b022f74b9..8c8392c97e 100644 --- a/LoopTests/Managers/TemporaryPresetsManagerTests.swift +++ b/LoopTests/Managers/TemporaryPresetsManagerTests.swift @@ -36,14 +36,14 @@ class TemporaryPresetsManagerTests: XCTestCase { func testPreMealOverride() { let preMealStart = Date() manager.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) - let actualPreMealRange = manager.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) + let actualPreMealRange = manager.effectiveCorrectionRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) XCTAssertEqual(preMealRange, actualPreMealRange) } func testPreMealOverrideWithPotentialCarbEntry() { let preMealStart = Date() manager.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) - let actualRange = manager.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: true)?.value(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) + let actualRange = manager.effectiveCorrectionRangeSchedule(presumingMealEntry: true)?.value(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) XCTAssertEqual(targetRange, actualRange) } @@ -62,7 +62,7 @@ class TemporaryPresetsManagerTests: XCTestCase { syncIdentifier: UUID() ) manager.scheduleOverride = override - let actualOverrideRange = manager.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(30 /* minutes */ * 60)) + let actualOverrideRange = manager.effectiveCorrectionRangeSchedule()?.value(at: overrideStart.addingTimeInterval(30 /* minutes */ * 60)) XCTAssertEqual(actualOverrideRange, overrideTargetRange) } @@ -91,7 +91,7 @@ class TemporaryPresetsManagerTests: XCTestCase { ) manager.scheduleOverride = override - let actualOverrideRange = manager.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(2 /* hours */ * 60 * 60)) + let actualOverrideRange = manager.effectiveCorrectionRangeSchedule()?.value(at: overrideStart.addingTimeInterval(2 /* hours */ * 60 * 60)) XCTAssertEqual(actualOverrideRange, overrideTargetRange) } } diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 10abce4428..6b70654c55 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -870,8 +870,8 @@ fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { automaticBolusApplicationFactor: 0.4 ) - func fetchData(for baseTime: Date, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput { - loopStateInput.predictionStart = baseTime + func fetchData(for baseTime: Date?, presumePresetEndingNow: Bool, ensureDosingCoverageStart: Date?) async throws -> StoredDataAlgorithmInput { + loopStateInput.predictionStart = baseTime ?? Date() return loopStateInput } diff --git a/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift index 1809d72ca0..ab5ea5800b 100644 --- a/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift +++ b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift @@ -95,10 +95,12 @@ struct TemporaryScheduleOverrideHashable: GlucoseChartValueHashable { } var min: LoopQuantity { - return override.settings.targetRange!.lowerBound + let effectiveTargetRange = override.effectiveCorrectionRangeDuring(scheduledRange: override.settings.targetRange!) + return effectiveTargetRange.lowerBound } var max: LoopQuantity { - return override.settings.targetRange!.upperBound + let effectiveTargetRange = override.effectiveCorrectionRangeDuring(scheduledRange: override.settings.targetRange!) + return effectiveTargetRange.upperBound } } From 9f0a6e3f47f613149a1229aeb3d4c6c7524c414e Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 7 Nov 2025 07:05:43 -0400 Subject: [PATCH 318/421] [LOOP-5428-5496] corrected background color (#860) * corrected background color * adding correction to last bolus value (LOOP-5258) --- Loop/View Controllers/StatusTableViewController.swift | 2 +- Loop/Views/LoopStatusModalView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index f8eb108ced..b5ed1fce26 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -33,7 +33,7 @@ final class StatusTableViewController: LoopChartsTableViewController { lazy var insulinFormatter: QuantityFormatter = { let formatter = QuantityFormatter(for: .internationalUnit) - formatter.numberFormatter.maximumFractionDigits = 2 + formatter.numberFormatter.maximumFractionDigits = 3 return formatter }() diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 60d8653e86..28a9ace29f 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -48,7 +48,7 @@ struct LoopStatusModalView: View { .padding(.bottom, 10) } .padding(10) - .background(Color.white) + .background(Color(UIColor.systemGroupedBackground)) .cornerRadius(10) .shadow(radius: 5) .frame(maxWidth: 340) From a209d4bf9a238ad86bff1d984c42d1ec27ff91ee Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 7 Nov 2025 13:21:55 -0400 Subject: [PATCH 319/421] [LOOP-5550] unschedule deleted preset (#861) --- Loop/Managers/TemporaryPresetsManager.swift | 5 +++++ Loop/Views/Presets/PresetsView.swift | 1 + 2 files changed, 6 insertions(+) diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index cee6730e4d..e729e7ccd0 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -397,6 +397,11 @@ class TemporaryPresetsManager { } return lastUsed![id] } + + func unschedulePresetReminderIfNeeded(_ preset: SelectablePreset) async { + guard preset.isScheduled else { return } + await alertIssuer?.retractAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: preset.id)) + } func scheduleNextPresetReminder() async { diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 11c7f37c8c..4fac44911b 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -244,6 +244,7 @@ struct PresetsView: View { onDelete: { preset in settingsManager.deletePreset(preset) Task { + await temporaryPresetsManager.unschedulePresetReminderIfNeeded(preset) await temporaryPresetsManager.scheduleNextPresetReminder() } } From 26b02f302c3c7d83f3d369991bfe75038933ee64 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 10 Nov 2025 13:30:52 -0400 Subject: [PATCH 320/421] [LOOP-5426] can schedule activity presets (#863) --- LoopCore/SelectablePreset.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/LoopCore/SelectablePreset.swift b/LoopCore/SelectablePreset.swift index 7aff2796cf..84131b91fb 100644 --- a/LoopCore/SelectablePreset.swift +++ b/LoopCore/SelectablePreset.swift @@ -210,7 +210,9 @@ public enum SelectablePreset: Hashable, Identifiable { switch self { case .custom(let preset): return preset.nextScheduledStartAfter(date) - case .preMeal, .activity: + case .activity(let activity): + return activity.preset.nextScheduledStartAfter(date) + case .preMeal: return nil } } @@ -220,7 +222,9 @@ public enum SelectablePreset: Hashable, Identifiable { switch self { case .custom(let preset): return preset.scheduleStartDate - case .preMeal, .activity: + case .activity(let activity): + return activity.preset.scheduleStartDate + case .preMeal: return nil } } @@ -229,6 +233,9 @@ public enum SelectablePreset: Hashable, Identifiable { case .custom(var preset): preset.scheduleStartDate = newValue self = .custom(preset) + case .activity(var activity): + activity.preset.scheduleStartDate = newValue + self = .activity(activity) default: break } @@ -364,9 +371,9 @@ public enum SelectablePreset: Hashable, Identifiable { public var allowsScheduling: Bool { switch self { - case .custom: + case .custom, .activity: return true - case .preMeal, .activity: + case .preMeal: return false } } From b34d14aa2780648279e632a9702f6b47303c6594 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 10 Nov 2025 11:12:18 -0800 Subject: [PATCH 321/421] [LOOP-5453] Presets Training Updates (#862) * [LOOP-5453] Presets Training Updates * [LOOP-5453] Notes for commented code --- Loop/Managers/TemporaryPresetsManager.swift | 2 +- .../Presets/Components/CardSection.swift | 13 +- Loop/Views/Presets/EditPresetView.swift | 65 +++- Loop/Views/Presets/PresetsView.swift | 13 +- .../Components/PresetsTrainingCard.swift | 4 +- .../Training Content/PresetsTraining.swift | 158 ++++---- .../PresetsTrainingContent.swift | 350 +++++++++--------- .../PresetsTrainingView.swift | 3 - LoopCore/SelectablePreset.swift | 11 +- .../Managers/LoopDataManager.swift | 2 +- 10 files changed, 345 insertions(+), 276 deletions(-) diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index e729e7ccd0..c114c3fdab 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -158,7 +158,7 @@ class TemporaryPresetsManager { ActivityPreset.ActivityType.allCases.forEach { activityType in if !settings.overridePresets.contains(where: { $0.id == activityType.id }) { - presets.append(.activity(ActivityPreset(activityType: activityType, preset: activityType.defaultPreset(duration: .finite(.minutes(90)))))) + presets.append(.activity(ActivityPreset(activityType: activityType, preset: activityType.completeDefaultPreset))) } } diff --git a/Loop/Views/Presets/Components/CardSection.swift b/Loop/Views/Presets/Components/CardSection.swift index e6cfe03ed4..cc16d02e99 100644 --- a/Loop/Views/Presets/Components/CardSection.swift +++ b/Loop/Views/Presets/Components/CardSection.swift @@ -16,23 +16,28 @@ struct CardSection: View { let header: Header? let footer: Footer? let content: Content + + let borderColor: Color // Initializer for custom view header - init(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> Header, @ViewBuilder footer: () -> Footer) { + init(borderColor: Color = .clear, @ViewBuilder content: () -> Content, @ViewBuilder header: () -> Header, @ViewBuilder footer: () -> Footer) { + self.borderColor = borderColor self.content = content() self.header = header() self.footer = footer() } // Initializer for string header - init(_ headerText: String? = nil, @ViewBuilder content: () -> Content, footerText: String? = nil) where Header == Text, Footer == Text { + init(_ headerText: String? = nil, borderColor: Color = .clear, @ViewBuilder content: () -> Content, footerText: String? = nil) where Header == Text, Footer == Text { + self.borderColor = borderColor self.content = content() self.header = headerText.map { Text($0) } self.footer = footerText.map { Text($0) } } // Initializer for no header - init(@ViewBuilder content: () -> Content) where Header == Text, Footer == Text { + init(borderColor: Color = .clear, @ViewBuilder content: () -> Content) where Header == Text, Footer == Text { + self.borderColor = borderColor self.content = content() self.header = nil self.footer = nil @@ -53,8 +58,8 @@ struct CardSection: View { .padding(.vertical, 12) .background(RoundedRectangle(cornerRadius: 10) .fill(Color(UIColor.tertiarySystemBackground)) + .stroke(borderColor, lineWidth: borderColor == .clear ? 0 : 1) .frame(maxWidth: .infinity)) - .clipped() if let footer = footer { footer .font(.footnote) diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 92cd27e6cc..4a0d47731f 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -24,11 +24,14 @@ struct EditPresetView: View { case editInsulinNeeds } + @State private var trainingCompletion: PresetsTrainingCompletion + @State private var showTrainingIncompleteAlert: Bool = false @State private var preset: SelectablePreset @State private var navigationPath = NavigationPath() @State private var isDurationPickerExpanded = false @State private var showingDayPicker: Bool = false @State private var isConfirmingDelete = false + @State private var showPresetsTrainingSheet: Bool = false @FocusState private var isTextFieldFocused: Bool @@ -46,23 +49,56 @@ struct EditPresetView: View { init( preset: SelectablePreset, scheduledRange: ClosedRange, + trainingCompletion: PresetsTrainingCompletion, onSave: @escaping ((SelectablePreset) throws -> Void), onDelete: @escaping ((SelectablePreset) throws -> Void) ) { self.preset = preset self.originalPreset = preset self.scheduledRange = scheduledRange + self.trainingCompletion = trainingCompletion self.onSave = onSave self.onDelete = onDelete } + + var trainingNeededSection: some View { + Button { + showPresetsTrainingSheet = true + } label: { + CardSection("Temporary Settings Adjustments", borderColor: .accentColor) { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Group { + Text(Image(systemName: "info.circle")) + .foregroundStyle(Color.accentColor) + + Text(" Extra Training Needed") + } + .font(.callout.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("Complete the training to change this preset’s settings. You can still update the details.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } + } + .foregroundColor(.primary) + } + } var sensitivitySection: some View { Button { - if preset.canAdjustSensitivity { + if !preset.isPreMeal && !trainingCompletion.isComplete { + showTrainingIncompleteAlert = true + } else if preset.canAdjustSensitivity { navigationPath.append(Destination.editInsulinNeeds) } } label: { - CardSection("Temporary Settings Adjustments") { + CardSection(preset.isPreMeal || trainingCompletion.isComplete ? "Temporary Settings Adjustments" : nil) { InsulinNeedsAdjustmentPreview( insulinPercentage: preset.insulinNeedsScaleFactor * 100, guardrail: Guardrail.presetInsulinNeeds, @@ -85,12 +121,20 @@ struct EditPresetView: View { ScrollViewReader { scrollViewProxy in CardSectionScrollView { presetTitle + + if !preset.isPreMeal && !trainingCompletion.isComplete { + trainingNeededSection + } sensitivitySection CardSection { Button { - navigationPath.append(Destination.editCorrectionRange) + if !preset.isPreMeal && !trainingCompletion.isComplete { + showTrainingIncompleteAlert = true + } else { + navigationPath.append(Destination.editCorrectionRange) + } } label: { CorrectionRangePreview( range: preset.correctionRange, @@ -108,7 +152,7 @@ struct EditPresetView: View { Button { if case let .activity(activityPreset) = preset { withAnimation { - preset = .activity(ActivityPreset(activityType: activityPreset.activityType, preset: activityPreset.activityType.defaultPreset(duration: activityPreset.preset.duration))) + preset = .activity(ActivityPreset(activityType: activityPreset.activityType, preset: activityPreset.activityType.defaultPreset(duration: activityPreset.preset.duration, scheduleStartDate: activityPreset.preset.scheduleStartDate, repeatOptions: activityPreset.preset.repeatOptions ?? .none))) } } } label: { @@ -367,6 +411,19 @@ struct EditPresetView: View { }) ) } + .alert(isPresented: $showTrainingIncompleteAlert) { + Alert( + title: Text("Extra Training Needed"), + message: Text("Complete the training to change this preset’s settings."), + primaryButton: .default(Text("Start Training"), action: { + showPresetsTrainingSheet = true + }), + secondaryButton: .cancel(Text("Close")) + ) + } + .sheet(isPresented: $showPresetsTrainingSheet) { + PresetsTrainingView(trainingCompletion: trainingCompletion) + } } } diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 4fac44911b..e96d954fd8 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -149,7 +149,6 @@ struct PresetsView: View { .onTapGesture { activeSheet = .presetDetent(preset) } - .disabled(preset.id.hasPrefix("activity-") && trainingCompletion.completedChapters[.introduction] != true) } } } @@ -214,20 +213,11 @@ struct PresetsView: View { } } } - .onAppear { - if trainingCompletion.completedChapters[.entry] != true { - activeSheet = .training() - } - } .sheet(item: $activeSheet) { sheet in switch sheet { case .presetDetent(let preset): PresetDetentView(preset: preset, didTapEdit: { - if case .activity(_) = preset, !trainingCompletion.isComplete { - activeSheet = .training(editPresetWhenComplete: preset) - } else { - activeSheet = .editPreset(preset) - } + activeSheet = .editPreset(preset) }) case .editPreset(let preset): Group { @@ -235,6 +225,7 @@ struct PresetsView: View { EditPresetView( preset: preset, scheduledRange: scheduledRange, + trainingCompletion: trainingCompletion, onSave: { updatedPreset in settingsManager.savePreset(updatedPreset) Task { diff --git a/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift b/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift index 7aa1ac9ec6..0eb511523d 100644 --- a/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift +++ b/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift @@ -13,9 +13,7 @@ struct PresetsTrainingCard: View { let imageName: String? init(trainingCompletion: PresetsTrainingCompletion) { - if trainingCompletion.completedChapters[.introduction] != true { - self.imageName = "PresetsTrainingRequiredCard" - } else if trainingCompletion.completedChapters[.customizingPresets] != true { + if trainingCompletion.completedChapters[.customizingPresets] != true { self.imageName = "PresetsTrainingCreditEditStartCard" } else if trainingCompletion.completedChapters[.trainingComplete] != true { self.imageName = "PresetsTrainingCreditEditResumeCard" diff --git a/Loop/Views/Presets/Training Content/PresetsTraining.swift b/Loop/Views/Presets/Training Content/PresetsTraining.swift index 0499a2d1f8..ada762997b 100644 --- a/Loop/Views/Presets/Training Content/PresetsTraining.swift +++ b/Loop/Views/Presets/Training Content/PresetsTraining.swift @@ -52,8 +52,9 @@ class PresetsTrainingCompletion { @Observable public class PresetsTraining { public enum Chapter: CaseIterable, Hashable, Sendable, Codable { - case entry - case introduction +// Temporarily removed -- will be moved to general onboarding with LOOP-5238 +// case entry +// case introduction case customizingPresets case illness case dailyActivities @@ -62,8 +63,9 @@ public class PresetsTraining { var title: Text { switch self { - case .entry: Text("Entry") - case .introduction: Text("Introduction") +// Temporarily removed -- will be moved to general onboarding with LOOP-5238 +// case .entry: Text("Entry") +// case .introduction: Text("Introduction") case .customizingPresets: Text("Customizing Presets") case .illness: Text("Presets for Illness") case .dailyActivities: Text("Presets for Daily Activities") @@ -74,8 +76,9 @@ public class PresetsTraining { var firstStep: Step { switch self { - case .entry: .entryPoint - case .introduction: .tier1(.introduction(.introduction)) +// Temporarily removed -- will be moved to general onboarding with LOOP-5238 +// case .entry: .entryPoint +// case .introduction: .tier1(.introduction(.introduction)) case .customizingPresets: .tier2(.customizingPresets(.customizingPresets)) case .illness: .tier2(.illness(.commonUses)) case .dailyActivities: .tier2(.dailyActivities(.commonUses)) @@ -86,22 +89,23 @@ public class PresetsTraining { } enum Step: Hashable, Sendable { - case entryPoint +// Temporarily removed -- will be moved to general onboarding with LOOP-5238 +// case entryPoint - enum Tier1Chapter: Hashable, Sendable { - enum Introduction: CaseIterable, Hashable, Sendable { - case introduction - case exercisingWithLoop - case timingYourPresets - case safeGlucoseRanges - case performanceHistory - case complete - } - - case introduction(Introduction) - } - - case tier1(Tier1Chapter) +// enum Tier1Chapter: Hashable, Sendable { +// enum Introduction: CaseIterable, Hashable, Sendable { +// case introduction +// case exercisingWithLoop +// case timingYourPresets +// case safeGlucoseRanges +// case performanceHistory +// case complete +// } +// +// case introduction(Introduction) +// } +// +// case tier1(Tier1Chapter) enum Tier2Chapter: Hashable, Sendable { enum CustomizingPresets: CaseIterable, Hashable, Sendable { @@ -157,26 +161,27 @@ public class PresetsTraining { func title(appName: String) -> String { switch self { - case .entryPoint: - NSLocalizedString("Presets Training", comment: "") - case .tier1(let tier1Chapter): - switch tier1Chapter { - case .introduction(let introduction): - switch introduction { - case .introduction: - NSLocalizedString("Part 1: Introduction to Presets", comment: "") - case .exercisingWithLoop: - String(format: NSLocalizedString("Exercising with %1$@", comment: ""), appName) - case .timingYourPresets: - NSLocalizedString("Timing Your Presets for Exercise", comment: "") - case .safeGlucoseRanges: - NSLocalizedString("Safe Glucose Ranges for Exercise", comment: "") - case .performanceHistory: - NSLocalizedString("Performance History", comment: "") - case .complete: - NSLocalizedString("Part 1: Complete", comment: "") - } - } +// Temporarily removed -- will be moved to general onboarding with LOOP-5238 +// case .entryPoint: +// NSLocalizedString("Presets Training", comment: "") +// case .tier1(let tier1Chapter): +// switch tier1Chapter { +// case .introduction(let introduction): +// switch introduction { +// case .introduction: +// NSLocalizedString("Part 1: Introduction to Presets", comment: "") +// case .exercisingWithLoop: +// String(format: NSLocalizedString("Exercising with %1$@", comment: ""), appName) +// case .timingYourPresets: +// NSLocalizedString("Timing Your Presets for Exercise", comment: "") +// case .safeGlucoseRanges: +// NSLocalizedString("Safe Glucose Ranges for Exercise", comment: "") +// case .performanceHistory: +// NSLocalizedString("Performance History", comment: "") +// case .complete: +// NSLocalizedString("Part 1: Complete", comment: "") +// } +// } case .tier2(let tier2Chapter): switch tier2Chapter { case .customizingPresets(let customizingPresets): @@ -248,24 +253,27 @@ public class PresetsTraining { func previous(startingFrom: Chapter) -> Step? { switch self { - case .entryPoint: nil - case .tier1(let tier1Chapter): - switch tier1Chapter { - case .introduction(let introduction): - switch introduction { - case .introduction: chapter != startingFrom ? nil : .entryPoint - case .exercisingWithLoop: .tier1(.introduction(.introduction)) - case .timingYourPresets: .tier1(.introduction(.exercisingWithLoop)) - case .safeGlucoseRanges: .tier1(.introduction(.timingYourPresets)) - case .performanceHistory: .tier1(.introduction(.safeGlucoseRanges)) - case .complete: .tier1(.introduction(.performanceHistory)) - } - } +// Temporarily removed -- will be moved to general onboarding with LOOP-5238 +// case .entryPoint: nil +// case .tier1(let tier1Chapter): +// switch tier1Chapter { +// case .introduction(let introduction): +// switch introduction { +// case .introduction: chapter != startingFrom ? nil : .entryPoint +// case .exercisingWithLoop: .tier1(.introduction(.introduction)) +// case .timingYourPresets: .tier1(.introduction(.exercisingWithLoop)) +// case .safeGlucoseRanges: .tier1(.introduction(.timingYourPresets)) +// case .performanceHistory: .tier1(.introduction(.safeGlucoseRanges)) +// case .complete: .tier1(.introduction(.performanceHistory)) +// } +// } case .tier2(let tier2Chapter): switch tier2Chapter { case .customizingPresets(let customizingPresets): switch customizingPresets { - case .customizingPresets: chapter != startingFrom ? nil : .tier1(.introduction(.complete)) +// Temporarily removed -- will be moved to general onboarding with LOOP-5238 +// case .customizingPresets: chapter != startingFrom ? nil : .tier1(.introduction(.complete)) + case .customizingPresets: nil case .overallInsulin: .tier2(.customizingPresets(.customizingPresets)) case .correctionRange: .tier2(.customizingPresets(.overallInsulin)) } @@ -308,19 +316,20 @@ public class PresetsTraining { func next() -> (Step?, completedChapter: Chapter?) { switch self { - case .entryPoint: (.tier1(.introduction(.introduction)), .entry) - case .tier1(let tier1Chapter): - switch tier1Chapter { - case .introduction(let introduction): - switch introduction { - case .introduction: (.tier1(.introduction(.exercisingWithLoop)), nil) - case .exercisingWithLoop: (.tier1(.introduction(.timingYourPresets)), nil) - case .timingYourPresets: (.tier1(.introduction(.safeGlucoseRanges)), nil) - case .safeGlucoseRanges: (.tier1(.introduction(.performanceHistory)), nil) - case .performanceHistory: (.tier1(.introduction(.complete)), nil) - case .complete: (.tier2(.customizingPresets(.customizingPresets)), .introduction) - } - } +// Temporarily removed -- will be moved to general onboarding with LOOP-5238 +// case .entryPoint: (.tier1(.introduction(.introduction)), .entry) +// case .tier1(let tier1Chapter): +// switch tier1Chapter { +// case .introduction(let introduction): +// switch introduction { +// case .introduction: (.tier1(.introduction(.exercisingWithLoop)), nil) +// case .exercisingWithLoop: (.tier1(.introduction(.timingYourPresets)), nil) +// case .timingYourPresets: (.tier1(.introduction(.safeGlucoseRanges)), nil) +// case .safeGlucoseRanges: (.tier1(.introduction(.performanceHistory)), nil) +// case .performanceHistory: (.tier1(.introduction(.complete)), nil) +// case .complete: (.tier2(.customizingPresets(.customizingPresets)), .introduction) +// } +// } case .tier2(let tier2Chapter): switch tier2Chapter { case .customizingPresets(let customizingPresets): @@ -368,8 +377,9 @@ public class PresetsTraining { var chapter: Chapter { switch self { - case .entryPoint: .entry - case .tier1: .introduction +// Temporarily removed -- will be moved to general onboarding with LOOP-5238 +// case .entryPoint: .entry +// case .tier1: .introduction case .tier2(.customizingPresets): .customizingPresets case .tier2(.illness): .illness case .tier2(.dailyActivities): .dailyActivities @@ -396,7 +406,9 @@ public class PresetsTraining { navigationPath.last ?? startingAt.firstStep } - private(set) var startingAt: Chapter = .entry +// Temporarily changed -- will be moved to general onboarding with LOOP-5238 +// private(set) var startingAt: Chapter = .entry + private(set) var startingAt: Chapter = .customizingPresets let trainingCompletion: PresetsTrainingCompletion @@ -422,7 +434,9 @@ public class PresetsTraining { if let startingAt { self.startingAt = startingAt } else { - self.startingAt = .entry +// Temporarily changed -- will be moved to general onboarding with LOOP-5238 +// self.startingAt = .entry + self.startingAt = .customizingPresets } } } diff --git a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift index 407ecd85e3..9f8dccde2a 100644 --- a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift +++ b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift @@ -30,167 +30,168 @@ extension PresetsTraining.Step: PresetsTrainingContent { @ViewBuilder func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, next: @escaping () -> Void) -> some View { switch self { - case .entryPoint: - if let image = Image("PresetsTrainingEntryHero") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - EstimatedReadTime(.minutes(3)) - - Text("Presets allow you temporarily adjust your settings for events like meals, exercise, illness, or hormonal changes that may affect your diabetes management.") - - VStack(alignment: .leading) { - Text("We'll walk you through the following:") - - BulletedListView { - Text("How Presets Work") - Text("Using pre-configured presets") - Text("Timing your presets for exercise") - Text("Safe Glucose Ranges for Exercise") - } - .padding(.leading, 8) - } - - case .tier1(let tier1Chapter): - switch tier1Chapter { - case .introduction(let introduction): - switch introduction { - case .introduction: - if let image = Image("PresetsTrainingEntryHero") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - EstimatedReadTime(.minutes(3)) - - VStack(alignment: .leading) { - Text("With a preset, you can:") - - BulletedListView { - Text("Adjust your overall insulin needs") - Text("Set an adjusted correction range") - Text("Choose a duration") - Text("Schedule a preset in advance") - } - .padding(.leading, 8) - } - - VStack(alignment: .leading) { - Text("Adjusting Overall Insulin Needs") - .font(.title2.bold()) - - Text("Overall insulin should be adjusted when your body needs more or less insulin than normal.") - } - - VStack(alignment: .leading) { - Text("Adjusting Correction Range") - .font(.title2.bold()) - - Text("The correction range is a safety setting. Adjusting it can help reduce the risk of low glucose if you expect unusual changes.") - } - - case .exercisingWithLoop: - if let image = Image("PresetsTrainingExercisingWithLoopHero") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - Text("Exercise and physical activity are common uses for presets.") - - Text("\(appName) has a few preset options designed to help with your insulin management. We designed these for various types of physical activities.") - - ActivityPreset.bulletList(full: true) - .padding(.leading, 8) - - Text("These presets are a starting point to help manage your glucose. You may need to work with your healthcare provider to edit them to meet your personal diabetes needs.") - - case .timingYourPresets: - Text("\(appName) suggests starting a preset for exercise at least 1 hour ahead of time. Keep it on until you finish your activity.") - - Text("If you forget to turn on a preset, turn it on as soon as you remember and keep it on until the activity ends.") - - InsetContent { - Timeline { - TimelineStep( - symbol: Image(systemName: "clock"), - title: Text("1 Hour Before"), - subtitle: Text("Enable your preset") - ) - - TimelineStep( - symbol: Image(systemName: "figure.run"), - title: Text("During Activity"), - subtitle: Text("Keep preset on throughout your exercise") - ) - - TimelineStep( - symbol: Image(systemName: "checkmark"), - symbolInset: 2, - title: Text("Activity Ends"), - subtitle: Text("Turn off preset when you finish exercising") - ) - } - } - - Text("You can plan ahead and schedule presets to start at a certain date and time. The app will send you a reminder and ask if you'd like to start the preset.") - - case .safeGlucoseRanges: - Text("Before starting exercise, make sure to check your glucose.") - - Text("Aim for your glucose to be between \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), includeUnit: false)) and \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: true)) before exercising. Based on current research, this can help prevent high and low levels during or after your workout.") - - InsetContent { - Text("Safe Starting Glucose Range") - .bold() - - Group { - Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: false)) ") - .font(.system(size: UIFontMetrics.default.scaledValue(for: 32)).weight(.heavy)) - + Text(displayGlucosePreference.unit.localizedShortUnitString) - } - .foregroundStyle(colorPalette.carbTintColor) - - Text("\(Image(systemName: "exclamationmark.circle")) Consider a small snack to prevent lows") - .font(.footnote) - .foregroundStyle(.secondary) - } - - Callout(.caution, title: Text("Starting a preset, especially one decreasing insulin, when your glucose is above \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: true)) may reduce its effectiveness and impact your results.")) - .padding(.horizontal, -16) - - Text("Always check your glucose before, during, and after any activity to ensure safe and optimal outcomes.") - - case .performanceHistory: - if let image = Image("PresetsTrainingPerformanceHistoryHero") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - Text("Performance History gives you a clear picture of how each preset helped manage your glucose.") - - Text("You can quickly review a summary of key data during the preset and for the six hours that follow to understand the full impact of the preset’s settings.") - - Text("To get started, tap Presets, then Performance History, and select the preset you want to review.") - - Text("Performance history is available for up to seven days.") - - case .complete: - Text("You can now use the following presets:") - - ActivityPreset.bulletList(full: false) - - Text("Complete Part 2 to enable preset editing and creation.") - } - } +// Temporarily removed -- will be moved to general onboarding with LOOP-5238 +// case .entryPoint: +// if let image = Image("PresetsTrainingEntryHero") { +// image +// .resizable() +// .scaledToFit() +// .frame(maxWidth: .infinity) +// } +// +// EstimatedReadTime(.minutes(3)) +// +// Text("Presets allow you temporarily adjust your settings for events like meals, exercise, illness, or hormonal changes that may affect your diabetes management.") +// +// VStack(alignment: .leading) { +// Text("We'll walk you through the following:") +// +// BulletedListView { +// Text("How Presets Work") +// Text("Using pre-configured presets") +// Text("Timing your presets for exercise") +// Text("Safe Glucose Ranges for Exercise") +// } +// .padding(.leading, 8) +// } +// +// case .tier1(let tier1Chapter): +// switch tier1Chapter { +// case .introduction(let introduction): +// switch introduction { +// case .introduction: +// if let image = Image("PresetsTrainingEntryHero") { +// image +// .resizable() +// .scaledToFit() +// .frame(maxWidth: .infinity) +// } +// +// EstimatedReadTime(.minutes(3)) +// +// VStack(alignment: .leading) { +// Text("With a preset, you can:") +// +// BulletedListView { +// Text("Adjust your overall insulin needs") +// Text("Set an adjusted correction range") +// Text("Choose a duration") +// Text("Schedule a preset in advance") +// } +// .padding(.leading, 8) +// } +// +// VStack(alignment: .leading) { +// Text("Adjusting Overall Insulin Needs") +// .font(.title2.bold()) +// +// Text("Overall insulin should be adjusted when your body needs more or less insulin than normal.") +// } +// +// VStack(alignment: .leading) { +// Text("Adjusting Correction Range") +// .font(.title2.bold()) +// +// Text("The correction range is a safety setting. Adjusting it can help reduce the risk of low glucose if you expect unusual changes.") +// } +// +// case .exercisingWithLoop: +// if let image = Image("PresetsTrainingExercisingWithLoopHero") { +// image +// .resizable() +// .scaledToFit() +// .frame(maxWidth: .infinity) +// } +// +// Text("Exercise and physical activity are common uses for presets.") +// +// Text("\(appName) has a few preset options designed to help with your insulin management. We designed these for various types of physical activities.") +// +// ActivityPreset.bulletList(full: true) +// .padding(.leading, 8) +// +// Text("These presets are a starting point to help manage your glucose. You may need to work with your healthcare provider to edit them to meet your personal diabetes needs.") +// +// case .timingYourPresets: +// Text("\(appName) suggests starting a preset for exercise at least 1 hour ahead of time. Keep it on until you finish your activity.") +// +// Text("If you forget to turn on a preset, turn it on as soon as you remember and keep it on until the activity ends.") +// +// InsetContent { +// Timeline { +// TimelineStep( +// symbol: Image(systemName: "clock"), +// title: Text("1 Hour Before"), +// subtitle: Text("Enable your preset") +// ) +// +// TimelineStep( +// symbol: Image(systemName: "figure.run"), +// title: Text("During Activity"), +// subtitle: Text("Keep preset on throughout your exercise") +// ) +// +// TimelineStep( +// symbol: Image(systemName: "checkmark"), +// symbolInset: 2, +// title: Text("Activity Ends"), +// subtitle: Text("Turn off preset when you finish exercising") +// ) +// } +// } +// +// Text("You can plan ahead and schedule presets to start at a certain date and time. The app will send you a reminder and ask if you'd like to start the preset.") +// +// case .safeGlucoseRanges: +// Text("Before starting exercise, make sure to check your glucose.") +// +// Text("Aim for your glucose to be between \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), includeUnit: false)) and \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: true)) before exercising. Based on current research, this can help prevent high and low levels during or after your workout.") +// +// InsetContent { +// Text("Safe Starting Glucose Range") +// .bold() +// +// Group { +// Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: false)) ") +// .font(.system(size: UIFontMetrics.default.scaledValue(for: 32)).weight(.heavy)) +// + Text(displayGlucosePreference.unit.localizedShortUnitString) +// } +// .foregroundStyle(colorPalette.carbTintColor) +// +// Text("\(Image(systemName: "exclamationmark.circle")) Consider a small snack to prevent lows") +// .font(.footnote) +// .foregroundStyle(.secondary) +// } +// +// Callout(.caution, title: Text("Starting a preset, especially one decreasing insulin, when your glucose is above \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: true)) may reduce its effectiveness and impact your results.")) +// .padding(.horizontal, -16) +// +// Text("Always check your glucose before, during, and after any activity to ensure safe and optimal outcomes.") +// +// case .performanceHistory: +// if let image = Image("PresetsTrainingPerformanceHistoryHero") { +// image +// .resizable() +// .scaledToFit() +// .frame(maxWidth: .infinity) +// } +// +// Text("Performance History gives you a clear picture of how each preset helped manage your glucose.") +// +// Text("You can quickly review a summary of key data during the preset and for the six hours that follow to understand the full impact of the preset’s settings.") +// +// Text("To get started, tap Presets, then Performance History, and select the preset you want to review.") +// +// Text("Performance history is available for up to seven days.") +// +// case .complete: +// Text("You can now use the following presets:") +// +// ActivityPreset.bulletList(full: false) +// +// Text("Complete Part 2 to enable preset editing and creation.") +// } +// } case .tier2(let tier2Chapter): switch tier2Chapter { case .customizingPresets(let customizingPresets): @@ -1116,19 +1117,20 @@ extension PresetsTraining.Step: PresetsTrainingContent { var cta: PresetsTraining.CTA? { switch self { - case .entryPoint: .start - case .tier1(let tier1Chapter): - switch tier1Chapter { - case .introduction(let introduction): - switch introduction { - case .introduction, - .exercisingWithLoop, - .timingYourPresets, - .safeGlucoseRanges, - .performanceHistory: .continue - case .complete: .closeOrContinue("Step 2", chapter: .introduction) - } - } +// Temporarily removed -- will be moved to general onboarding with LOOP-5238 +// case .entryPoint: .start +// case .tier1(let tier1Chapter): +// switch tier1Chapter { +// case .introduction(let introduction): +// switch introduction { +// case .introduction, +// .exercisingWithLoop, +// .timingYourPresets, +// .safeGlucoseRanges, +// .performanceHistory: .continue +// case .complete: .closeOrContinue("Step 2", chapter: .introduction) +// } +// } case .tier2(let tier2Chapter): switch tier2Chapter { case .customizingPresets: .continue diff --git a/Loop/Views/Presets/Training Content/PresetsTrainingView.swift b/Loop/Views/Presets/Training Content/PresetsTrainingView.swift index bb870123d6..0d96e50a8c 100644 --- a/Loop/Views/Presets/Training Content/PresetsTrainingView.swift +++ b/Loop/Views/Presets/Training Content/PresetsTrainingView.swift @@ -45,9 +45,6 @@ struct PresetsTrainingView: View { Button("Close") { if training.trainingCompletion.isComplete { close() - } else if training.trainingCompletion.completedChapters[.entry] != true { - training.trainingCompletion.completedChapters[.entry] = true - close() } else { confirmDismiss = true } diff --git a/LoopCore/SelectablePreset.swift b/LoopCore/SelectablePreset.swift index 84131b91fb..b9cbc83fa6 100644 --- a/LoopCore/SelectablePreset.swift +++ b/LoopCore/SelectablePreset.swift @@ -236,7 +236,7 @@ public enum SelectablePreset: Hashable, Identifiable { case .activity(var activity): activity.preset.scheduleStartDate = newValue self = .activity(activity) - default: + case .preMeal: break } } @@ -247,7 +247,9 @@ public enum SelectablePreset: Hashable, Identifiable { switch self { case .custom(let preset): return preset.repeatOptions ?? .none - case .preMeal, .activity: + case .activity(let activity): + return activity.preset.repeatOptions ?? .none + case .preMeal: return .none } } @@ -256,7 +258,10 @@ public enum SelectablePreset: Hashable, Identifiable { case .custom(var preset): preset.repeatOptions = newValue self = .custom(preset) - default: + case .activity(var activity): + activity.preset.repeatOptions = newValue + self = .activity(activity) + case .preMeal: break } } diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift index ec799864a9..21e367db88 100644 --- a/WatchApp Extension/Managers/LoopDataManager.swift +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -287,7 +287,7 @@ extension LoopDataManager { .activity( ActivityPreset( activityType: activityType, - preset: activityType.defaultPreset(duration: .finite(.minutes(90))) + preset: activityType.completeDefaultPreset ) ) ) From c043ecf9c6d60375e97b2b1d07560bea1ec4e2c9 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 12 Nov 2025 08:46:32 -0400 Subject: [PATCH 322/421] [LOOP-5545] retract alert when preset starts (#864) * retract alert when preset starts * respond to PR comments * correcting glucose units displayed --- Loop/Managers/TemporaryPresetsManager.swift | 5 +++-- Loop/Views/Presets/PresetRangeEditor.swift | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index c114c3fdab..0e18cb4707 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -483,9 +483,10 @@ extension TemporaryPresetsManager : AlertResponder { if actionIdentifier == NotificationManager.Action.startPreset.rawValue, let metdata = alert.metadata, - let presetIdentifier = metdata["presetId"]?.wrapped as? String? + let presetIdentifier = metdata["presetId"]?.wrapped as? String { - startPreset(withIdentifier: presetIdentifier!) + startPreset(withIdentifier: presetIdentifier) + await alertIssuer?.retractAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: presetIdentifier)) } else { log.error("Could not identify preset to activate for alert action: actionIdentifier=%{public}@, alert=%{public}@", actionIdentifier, String(describing: alert)) } diff --git a/Loop/Views/Presets/PresetRangeEditor.swift b/Loop/Views/Presets/PresetRangeEditor.swift index d2a4deba41..db11748267 100644 --- a/Loop/Views/Presets/PresetRangeEditor.swift +++ b/Loop/Views/Presets/PresetRangeEditor.swift @@ -116,7 +116,7 @@ struct PresetRangeEditor: View { .accessibilityIdentifier("text_AdjustedCorrectionRange") - Text("mg/dL") + Text(displayGlucosePreference.unit.localizedShortUnitString) .foregroundColor(.secondary) } From 0d920648b52c855a1ee2cde9759ed1495450415c Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 12 Nov 2025 14:41:10 -0400 Subject: [PATCH 323/421] [LOOP-5550] Display confirm delete preset alert (#865) * refactored EditPresetView to allow for multiple alerts * minor cleanup --- Loop/Views/Presets/EditPresetView.swift | 486 +++++++++++++----------- 1 file changed, 268 insertions(+), 218 deletions(-) diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 4a0d47731f..c373077198 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -23,29 +23,33 @@ struct EditPresetView: View { case editCorrectionRange case editInsulinNeeds } + + fileprivate enum AlertState { + case confirmDelete + case trainingIncomplete + } @State private var trainingCompletion: PresetsTrainingCompletion - @State private var showTrainingIncompleteAlert: Bool = false @State private var preset: SelectablePreset @State private var navigationPath = NavigationPath() @State private var isDurationPickerExpanded = false @State private var showingDayPicker: Bool = false - @State private var isConfirmingDelete = false @State private var showPresetsTrainingSheet: Bool = false - + @State private var activeAlert: AlertState? + @FocusState private var isTextFieldFocused: Bool - + private var originalPreset: SelectablePreset private var scheduledRange: ClosedRange private var onSave: (SelectablePreset) throws -> Void private var onDelete: (SelectablePreset) throws -> Void - + private var activityPresetIsModified: Bool? { guard case let .activity(activityPreset) = preset else { return nil } return activityPreset.isModifiedFromDefault } - + init( preset: SelectablePreset, scheduledRange: ClosedRange, @@ -93,7 +97,7 @@ struct EditPresetView: View { var sensitivitySection: some View { Button { if !preset.isPreMeal && !trainingCompletion.isComplete { - showTrainingIncompleteAlert = true + activeAlert = .trainingIncomplete } else if preset.canAdjustSensitivity { navigationPath.append(Destination.editInsulinNeeds) } @@ -125,13 +129,13 @@ struct EditPresetView: View { if !preset.isPreMeal && !trainingCompletion.isComplete { trainingNeededSection } - + sensitivitySection - + CardSection { Button { if !preset.isPreMeal && !trainingCompletion.isComplete { - showTrainingIncompleteAlert = true + activeAlert = .trainingIncomplete } else { navigationPath.append(Destination.editCorrectionRange) } @@ -174,196 +178,21 @@ struct EditPresetView: View { } .padding(.vertical, 4) } - - CardSection("Preset Details") { - HStack { - Text("Name") - Spacer() - if preset.canChangeName { - TextField("", text: $preset.name, prompt: Text("Required")) - .multilineTextAlignment(.trailing) - .focused($isTextFieldFocused) - .foregroundColor(.secondary) - } else { - HStack(spacing: 4) { - if case let .activity(activityPreset) = preset { - Text(Image(systemName: activityPreset.activityType.symbol.value)) - } - - Text(preset.name) - } - .foregroundColor(.secondary) - } - } - } - + + presetDetailsCard + // Duration Section if preset.canAdjustDuration { - CardSection { - VStack(alignment: .leading) { - HStack { - Text("Duration") - .foregroundColor(.primary) - Spacer() - Group { - Text(preset.duration.localizedTitle) - Image(systemName: "chevron.right") - } - .foregroundColor(.secondary) - } - .contentShape(Rectangle()) - .onTapGesture { - isTextFieldFocused = false - withAnimation { - isDurationPickerExpanded.toggle() - Task { - if isDurationPickerExpanded { - try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay - withAnimation { - scrollViewProxy.scrollTo("durationPicker", anchor: .bottom) - } - } - } - } - } - - if isDurationPickerExpanded { - DurationPickerView( - durationType: $preset.duration, - allowIndefinite: preset.allowsIndefiniteDuration - ) - .id("durationPicker") // Assign an ID for scrolling - } - } - } - .id("durationSection") // Optional: ID for the entire duration section + durationCard(scrollViewProxy) } - + // Schedule Toggle if preset.allowsScheduling { - CardSection { - HStack { - Text("Schedule") - .font(.body) - - Spacer() - - Toggle("", isOn: Binding(get: { - return preset.isScheduled - }, set: { newValue in - withAnimation { - if newValue { - preset.scheduleStartDate = Date().addingTimeInterval(.hours(1)) - Task { - try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay - withAnimation { - scrollViewProxy.scrollTo("repeatOption", anchor: .bottom) - } - } - } else { - preset.scheduleStartDate = nil - preset.repeatOptions = .none - } - } - })) - .toggleStyle(SwitchToggleStyle(tint: .green)) - .labelsHidden() - .padding(.vertical, -4) - } - - if preset.isScheduled { - Divider() - HStack { - if preset.repeatOptions != .none { - Text("Next Date") - } else { - Text("Start Date") - } - Spacer() - DatePicker( - "", - selection: Binding(get: { - preset.nextScheduledStartAfter(Date()) ?? Date() - }, set: { newValue in - preset.scheduleStartDate = newValue - }), - in: Date().addingTimeInterval(.minutes(1))..., - displayedComponents: [.date, .hourAndMinute] - ) - } - Divider() - .padding(.top, -4) - HStack { - Text("Repeat") - Spacer() - Picker("Repeat", selection: Binding( - get: { preset.repeatOptions == .none ? .never : .weekly }, - set: { newValue in - if newValue == .never { - preset.repeatOptions = .none - } else { - Task { - if let requiredRepeatOption { - preset.repeatOptions = requiredRepeatOption - } - try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay - withAnimation { - scrollViewProxy.scrollTo("selectedDays", anchor: .bottom) - } - } - } - } - ).animation()) { - ForEach(RepeatOption.allCases, id: \.self) { option in - Text(String(describing: option)) - } - } - .tint(.secondary) - .pickerStyle(MenuPickerStyle()) - .padding(.trailing, -8) - } - .id("repeatOption") // Assign an ID for scrolling - - - if preset.repeatOptions != .none { - Divider() - .padding(.top, -4) - HStack { - Text("Selected days") - .foregroundColor(.primary) - HStack { - Spacer() - RepeatOptionView(repeatOptions: preset.repeatOptions) - .padding(.vertical, 6) - .onTapGesture { - withAnimation { - showingDayPicker = true - } - } - } - .popover(isPresented: $showingDayPicker, arrowEdge: .bottom) { - DayPickerPopup(selectedDays: Binding( - get: { - preset.repeatOptions - }, set: { newValue in - preset.repeatOptions = newValue.union(requiredRepeatOption ?? .none) - })) - .cornerRadius(12) - .presentationCompactAdaptation(.popover) - } - } - .id("selectedDays") // Assign an ID for scrolling - } - } - } + schedulingCard(scrollViewProxy) } - + if preset.canBeDeleted { - Button("Delete Preset") { - isConfirmingDelete = true - } - .buttonStyle(ActionButtonStyle(.destructive)) - .padding(.top) + deletePresetButton } } .animation(.easeInOut, value: preset.duration) @@ -388,7 +217,6 @@ struct EditPresetView: View { ) } } - .onChange(of: preset) { do { try onSave(preset) @@ -396,37 +224,208 @@ struct EditPresetView: View { print(error) } } - .alert(isPresented: $isConfirmingDelete) { - Alert( - title: Text("Delete “\(preset.name)”?"), - message: Text("Are you sure you want to delete this preset?"), - primaryButton: .default(Text("Go Back")), - secondaryButton: .destructive(Text("Yes, Delete").bold(), action: { - do { - try onDelete(preset) - dismiss() - } catch { - print(error) - } - }) - ) - } - .alert(isPresented: $showTrainingIncompleteAlert) { - Alert( - title: Text("Extra Training Needed"), - message: Text("Complete the training to change this preset’s settings."), - primaryButton: .default(Text("Start Training"), action: { - showPresetsTrainingSheet = true - }), - secondaryButton: .cancel(Text("Close")) - ) + .alert(alertTitle, isPresented: isAlertPresented, presenting: activeAlert) { alertState in + alertActions(for: alertState) + } message: { alertState in + alertMessage(for: alertState) } .sheet(isPresented: $showPresetsTrainingSheet) { PresetsTrainingView(trainingCompletion: trainingCompletion) } } } + + private var presetDetailsCard: some View { + CardSection("Preset Details") { + HStack { + Text("Name") + Spacer() + if preset.canChangeName { + TextField("", text: $preset.name, prompt: Text("Required")) + .multilineTextAlignment(.trailing) + .focused($isTextFieldFocused) + .foregroundColor(.secondary) + } else { + HStack(spacing: 4) { + if case let .activity(activityPreset) = preset { + Text(Image(systemName: activityPreset.activityType.symbol.value)) + } + + Text(preset.name) + } + .foregroundColor(.secondary) + } + } + } + } + + private func durationCard(_ proxy: ScrollViewProxy) -> some View { + CardSection { + VStack(alignment: .leading) { + HStack { + Text("Duration") + .foregroundColor(.primary) + Spacer() + Group { + Text(preset.duration.localizedTitle) + Image(systemName: "chevron.right") + } + .foregroundColor(.secondary) + } + .contentShape(Rectangle()) + .onTapGesture { + isTextFieldFocused = false + withAnimation { + isDurationPickerExpanded.toggle() + Task { + if isDurationPickerExpanded { + try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay + withAnimation { + proxy.scrollTo("durationPicker", anchor: .bottom) + } + } + } + } + } + + if isDurationPickerExpanded { + DurationPickerView( + durationType: $preset.duration, + allowIndefinite: preset.allowsIndefiniteDuration + ) + .id("durationPicker") // Assign an ID for scrolling + } + } + } + .id("durationSection") // Optional: ID for the entire duration section + } + + private var deletePresetButton: some View { + Button("Delete Preset") { + activeAlert = .confirmDelete + } + .buttonStyle(ActionButtonStyle(.destructive)) + .padding(.top) + } + private func schedulingCard(_ proxy: ScrollViewProxy) -> some View { + CardSection { + HStack { + Text("Schedule") + .font(.body) + + Spacer() + + Toggle("", isOn: Binding(get: { + return preset.isScheduled + }, set: { newValue in + withAnimation { + if newValue { + preset.scheduleStartDate = Date().addingTimeInterval(.hours(1)) + Task { + try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay + withAnimation { + proxy.scrollTo("repeatOption", anchor: .bottom) + } + } + } else { + preset.scheduleStartDate = nil + preset.repeatOptions = .none + } + } + })) + .toggleStyle(SwitchToggleStyle(tint: .green)) + .labelsHidden() + .padding(.vertical, -4) + } + + if preset.isScheduled { + Divider() + HStack { + if preset.repeatOptions != .none { + Text("Next Date") + } else { + Text("Start Date") + } + Spacer() + DatePicker( + "", + selection: Binding(get: { + preset.nextScheduledStartAfter(Date()) ?? Date() + }, set: { newValue in + preset.scheduleStartDate = newValue + }), + in: Date().addingTimeInterval(.minutes(1))..., + displayedComponents: [.date, .hourAndMinute] + ) + } + Divider() + .padding(.top, -4) + HStack { + Text("Repeat") + Spacer() + Picker("Repeat", selection: Binding( + get: { preset.repeatOptions == .none ? .never : .weekly }, + set: { newValue in + if newValue == .never { + preset.repeatOptions = .none + } else { + Task { + if let requiredRepeatOption { + preset.repeatOptions = requiredRepeatOption + } + try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay + withAnimation { + proxy.scrollTo("selectedDays", anchor: .bottom) + } + } + } + } + ).animation()) { + ForEach(RepeatOption.allCases, id: \.self) { option in + Text(String(describing: option)) + } + } + .tint(.secondary) + .pickerStyle(MenuPickerStyle()) + .padding(.trailing, -8) + } + .id("repeatOption") // Assign an ID for scrolling + + + if preset.repeatOptions != .none { + Divider() + .padding(.top, -4) + HStack { + Text("Selected days") + .foregroundColor(.primary) + HStack { + Spacer() + RepeatOptionView(repeatOptions: preset.repeatOptions) + .padding(.vertical, 6) + .onTapGesture { + withAnimation { + showingDayPicker = true + } + } + } + .popover(isPresented: $showingDayPicker, arrowEdge: .bottom) { + DayPickerPopup(selectedDays: Binding( + get: { + preset.repeatOptions + }, set: { newValue in + preset.repeatOptions = newValue.union(requiredRepeatOption ?? .none) + })) + .cornerRadius(12) + .presentationCompactAdaptation(.popover) + } + } + .id("selectedDays") // Assign an ID for scrolling + } + } + } + } + private var requiredRepeatOption: PresetScheduleRepeatOptions? { guard let startDate = preset.scheduleStartDate else { return nil } return .allCases[Calendar.current.component(.weekday, from: startDate) - 1] @@ -449,4 +448,55 @@ struct EditPresetView: View { .foregroundColor(.primary) } } + + private var alertTitle: String { + switch activeAlert { + case .confirmDelete: return "Delete “\(preset.name)”?" + case .trainingIncomplete: return "Extra Training Needed" + case .none: return "" + } + } + + private var isAlertPresented: Binding { + Binding( + get: { activeAlert != nil }, + set: { if !$0 { activeAlert = nil } } + ) + } + + @ViewBuilder + private func alertActions(for alertState: AlertState) -> some View { + switch alertState { + case .confirmDelete: + Button("Go Back", role: .cancel) { + activeAlert = nil + } + Button("Yes, Delete", role: .destructive) { + do { + try onDelete(preset) + dismiss() + } catch { + print(error) + } + } + case .trainingIncomplete: + Button("Start Training") { + showPresetsTrainingSheet = true + activeAlert = nil + } + Button("Close", role: .cancel) { + activeAlert = nil + } + } + } + + @ViewBuilder + private func alertMessage(for alertState: AlertState) -> some View { + switch alertState { + case .confirmDelete: + Text("Are you sure you want to delete this preset?") + case .trainingIncomplete: + Text("Complete the training to change this preset’s settings.") + } + } } From 55d9a837d8e5f07ba663891fc4ee29c7a7c45616 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 13 Nov 2025 13:29:26 -0400 Subject: [PATCH 324/421] [LOOP-5425] added automated caption (#866) --- .../Insulin Delivery Log/InsulinDeliveryLogEventRow.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift index 4558ff2a1f..324f6215bb 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift @@ -223,6 +223,9 @@ struct InsulinDeliveryLogEventRow: View { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 0) { bolusTitle(deliveryAmount: deliveryAmount, programmedAmount: programmedAmount) + Text("Automated") + .font(.footnote) + .foregroundStyle(.secondary) } Spacer() From fd62aa26d1a64b1b1277b46de45ff8df36efc4ca Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 18 Nov 2025 10:23:50 -0800 Subject: [PATCH 325/421] [LOOP-5500] Show "No Delivery" in Insulin Delivery Log During Pump Error (#867) --- .../InsulinDeliveryLog.swift | 5 ----- .../InsulinDeliveryLogViewModel.swift | 16 ++++------------ 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift index 159d149974..668ed41b74 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift @@ -141,11 +141,6 @@ struct InsulinDeliveryLog: View { Section { totalInsulinDeliveredLabel(from: data.totalInsulinDelivered) } - case .error(let fetchError): - switch fetchError { - case .noBasalRateSchedule: // FIXME: Needed? - Text("No Basal Rate Schedule") - } } Section { diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index 55813fd320..1a612d2b3e 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -42,14 +42,9 @@ class InsulinDeliveryLogViewModel { } enum State: Hashable { - enum FetchError: Error { - case noBasalRateSchedule - } - case loading case fetched(DisplayData) case refreshing(DisplayData) - case error(FetchError) } let totalDeliveredFormatter: QuantityFormatter = { @@ -97,7 +92,7 @@ class InsulinDeliveryLogViewModel { displayEvents.append(.event(event)) } } - case .loading, .error: + case .loading: break } @@ -174,11 +169,6 @@ class InsulinDeliveryLogViewModel { let lastAutoBolus = fetchLastAutoBolus(doses: doses) let decisions = await fetchDosingDecisions(doses.compactMap(\.decisionId)) - guard let currentBasalRate = fetchCurrentBasal() else { - state = .error(.noBasalRateSchedule) - return - } - // map raw event data into delivery log events for display var events = [InsulinDeliveryLogEvent]() handleDoseEvents(doses: doses, decisions: decisions, fetchedDate: fetchedDate, events: &events) @@ -190,7 +180,7 @@ class InsulinDeliveryLogViewModel { .init( insulinDeliveryState: statusState, insulinDeliveryStateUpdatedDate: fetchedDate, - currentBasalRate: currentBasalRate, + currentBasalRate: fetchCurrentBasal() ?? DatedQuantity(date: TestingDate.currentTestingDate(), quantity: LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: 0)), lastAutoBolus: lastAutoBolus, totalInsulinDelivered: totalInsulinDelivered, events: events @@ -209,6 +199,8 @@ class InsulinDeliveryLogViewModel { if insulinSuspended { return .error(status: .suspended) + } else if fetchCurrentBasal() == nil { + return .error(status: .noDelivery) } else if automationEnabled { let basalStatus: InsulinDeliveryOverview.State.AutomatedBasalStatus switch automatedTreatmentState { From 422b34e8f9c7e71fc087f75742c4d3dfe897b73b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 19 Nov 2025 13:29:03 -0600 Subject: [PATCH 326/421] LOOP-5439 - High Insulin Needs Preset Mitigation Bug Fixes (#868) * Fix text layout, and do not show mitigation if preset overrides correction range schedule and is above mitigation limit * Fix display bug during preset with overridden correction range --- Loop/Managers/LoopDataManager.swift | 2 +- Loop/Managers/TemporaryPresetsManager.swift | 2 +- .../Presets/Components/CorrectionRangePreview.swift | 9 ++++++++- .../Views/Presets/Components/PresetDetentView.swift | 1 + Loop/Views/Presets/EditPresetView.swift | 2 +- Loop/Views/Presets/ExistingPresetRangeEdit.swift | 13 ++++++++++--- Loop/Views/Presets/NewPresetRangeEdit.swift | 9 ++++++++- 7 files changed, 30 insertions(+), 8 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 09b14ac336..3600e4534a 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -457,7 +457,7 @@ final class LoopDataManager: ObservableObject { ] if activeOverride.veryHighInsulinNeeds { - suspendThreshold = max(TemporaryScheduleOverride.highInsulinNeedsMitigationCorrrectionRangeLimit, suspendThreshold) + suspendThreshold = max(TemporaryScheduleOverride.highInsulinNeedsMitigationCorrectionRangeLimit, suspendThreshold) } } else { diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 0e18cb4707..64795dfc7c 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -265,7 +265,7 @@ class TemporaryPresetsManager { let scheduledRange = schedule.quantityRange(at: now) - if let override = activeOverride, override.veryHighInsulinNeeds { + if let override = activeOverride { return override.effectiveCorrectionRangeDuring(scheduledRange: scheduledRange) } diff --git a/Loop/Views/Presets/Components/CorrectionRangePreview.swift b/Loop/Views/Presets/Components/CorrectionRangePreview.swift index d327af310b..89ddb89120 100644 --- a/Loop/Views/Presets/Components/CorrectionRangePreview.swift +++ b/Loop/Views/Presets/Components/CorrectionRangePreview.swift @@ -75,6 +75,13 @@ public struct CorrectionRangePreview: View { return thresholds } + var requiresHighInsulinNeedsMitigation: Bool { + if veryHighInsulinNeeds, let range { + return range.lowerBound < TemporaryScheduleOverride.highInsulinNeedsMitigationCorrectionRangeLimit + } + return veryHighInsulinNeeds + } + var highInsulinNeedsWarningText: String { String(format: NSLocalizedString("%1$@ will set your correction range to 110 mg/dL or higher when this preset is enabled.", comment: "The format string for the high insulin needs preset warning text on the preset preview screen. (1: app name)"), appName) } @@ -88,7 +95,7 @@ public struct CorrectionRangePreview: View { Text(SafetyClassification.captionForCrossedThresholds(crossedThresholds, isRange: true)) .accessibilityIdentifier("text_CorrectionRangeWarning"); } - } else if veryHighInsulinNeeds { + } else if requiresHighInsulinNeedsMitigation { WarningPanel { Text(highInsulinNeedsWarningText) } diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index d1ff711cc4..6eef966c38 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -172,6 +172,7 @@ struct PresetDetentView: View { Text(highInsulinNeedsWarningText) .font(.subheadline) .fontWeight(.semibold) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index c373077198..7cb1578bec 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -213,7 +213,7 @@ struct EditPresetView: View { allowsScheduledRange: preset.canAdjustSensitivity, isPreMeal: preset.isPreMeal, presetAdjustsInsulinNeeds: preset.insulinNeedsScaleFactor != 1, - requiresHighInsulinNeedsMitigation: preset.veryHighInsulinNeeds + veryHighInsulinNeeds: preset.veryHighInsulinNeeds ) } } diff --git a/Loop/Views/Presets/ExistingPresetRangeEdit.swift b/Loop/Views/Presets/ExistingPresetRangeEdit.swift index 818d193d73..3675191312 100644 --- a/Loop/Views/Presets/ExistingPresetRangeEdit.swift +++ b/Loop/Views/Presets/ExistingPresetRangeEdit.swift @@ -22,7 +22,7 @@ struct ExistingPresetRangeEdit: View { private var allowsScheduledRange: Bool private var isPreMeal: Bool = false private var presetAdjustsInsulinNeeds: Bool - private var requiresHighInsulinNeedsMitigation: Bool + private var veryHighInsulinNeeds: Bool init( range: Binding?>, @@ -31,7 +31,7 @@ struct ExistingPresetRangeEdit: View { allowsScheduledRange: Bool = true, isPreMeal: Bool = false, presetAdjustsInsulinNeeds: Bool, - requiresHighInsulinNeedsMitigation: Bool + veryHighInsulinNeeds: Bool ) { self._range = range self.editedRange = range.wrappedValue @@ -40,7 +40,14 @@ struct ExistingPresetRangeEdit: View { self.allowsScheduledRange = allowsScheduledRange self.isPreMeal = isPreMeal self.presetAdjustsInsulinNeeds = presetAdjustsInsulinNeeds - self.requiresHighInsulinNeedsMitigation = requiresHighInsulinNeedsMitigation + self.veryHighInsulinNeeds = veryHighInsulinNeeds + } + + var requiresHighInsulinNeedsMitigation: Bool { + if veryHighInsulinNeeds, let editedRange { + return editedRange.lowerBound < TemporaryScheduleOverride.highInsulinNeedsMitigationCorrectionRangeLimit + } + return veryHighInsulinNeeds } var highInsulinNeedsWarningText: String { diff --git a/Loop/Views/Presets/NewPresetRangeEdit.swift b/Loop/Views/Presets/NewPresetRangeEdit.swift index 9949853507..2ec5ff9929 100644 --- a/Loop/Views/Presets/NewPresetRangeEdit.swift +++ b/Loop/Views/Presets/NewPresetRangeEdit.swift @@ -32,6 +32,13 @@ struct NewPresetRangeEdit: View { self.onCancel = onCancel } + var requiresHighInsulinNeedsMitigation: Bool { + if preset.veryHighInsulinNeeds, let editedRange { + return editedRange.lowerBound < TemporaryScheduleOverride.highInsulinNeedsMitigationCorrectionRangeLimit + } + return preset.veryHighInsulinNeeds + } + var highInsulinNeedsWarningText: String { String(format: NSLocalizedString("For presets with insulin needs of 170%% or greater, %1$@ will set your correction range to 110 mg/dL or higher when this is preset enabled.", comment: "The format string for the high insulin needs preset warning text. (1: app name)"), appName) } @@ -53,7 +60,7 @@ struct NewPresetRangeEdit: View { NoticeView( title: Text("Set an Adjusted Correction Range"), caption: Text("With overall insulin needs at 100%, an adjusted correction range is required.")) - } else if preset.veryHighInsulinNeeds { + } else if requiresHighInsulinNeedsMitigation { WarningView( title: Text("Correction range adjustment when preset is enabled"), caption: Text(highInsulinNeedsWarningText)) From d54a4a89961a0635c42f0fd0d9b8ca247cb83105 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 19 Nov 2025 13:48:30 -0600 Subject: [PATCH 327/421] Update StoredDataAlgorithmInput for AlgorithmInput protocol changes (#869) --- Loop/Models/StoredDataAlgorithmInput.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Loop/Models/StoredDataAlgorithmInput.swift b/Loop/Models/StoredDataAlgorithmInput.swift index b5273bf2c6..ba3c169d88 100644 --- a/Loop/Models/StoredDataAlgorithmInput.swift +++ b/Loop/Models/StoredDataAlgorithmInput.swift @@ -52,4 +52,8 @@ struct StoredDataAlgorithmInput: AlgorithmInput { var automaticBolusApplicationFactor: Double? let useMidAbsorptionISF: Bool = true + + var maxActiveInsulinMultiplier: Double? = nil + + var gradualTransitionsThreshold: Double? = nil } From 0212f30b10a3608eeec78125e99827f6c5ba113b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 19 Nov 2025 14:32:28 -0600 Subject: [PATCH 328/421] Only using piecewise linear carb model in Loop now (#870) --- Common/FeatureFlags.swift | 9 --------- Loop/Managers/LoopAppManager.swift | 4 +--- Loop/Managers/LoopDataManager+CarbAbsorption.swift | 4 +--- Loop/Managers/LoopDataManager.swift | 4 +--- 4 files changed, 3 insertions(+), 18 deletions(-) diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index dee085d09c..6484a513a9 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -22,7 +22,6 @@ struct FeatureFlagConfiguration: Decodable { let manualDoseEntryEnabled: Bool let insulinDeliveryReservoirViewEnabled: Bool let mockTherapySettingsEnabled: Bool - let nonlinearCarbModelEnabled: Bool let observeHealthKitCarbSamplesFromOtherApps: Bool let observeHealthKitDoseSamplesFromOtherApps: Bool let observeHealthKitGlucoseSamplesFromOtherApps: Bool @@ -114,13 +113,6 @@ struct FeatureFlagConfiguration: Decodable { self.mockTherapySettingsEnabled = false #endif - // Swift compiler config is inverse, since the default state is enabled. - #if NONLINEAR_CARB_MODEL_DISABLED - self.nonlinearCarbModelEnabled = false - #else - self.nonlinearCarbModelEnabled = true - #endif - #if OBSERVE_HEALTH_KIT_CARB_SAMPLES_FROM_OTHER_APPS_ENABLED self.observeHealthKitCarbSamplesFromOtherApps = true #else @@ -238,7 +230,6 @@ extension FeatureFlagConfiguration : CustomDebugStringConvertible { "* afrezzaInsulinModelEnabled: \(afrezzaInsulinModelEnabled)", "* includeServicesInSettingsEnabled: \(includeServicesInSettingsEnabled)", "* mockTherapySettingsEnabled: \(mockTherapySettingsEnabled)", - "* nonlinearCarbModelEnabled: \(nonlinearCarbModelEnabled)", "* observeHealthKitCarbSamplesFromOtherApps: \(observeHealthKitCarbSamplesFromOtherApps)", "* observeHealthKitDoseSamplesFromOtherApps: \(observeHealthKitDoseSamplesFromOtherApps)", "* observeHealthKitGlucoseSamplesFromOtherApps: \(observeHealthKitGlucoseSamplesFromOtherApps)", diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 35d51cd082..b137e6f44c 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -303,8 +303,6 @@ class LoopAppManager: NSObject { } } - let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear - crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) loopDataManager = LoopDataManager( @@ -318,7 +316,7 @@ class LoopAppManager: NSObject { dosingDecisionStore: dosingDecisionStore, trustedTimeOffset: { self.trustedTimeChecker.detectedSystemTimeOffset }, analyticsServicesManager: analyticsServicesManager, - carbAbsorptionModel: carbModel, + carbAbsorptionModel: .piecewiseLinear, dosingStrategySelectionEnabled: FeatureFlags.dosingStrategySelectionEnabled ) diff --git a/Loop/Managers/LoopDataManager+CarbAbsorption.swift b/Loop/Managers/LoopDataManager+CarbAbsorption.swift index 24b92f7886..643bddc9ed 100644 --- a/Loop/Managers/LoopDataManager+CarbAbsorption.swift +++ b/Loop/Managers/LoopDataManager+CarbAbsorption.swift @@ -67,8 +67,6 @@ extension LoopDataManager { } 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) @@ -92,7 +90,7 @@ extension LoopDataManager { to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), carbRatios: carbRatioWithOverrides, insulinSensitivities: sensitivityWithOverrides, - absorptionModel: carbModel.model + absorptionModel: CarbAbsorptionModel.piecewiseLinear.model ) return CarbAbsorptionReview( diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 3600e4534a..9b53893e1d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1410,8 +1410,6 @@ extension LoopDataManager: FavoriteFoodInsightsViewModelDelegate { } 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.map({ $0.simpleDose(with: insulinModel(for: $0.insulinType)) }).annotated(with: basalWithOverrides) @@ -1435,7 +1433,7 @@ extension LoopDataManager: FavoriteFoodInsightsViewModelDelegate { to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), carbRatios: carbRatioWithOverrides, insulinSensitivities: sensitivityWithOverrides, - absorptionModel: carbModel.model + absorptionModel: CarbAbsorptionModel.piecewiseLinear.model ) let carbAbsorptionReview = CarbAbsorptionReview( From 63b3b036e0a5e6fd7db9858c998b69f0c3812a0c Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 21 Nov 2025 15:10:00 -0400 Subject: [PATCH 329/421] [LOOP-5542] initiating a bolus needs to state auto or not (#871) * initiating a bolus needs to state auto or not * ignore the initiating bolus state --- Loop/View Controllers/StatusTableViewController.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index b5ed1fce26..f811ff72e2 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -735,9 +735,7 @@ final class StatusTableViewController: LoopChartsTableViewController { private func determineStatusRowMode() -> StatusRowMode { let statusRowMode: StatusRowMode - if case .initiating = bolusState { - statusRowMode = .enactingBolus - } else if case .canceling = bolusState { + if case .canceling = bolusState { statusRowMode = .cancelingBolus } else if let canceledDose { statusRowMode = .canceledBolus(dose: canceledDose) From ccb7fcd5c6fa33ac393a51d2c012478a7ec37822 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 24 Nov 2025 17:02:20 -0400 Subject: [PATCH 330/421] [LOOP-5421] check that the delivered bolus is different from the previous delivered bolus (#872) --- .../StatusTableViewController.swift | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index f811ff72e2..bda7e8993b 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -306,16 +306,20 @@ final class StatusTableViewController: LoopChartsTableViewController { didSet { if oldValue != bolusState { switch bolusState { - case .inProgress(let dose): - guard case .inProgress = oldValue else { - guard case .canceling = oldValue else { - // Bolus starting - if dose.automatic != true { - bolusProgressReporter = deviceManager.pumpManager?.createBolusProgressReporter(reportingOn: DispatchQueue.main) - } - break - } + case .inProgress(let doseNew): + switch oldValue { + case .inProgress(let doseOld): + guard doseNew.syncIdentifier != doseOld.syncIdentifier, + doseNew.automatic != true + else { break } + // Different manual bolus is being delivered + bolusProgressReporter = deviceManager.pumpManager?.createBolusProgressReporter(reportingOn: DispatchQueue.main) + case .canceling: break + default: + // Bolus starting + guard doseNew.automatic != true else { break } + bolusProgressReporter = deviceManager.pumpManager?.createBolusProgressReporter(reportingOn: DispatchQueue.main) } default: break From ade7d629a6b321aaa553705e9a512c4c6b89c308 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 24 Nov 2025 15:09:38 -0600 Subject: [PATCH 331/421] Fix issues setting start date for scheduled presets (#873) --- Loop/Views/Presets/EditPresetView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 7cb1578bec..6ad6d2b01d 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -353,6 +353,7 @@ struct EditPresetView: View { selection: Binding(get: { preset.nextScheduledStartAfter(Date()) ?? Date() }, set: { newValue in + preset.repeatOptions = .none preset.scheduleStartDate = newValue }), in: Date().addingTimeInterval(.minutes(1))..., From aea3372a0395fd6051c6205b1e3a92757e697b9b Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 26 Nov 2025 06:28:09 -0400 Subject: [PATCH 332/421] [LOOP-5577] checking before deallocating (#874) --- Loop/Managers/ExtensionDataManager.swift | 35 ++++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift index 63c84df095..32bc0b90c4 100644 --- a/Loop/Managers/ExtensionDataManager.swift +++ b/Loop/Managers/ExtensionDataManager.swift @@ -18,6 +18,9 @@ final class ExtensionDataManager { unowned let settingsManager: SettingsManager unowned let temporaryPresetsManager: TemporaryPresetsManager + private var dataUpdatedObserver: NSObjectProtocol? + private var pumpManagerChangedObserver: NSObjectProtocol? + init(deviceDataManager: DeviceDataManager, loopDataManager: LoopDataManager, settingsManager: SettingsManager, @@ -28,12 +31,30 @@ final class ExtensionDataManager { self.settingsManager = settingsManager self.temporaryPresetsManager = temporaryPresetsManager - NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .LoopDataUpdated, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .PumpManagerChanged, object: nil) - + dataUpdatedObserver = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in + self?.update() + } + } + + pumpManagerChangedObserver = NotificationCenter.default.addObserver(forName: .PumpManagerChanged, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in + self?.update() + } + } + // Wait until LoopDataManager has had a chance to initialize itself - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.update() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.update() + } + } + + deinit { + if let obs = dataUpdatedObserver { + NotificationCenter.default.removeObserver(obs) + } + if let obs = pumpManagerChangedObserver { + NotificationCenter.default.removeObserver(obs) } } @@ -62,10 +83,6 @@ final class ExtensionDataManager { static var lastLoopCompleted: Date? { context?.lastLoopCompleted } - - @objc private func notificationReceived(_ notification: Notification) { - update() - } private func update() { Task { @MainActor in From 620daea25a013bb2022b5fdd87e1036252a28492 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 28 Nov 2025 13:35:26 -0400 Subject: [PATCH 333/421] [LOOP-5496-5600] disable pre-meal preset when in open loop (#875) * disable pre-meal preset when in open loop * always display timestamp --- Loop/Views/LoopStatusModalView.swift | 27 ++++++++++++++----- .../Presets/Components/PresetDetentView.swift | 2 +- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 28a9ace29f..15ff1b34b9 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -78,8 +78,11 @@ struct LoopStatusModalView: View { Text("Last loop completed") Text("\(Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90")) \(lastLoopCompletedString)") .foregroundStyle(freshnessColor) - if viewModel.includeTimeStamp { - Text(viewModel.formattedLastLoopCompleted) + if viewModel.includeDateTimeStamp { + Text(viewModel.formattedLastLoopCompletedDateTime) + .foregroundStyle(freshnessColor) + } else { + Text(viewModel.formattedLastLoopCompletedTime) .foregroundStyle(freshnessColor) } } @@ -131,7 +134,7 @@ struct LoopStatusModalView: View { } struct LoopStatusModalViewModel { - private var timeDateFormatter: DateFormatter = { + private var dateTimeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short @@ -139,6 +142,14 @@ struct LoopStatusModalViewModel { return formatter }() + private var timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + formatter.locale = Locale.current + return formatter + }() + var lastLoopCompleted: Date? var freshness: LoopCompletionFreshness { LoopCompletionFreshness(age: ago) @@ -147,14 +158,18 @@ struct LoopStatusModalViewModel { guard let lastLoopCompleted else { return nil } return abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) } - var includeTimeStamp: Bool { // only include if last loop was before today + var includeDateTimeStamp: Bool { // only include if last loop was before today guard let lastLoopCompleted else { return false } let startOfToday = Calendar.current.startOfDay(for: Date()) return lastLoopCompleted < startOfToday } - var formattedLastLoopCompleted: String { + var formattedLastLoopCompletedDateTime: String { + guard let lastLoopCompleted else { return "Unknown" } + return String(format: NSLocalizedString("at %1$@", comment: "when adding the date and time. (1: the formatted date and time)"), dateTimeFormatter.string(from: lastLoopCompleted)) + } + var formattedLastLoopCompletedTime: String { guard let lastLoopCompleted else { return "Unknown" } - return String(format: NSLocalizedString("at %1$@", comment: "when adding a timestamp. (1: the formatted timestamp)"), timeDateFormatter.string(from: lastLoopCompleted)) + return String(format: NSLocalizedString("at %1$@", comment: "when adding a timestamp. (1: the formatted timestamp)"), timeFormatter.string(from: lastLoopCompleted)) } var loopIconClosed: Bool diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 6eef966c38..a5bdc8ef38 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -80,7 +80,7 @@ struct PresetDetentView: View { dismiss() } .buttonStyle(ActionButtonStyle()) - .disabled(temporaryPresetsManager.activeOverride != nil && preset.id != temporaryPresetsManager.activeOverride?.presetId) + .disabled((temporaryPresetsManager.activeOverride != nil && preset.id != temporaryPresetsManager.activeOverride?.presetId) || (preset.isPreMeal && settingsManager.dosingEnabled == false)) .accessibilityIdentifier("button_startPreset") case .end: Button("End Preset") { From 0193c55748ad42d6f49656bdcb9bd3d526052530 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 5 Dec 2025 18:35:50 -0400 Subject: [PATCH 334/421] [LOOP-5496-5625] display programmed details & status modal update (#877) * display programmed details * adding LOOP-5469 UI update --- .../Insulin Delivery Log/InsulinDeliveryLogViewModel.swift | 5 ++++- Loop/Views/LoopStatusModalView.swift | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index 1a612d2b3e..1eebabca59 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -485,7 +485,10 @@ class InsulinDeliveryLogViewModel { type: .pumpEvent( .bolus( .correction(recommendedAmount: nil), - programmedAmount: nil, + programmedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.programmedUnits + ), deliveryAmount: LoopQuantity( unit: .internationalUnit, doubleValue: dose.deliveredUnits ?? dose.programmedUnits diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 15ff1b34b9..73cac4f446 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -81,7 +81,7 @@ struct LoopStatusModalView: View { if viewModel.includeDateTimeStamp { Text(viewModel.formattedLastLoopCompletedDateTime) .foregroundStyle(freshnessColor) - } else { + } else if viewModel.freshness != .fresh { Text(viewModel.formattedLastLoopCompletedTime) .foregroundStyle(freshnessColor) } From 6cb0a898041e630609252abe8b732cd8d5c9957d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 9 Dec 2025 11:44:14 -0600 Subject: [PATCH 335/421] Fix truncated units on watch display of presets (#876) --- WatchApp Extension/Views/PresetDetailView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/WatchApp Extension/Views/PresetDetailView.swift b/WatchApp Extension/Views/PresetDetailView.swift index 0a3354f6cd..879a40bbb9 100644 --- a/WatchApp Extension/Views/PresetDetailView.swift +++ b/WatchApp Extension/Views/PresetDetailView.swift @@ -11,6 +11,8 @@ import LoopKit import LoopCore struct PresetDetailView: View { + @Environment(\.sizeClass) private var sizeClass + @Environment(\.glucoseDisplayUnit) private var glucoseDisplayUnit let preset: SelectablePreset @@ -48,7 +50,7 @@ struct PresetDetailView: View { var text = Text(percent).bold() if let correctionRange = preset.correctionRange { - text = text + Text(" • ") + text = text + Text(sizeClass.isLarge ? " • " : "\n") text = text + (Text(glucoseFormatter.string(from: correctionRange.lowerBound, includeUnit: false)!) + Text("-") + Text(glucoseFormatter.string(from: correctionRange.upperBound, includeUnit: false)!)).bold() @@ -63,6 +65,8 @@ struct PresetDetailView: View { presetTitle presetDuration descriptionText + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) .padding(.top, 8) .padding(.bottom, 10) } From 89cdc5c45848869d433e8e5573762f69a3fd9510 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 10 Dec 2025 14:26:41 -0600 Subject: [PATCH 336/421] LOOP-5617 Fix repeat day reset on start day change (#878) * Fix repeat day reset on start day change * Updates from PR review --- Loop/Views/Presets/EditPresetView.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 6ad6d2b01d..83c880354d 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -217,7 +217,12 @@ struct EditPresetView: View { ) } } - .onChange(of: preset) { + .onChange(of: preset.scheduleStartDate, { _, newValue in + if newValue != nil { + assignRepeatDays() + } + }) + .onChange(of: preset) { _, _ in do { try onSave(preset) } catch { @@ -432,6 +437,13 @@ struct EditPresetView: View { return .allCases[Calendar.current.component(.weekday, from: startDate) - 1] } + func assignRepeatDays() { + guard let requiredRepeatOption else { + return + } + preset.repeatOptions = requiredRepeatOption + } + private var dismissButton: some View { Button("Done") { dismiss() From e385dc3529c5b84a758f312547c12418dc9505f8 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 16 Dec 2025 17:18:09 -0400 Subject: [PATCH 337/421] [LOOP-5639-5640] preset training fixes (#879) * tapping + can trigger preset training * corrected copy --- Loop/Views/Presets/PresetsView.swift | 29 +++++++++++++++++-- .../PresetsTrainingContent.swift | 21 ++++++++++---- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index e96d954fd8..67fd43699a 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -64,6 +64,7 @@ struct PresetsView: View { @State private var editMode: EditMode = .inactive @State private var showingMenu: Bool = false @State private var presentCreateView: Bool = false + @State private var presentTrainingNeededAlert: Bool = false @State private var activeSheet: ActiveSheet? @State private var navigationPath = NavigationPath() @@ -123,11 +124,14 @@ struct PresetsView: View { } Button(action: { - presentCreateView = true; + if trainingCompletion.isComplete { + presentCreateView = true + } else { + presentTrainingNeededAlert = true + } }) { Image(systemName: "plus") } - .disabled(!trainingCompletion.isComplete) } .padding(.horizontal, 10) @@ -143,7 +147,6 @@ struct PresetsView: View { PresetCard( preset, guardrail: settingsManager.correctionRangeGuardrailForPreset(preset) - ) .cornerRadius(12) .onTapGesture { @@ -253,6 +256,26 @@ struct PresetsView: View { .sheet(isPresented: $presentCreateView) { CreatePresetView() } + .alert(isPresented: $presentTrainingNeededAlert) { + trainingNeededAlert + } + } + + private var trainingNeededAlert: SwiftUI.Alert { + Alert(title: Text("Extra Training Needed", comment: "Preset training needed alert title"), + message: Text("Complete the training to create a new preset.", comment: "Preset training needed alert message"), + primaryButton: startNeededTrainingButton, + secondaryButton: closeButton) + } + + private var startNeededTrainingButton: SwiftUI.Alert.Button { + .cancel(Text("Close", comment: "Preset training needed alert cancel button")) + } + + private var closeButton: SwiftUI.Alert.Button { + .default(Text("Start Training", comment: "CPreset training needed alert start training button")) { + activeSheet = .training() + } } private var sortMenu: some View { diff --git a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift index 9f8dccde2a..8dfc433812 100644 --- a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift +++ b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift @@ -307,10 +307,10 @@ extension PresetsTraining.Step: PresetsTrainingContent { Text("Physical stress, like illness, can cause glucose to rise.") InsetContent(alignment: .leading) { - Text("**Example:** Paloma Porpoise notices her glucose is higher than normal and wants to create a preset to manage it while she's sick.") + Text("**Example:** Paloma Porpoise sees her glucose is running higher than usual. She decides to create a preset to help manage it while she's sick.") } - Text("Let's look at the settings that will impact Paloma's insulin delivery.") + Text("Let's look at the settings that can change how much insulin Paloma get.") case .overallInsulin: Text("Paloma wants \(appName) to know she needs more insulin than usual.") @@ -378,7 +378,7 @@ extension PresetsTraining.Step: PresetsTrainingContent { .frame(maxWidth: .infinity) } - Text("To be safe, \(appName) will remind her at 8 hours that the preset is still running.") + Text("To be safe, \(appName) will remind her after 24 hours that the preset is still running.") if let image = Image("PresetsTrainingIllnessDuration2") { image @@ -484,7 +484,7 @@ extension PresetsTraining.Step: PresetsTrainingContent { component: .correctionRange(110...120) ) - Text("Omar sets his correction range a little higher, to \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 110), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), includeUnit: false)) \(displayGlucosePreference.unit.localizedShortUnitString). This tells \(appName) to step in sooner.") + Text("Omar sets his correction range a little higher, to \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 160), includeUnit: false)) \(displayGlucosePreference.unit.localizedShortUnitString). This tells \(appName) to step in sooner.") if let image = Image("PresetsTrainingDailyActivityCorrectionRange") { image @@ -599,7 +599,18 @@ extension PresetsTraining.Step: PresetsTrainingContent { HStack(alignment: .firstTextBaseline, spacing: 16) { Bullet(color: .secondary) - Text("Walking") + VStack(alignment: .leading, spacing: 4) { + Text("Walking") + + HStack(alignment: .center, spacing: 2) { + Text("\(Image(systemName: "lightbulb.max"))") + + Text(" **Tip** Use your \(Image(systemName: "figure.walk")) **Walking** preset") + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) + } } .frame(maxWidth: .infinity, alignment: .leading) From a5629bdeb0922ffbc0f31de05dd682fb2f2ed07c Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 17 Dec 2025 15:06:38 -0800 Subject: [PATCH 338/421] [LOOP-5613] Update Watch with Settings and Automation Changes (#881) * [LOOP-5613] Update Watch with Settings and Automation Changes * [LOOP-5613] Update Watch with Settings and Automation Changes --- Loop.xcodeproj/project.pbxproj | 2 + Loop/Managers/WatchDataManager.swift | 1 + LoopCore/Models/WatchContext.swift | 8 +- WatchApp Extension/Views/ChartPageView.swift | 75 ++++++++++++++++++- WatchApp Extension/Views/LoopCircleView.swift | 21 ++++-- WatchApp Extension/Views/LoopHeader.swift | 2 +- 6 files changed, 96 insertions(+), 13 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 4c27ade0f8..08ce121575 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -236,6 +236,7 @@ 84475E0E2E5F00B900FC5E7C /* TimelineSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475E0D2E5F00B900FC5E7C /* TimelineSteps.swift */; }; 84475E102E5F870800FC5E7C /* PresetsTrainingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475E0F2E5F870800FC5E7C /* PresetsTrainingCard.swift */; }; 847F23432E4543140035C864 /* ActivePresetBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847F23422E4543140035C864 /* ActivePresetBanner.swift */; }; + 849466D02EF1EAD300A90718 /* LocalizablePlural.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */; }; 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; 849E06232E5E41BA00A71614 /* PresetsTrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849E06222E5E41BA00A71614 /* PresetsTrainingView.swift */; }; 84A7B5502D2D972C00B6D202 /* Image+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */; }; @@ -3325,6 +3326,7 @@ C1275E1F2E822CEE0013B99D /* DerivedAssets.xcassets in Resources */, 63F5E17C297DDF3900A62D4B /* ckcomplication.strings in Resources */, A966152A23EA5A37005D8B29 /* DefaultAssets.xcassets in Resources */, + 849466D02EF1EAD300A90718 /* LocalizablePlural.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 4c02fe577b..fd8c4aea14 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -300,6 +300,7 @@ final class WatchDataManager: NSObject { let settings = self.settingsManager.loopSettings context.isClosedLoop = settings.dosingEnabled + context.deviceInoperable = deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true || deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true || deviceManager.hasBluetoothIssue context.potentialCarbEntry = potentialCarbEntry diff --git a/LoopCore/Models/WatchContext.swift b/LoopCore/Models/WatchContext.swift index b1073ccebd..5277b1de14 100644 --- a/LoopCore/Models/WatchContext.swift +++ b/LoopCore/Models/WatchContext.swift @@ -20,6 +20,7 @@ public enum InsulinDeliveryWatchState: Int, Equatable { case noDelivery } +@Observable public final class WatchContext: RawRepresentable { public typealias RawValue = [String: Any] @@ -61,6 +62,7 @@ public final class WatchContext: RawRepresentable { public var cgmManagerState: CGMManager.RawStateValue? public var isClosedLoop: Bool? + public var deviceInoperable: Bool? public init( creationDate: Date = Date(), @@ -87,7 +89,8 @@ public final class WatchContext: RawRepresentable { cgmManagerState: CGMManager.RawStateValue? = nil, insulinDeliveryState: InsulinDeliveryWatchState? = nil, lastManualBolus: LastManualBolus? = nil, - isClosedLoop: Bool? = nil + isClosedLoop: Bool? = nil, + deviceInoperable: Bool? = nil ) { self.creationDate = creationDate self.displayGlucoseUnit = displayGlucoseUnit @@ -114,6 +117,7 @@ public final class WatchContext: RawRepresentable { self.insulinDeliveryState = insulinDeliveryState self.lastManualBolus = lastManualBolus self.isClosedLoop = isClosedLoop + self.deviceInoperable = deviceInoperable } public required init?(rawValue: RawValue) { @@ -123,6 +127,7 @@ public final class WatchContext: RawRepresentable { self.creationDate = creationDate isClosedLoop = rawValue["cl"] as? Bool + deviceInoperable = rawValue["di"] as? Bool if let unitString = rawValue["gu"] as? String { displayGlucoseUnit = LoopUnit(from: unitString) @@ -185,6 +190,7 @@ public final class WatchContext: RawRepresentable { raw["bad"] = lastNetTempBasalDate raw["bp"] = batteryPercentage raw["cl"] = isClosedLoop + raw["di"] = deviceInoperable raw["cgmManagerState"] = cgmManagerState diff --git a/WatchApp Extension/Views/ChartPageView.swift b/WatchApp Extension/Views/ChartPageView.swift index a2ed88c942..4b9bdd4d6b 100644 --- a/WatchApp Extension/Views/ChartPageView.swift +++ b/WatchApp Extension/Views/ChartPageView.swift @@ -18,9 +18,11 @@ struct ChartPageView: View { @Environment(LoopDataManager.self) var loopManager @State private var isShowingCarbList: Bool = false + + @State private var lastSyncString: String? @ScaledMetric private var iconSize: Double = 26 - + var presetActive: Bool { return loopManager.watchInfo.scheduleOverride?.isActive() == true } @@ -57,7 +59,9 @@ struct ChartPageView: View { } ) } - + + let lastSyncUpdateTimer = Timer.publish(every: 10, on: .main, in: .common).autoconnect() + var activeInsulin: String? { guard let activeContext = loopManager.activeContext, let activeInsulin = activeContext.activeInsulin @@ -151,13 +155,19 @@ struct ChartPageView: View { return insulinFormatter.string(from: reservoirVolume) } - var body: some View { ScrollView(.vertical) { LoopHeader() chartView VStack(spacing: 8) { + if let lastSyncString { + LabelValueRow("Last Loop") { + Text(Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90")) + + Text(" " + lastSyncString) + } + Divider() + } LabelValueRow("Active Insulin") { Text(activeInsulin ?? "-") } @@ -191,10 +201,17 @@ struct ChartPageView: View { .environment(\.glucoseDisplayUnit, loopManager.displayGlucoseUnit) .onAppear() { updateGlucoseChart() + updateLastSyncString() } .onChange(of: loopManager.activeContext?.predictedGlucose) { oldValue, newValue in updateGlucoseChart() } + .onReceive(lastSyncUpdateTimer) { _ in + updateLastSyncString() + } + .onChange(of: loopManager.activeContext?.isClosedLoop) { _, _ in + updateLastSyncString() + } .sheet(isPresented: $isShowingCarbList) { CarbList() } @@ -207,4 +224,56 @@ struct ChartPageView: View { loopManager.glucoseChartScene.setNeedsUpdate() } } + + private func updateLastSyncString() { + guard loopManager.activeContext?.isClosedLoop == true, let date = loopManager.activeContext?.loopLastRunDate else { + lastSyncString = nil + return + } + + let ago = min(abs(min(0, date.timeIntervalSinceNow)), TimeInterval.days(7)) + + guard let timeString = ago.truncatedTimeAgoString else { + lastSyncString = nil + return + } + + if ago > .hours(1) { + lastSyncString = String(format: NSLocalizedString(" >%@ ago", comment: "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components"), timeString) + } else { + lastSyncString = String(format: NSLocalizedString(" %@ ago", comment: "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components"), timeString) + } + } +} + +extension TimeInterval { + /// Formats a time interval as a truncated "time ago" string (e.g., "1 hr", "2 mins") + var truncatedTimeAgoString: String? { + let calendar = Calendar.current + let now = Date() + let past = now.addingTimeInterval(-self) + + let components = calendar.dateComponents([.day, .hour, .minute], from: past, to: now) + if let days = components.day, days > 0 { + return String.localizedStringWithFormat( + NSLocalizedString("%d day", tableName: "LocalizablePlural", bundle: .main, value: "%d day", comment: "Singular/plural day count"), + days + ) + } else if let hours = components.hour, hours > 0 { + return String.localizedStringWithFormat( + NSLocalizedString("%d hr", tableName: "LocalizablePlural", bundle: .main, value: "%d hr", comment: "Singular/plural hour count"), + hours + ) + } else if let minutes = components.minute { + return String.localizedStringWithFormat( + NSLocalizedString("%d min", tableName: "LocalizablePlural", bundle: .main, value: "%d min", comment: "Singular/plural minute count"), + minutes + ) + } else { + return nil + } + } } + + + diff --git a/WatchApp Extension/Views/LoopCircleView.swift b/WatchApp Extension/Views/LoopCircleView.swift index 8c7afdd773..a5b808caca 100644 --- a/WatchApp Extension/Views/LoopCircleView.swift +++ b/WatchApp Extension/Views/LoopCircleView.swift @@ -10,18 +10,21 @@ import SwiftUI import LoopKit public struct LoopCircleView: View { + @Environment(\.isEnabled) private var isEnabled - + private let animating: Bool private let closedLoop: Bool private let freshness: LoopCompletionFreshness - - public init(closedLoop: Bool, freshness: LoopCompletionFreshness, animating: Bool = false) { + private let deviceInoperable: Bool + + public init(closedLoop: Bool, freshness: LoopCompletionFreshness, animating: Bool = false, deviceInoperable: Bool = false) { self.closedLoop = closedLoop self.freshness = freshness self.animating = animating + self.deviceInoperable = deviceInoperable } - + private var reversingAnimation: Animation { if animating && closedLoop { return .easeInOut(duration: 1).repeatForever(autoreverses: true) @@ -29,7 +32,7 @@ public struct LoopCircleView: View { return .easeInOut(duration: 1) } } - + public var body: some View { GeometryReader { geometry in Circle() @@ -42,18 +45,20 @@ public struct LoopCircleView: View { .animation(reversingAnimation, value: UUID()) } } - + private var loopColor: Color { if !isEnabled { return .defaultWatchButtonGray + } else if deviceInoperable { + return .gray } else { switch freshness { case .fresh: return .fresh case .aging: - return .aging + return .gray case .stale: - return .stale + return .gray } } } diff --git a/WatchApp Extension/Views/LoopHeader.swift b/WatchApp Extension/Views/LoopHeader.swift index 3c0e1a85d7..be6a25219f 100644 --- a/WatchApp Extension/Views/LoopHeader.swift +++ b/WatchApp Extension/Views/LoopHeader.swift @@ -22,7 +22,7 @@ struct LoopHeader: View { if let activeContext = loopManager.activeContext, let unit = activeContext.displayGlucoseUnit { - LoopCircleView(closedLoop: activeContext.isClosedLoop ?? false, freshness: freshness) + LoopCircleView(closedLoop: activeContext.isClosedLoop ?? false, freshness: freshness, deviceInoperable: loopManager.activeContext?.deviceInoperable ?? true) .frame(width: 22, height: 22) .padding(.horizontal) From 13c2c53471fd050b21226a85ad08e599b82b1e85 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 18 Dec 2025 11:25:52 -0400 Subject: [PATCH 339/421] [LOOP-5561] add preset schedule footer (#880) --- Loop/Views/Presets/EditPresetView.swift | 19 +++++++++++++++++-- Loop/Views/Presets/NewCustomPreset.swift | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift index 83c880354d..6f97ac0103 100644 --- a/Loop/Views/Presets/EditPresetView.swift +++ b/Loop/Views/Presets/EditPresetView.swift @@ -44,6 +44,21 @@ struct EditPresetView: View { private var onSave: (SelectablePreset) throws -> Void private var onDelete: (SelectablePreset) throws -> Void + private var scheduleFooter: String? { + guard preset.repeatOptions != .none, + let timeString = preset.scheduleStartDate?.formatted(date: .omitted, time: .shortened) + else { return nil } + + return String( + format: NSLocalizedString( + "Repeats weekly on %1$@ at %2$@", + comment: "preset weekly repeat footer (1: repeat day(s)) (2: repeat time)" + ), + String(describing: preset.repeatOptions), + timeString + ) + } + private var activityPresetIsModified: Bool? { guard case let .activity(activityPreset) = preset else { return nil } @@ -314,7 +329,7 @@ struct EditPresetView: View { } private func schedulingCard(_ proxy: ScrollViewProxy) -> some View { - CardSection { + CardSection(content: { HStack { Text("Schedule") .font(.body) @@ -429,7 +444,7 @@ struct EditPresetView: View { .id("selectedDays") // Assign an ID for scrolling } } - } + }, footerText: scheduleFooter) } private var requiredRepeatOption: PresetScheduleRepeatOptions? { diff --git a/Loop/Views/Presets/NewCustomPreset.swift b/Loop/Views/Presets/NewCustomPreset.swift index c09b19a406..bb01da31f9 100644 --- a/Loop/Views/Presets/NewCustomPreset.swift +++ b/Loop/Views/Presets/NewCustomPreset.swift @@ -22,7 +22,7 @@ extension PresetScheduleRepeatOptions: @retroactive CustomStringConvertible { } // Handle multiple days - return NSLocalizedString("Multiple", comment: "Preset schedule repeat option multiple days") + return NSLocalizedString("multiple days", comment: "Preset schedule repeat option multiple days") } var veryShortDescription: String { From 50d5e8dea8d38d108b25988f39b0f01e516cd896 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 5 Jan 2026 12:37:10 -0800 Subject: [PATCH 340/421] [LOOP-5676] Fixed AlertStore.purge(before:) crash (#882) --- Loop/Managers/Alerts/AlertStore.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift index abfda250df..a87cab8ed0 100644 --- a/Loop/Managers/Alerts/AlertStore.swift +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -288,7 +288,10 @@ extension AlertStore { // Must be invoked within NSManagedObjectContext perform or performAndWait block private func purgeExpired() { - purge(before: expireDate) + managedObjectContext.perform { [weak self] in + guard let self else { return } + self.purge(before: self.expireDate) + } } func purge(before date: Date, completion: (Error?) -> Void) { From 0be4495d8d4019ea2e6600c7e3bb7c89f260101f Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 8 Jan 2026 05:27:20 -0400 Subject: [PATCH 341/421] [LOOP-5329] when a single use preset ends it cannot start again (#883) --- .../Views/Presets/Components/PresetDetentView.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index a5bdc8ef38..79142126a1 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -27,11 +27,14 @@ struct PresetDetentView: View { let preset: SelectablePreset let didTapEdit: () -> Void - var operation: Operation { + var operation: Operation? { if temporaryPresetsManager.activeOverride?.presetId == preset.id { return .end - } else { + } else if settingsManager.settings.overridePresets.contains(where: { $0.id == preset.id }) { return .start + } else { + // if the preset is not saved, it is single use and cannot start again + return nil } } @@ -65,6 +68,8 @@ struct PresetDetentView: View { Text(String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText)) } } + default: + EmptyView() } } .font(.subheadline) @@ -97,6 +102,8 @@ struct PresetDetentView: View { .buttonStyle(ActionButtonStyle(.tertiary)) .accessibilityIdentifier("button_adjustPresetDuration") } + default: + EmptyView() } Button("Close") { @@ -120,6 +127,8 @@ struct PresetDetentView: View { String(format: NSLocalizedString("%1$@ will set your correction range to 110 mg/dL or higher when this preset is enabled.", comment: "The format string for the high insulin needs preset warning text on the preset detent screen when starting a preset. (1: app name)"), appName) case .end: String(format: NSLocalizedString("%1$@ has set your correction range to 110 mg/dL or higher.", comment: "The format string for the high insulin needs preset warning text on the preset detent screen when stopping a preset. (1: app name)"), appName) + default: + "" } } From 868a8a721b9ecfcd484db04281243689706c3413 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 8 Jan 2026 05:33:26 -0400 Subject: [PATCH 342/421] [LOP-5558] round basal rate when displaying preset settings impact (#884) --- Loop/Managers/DeviceDataManager.swift | 4 ++++ Loop/Managers/LoopAppManager.swift | 2 +- Loop/Managers/LoopDataManager.swift | 1 + .../View Controllers/StatusTableViewController.swift | 2 +- Loop/View Models/SettingsViewModel.swift | 7 +++++-- Loop/Views/Presets/Components/PresetDetentView.swift | 12 +++++++++++- Loop/Views/Presets/PresetsView.swift | 4 +++- Loop/Views/SettingsView.swift | 2 +- Loop/Views/StatusTableView.swift | 2 +- 9 files changed, 28 insertions(+), 8 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index a37e5c3f12..84d92ad437 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1343,6 +1343,10 @@ extension DeviceDataManager: DeliveryDelegate { return pumpManager.roundToSupportedBasalRate(unitsPerHour: unitsPerHour) } + + func roundBasalRate(rate: LoopQuantity) -> LoopQuantity { + LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: roundBasalRate(unitsPerHour: rate.doubleValue(for: .internationalUnitsPerHour))) + } func roundBolusVolume(units: Double) -> Double { guard let pumpManager = pumpManager else { diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index b137e6f44c..b305d8089f 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -572,7 +572,7 @@ class LoopAppManager: NSObject { isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceDataManager, presetHistory: temporaryPresetsManager.presetHistory, - temporaryPresetsManager: temporaryPresetsManager + deliveryDelegate: deviceDataManager ) viewModel.favoriteFoodInsightsDelegate = loopDataManager diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 9b53893e1d..bf21c27b60 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -47,6 +47,7 @@ protocol DeliveryDelegate: AnyObject { func enact(bolus: Double?, tempBasal: TempBasalRecommendation?, decisionId: UUID?) async throws func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws func roundBasalRate(unitsPerHour: Double) -> Double + func roundBasalRate(rate: LoopQuantity) -> LoopQuantity func roundBolusVolume(units: Double) -> Double } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index bda7e8993b..7868147ec8 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1518,7 +1518,7 @@ final class StatusTableViewController: LoopChartsTableViewController { func presentPresets() { let hostingController = DismissibleHostingController( - rootView: PresetsView() + rootView: PresetsView(roundBasalRate: deviceManager.roundBasalRate) .onAppear { self.isShowingPresets = true } .onDisappear { self.isShowingPresets = false } .environmentObject(deviceManager.displayGlucosePreference) diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 1d23ab994e..34d8d554eb 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -67,6 +67,8 @@ class SettingsViewModel { let versionUpdateViewModel: VersionUpdateViewModel weak var delegate: SettingsViewModelDelegate? + + weak var deliveryDelegate: DeliveryDelegate? func didTapIssueReport() { delegate?.didTapIssueReport() @@ -155,7 +157,7 @@ class SettingsViewModel { isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, presetHistory: TemporaryScheduleOverrideHistory, - temporaryPresetsManager: TemporaryPresetsManager + deliveryDelegate: DeliveryDelegate? ) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter @@ -174,6 +176,7 @@ class SettingsViewModel { self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate self.presetHistory = presetHistory + self.deliveryDelegate = deliveryDelegate // This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject) lastLoopCompletion @@ -242,7 +245,7 @@ extension SettingsViewModel { isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, presetHistory: TemporaryScheduleOverrideHistory(), - temporaryPresetsManager: TemporaryPresetsManager(settingsProvider: FakeSettingsProvider()) + deliveryDelegate: nil ) } } diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 79142126a1..c05d10b451 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -10,6 +10,7 @@ import LoopKit import LoopKitUI import SwiftUI import LoopCore +import LoopAlgorithm struct PresetDetentView: View { @@ -25,6 +26,7 @@ struct PresetDetentView: View { @Environment(\.appName) private var appName let preset: SelectablePreset + let roundBasalRate: ((LoopQuantity) -> LoopQuantity)? let didTapEdit: () -> Void var operation: Operation? { @@ -118,7 +120,15 @@ struct PresetDetentView: View { @State var sheetContentHeight: Double = 0 var settingsImpact: TherapySettings.InsulinMultiplierImpact { - settingsManager.therapySettings.impact(for: preset.insulinNeedsScaleFactor) + var settingsImpact = settingsManager.therapySettings.impact(for: preset.insulinNeedsScaleFactor) + guard let basalRate = settingsImpact.basalRate, + let roundBasalRate + else { + return settingsImpact + } + + settingsImpact.basalRate = roundBasalRate(basalRate) + return settingsImpact } var highInsulinNeedsWarningText: String { diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 67fd43699a..3b253cce56 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -91,6 +91,8 @@ struct PresetsView: View { var scheduledRange: ClosedRange? { settingsManager.therapySettings.glucoseTargetRangeSchedule?.quantityRange(at: Date()) } + + let roundBasalRate: ((LoopQuantity) -> LoopQuantity)? var body: some View { NavigationStack(path: $navigationPath) { @@ -219,7 +221,7 @@ struct PresetsView: View { .sheet(item: $activeSheet) { sheet in switch sheet { case .presetDetent(let preset): - PresetDetentView(preset: preset, didTapEdit: { + PresetDetentView(preset: preset, roundBasalRate: roundBasalRate, didTapEdit: { activeSheet = .editPreset(preset) }) case .editPreset(let preset): diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index c87e4d4013..6358b47a46 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -141,7 +141,7 @@ struct SettingsView: View { Group { switch sheet { case .presets: - PresetsView() + PresetsView(roundBasalRate: viewModel.deliveryDelegate?.roundBasalRate) case .favoriteFoods: FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) } diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 4e040e189c..1f0167d322 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -150,7 +150,7 @@ struct StatusTableView: View { } .sheet(item: $viewModel.pendingPreset) { preset in // This is the active preset; edit disabled - PresetDetentView(preset: preset, didTapEdit: { }) + PresetDetentView(preset: preset, roundBasalRate: viewModel.loopDataManager.deliveryDelegate?.roundBasalRate, didTapEdit: { }) .accessibilityIdentifier("bar_Presets") } .toolbar { From 184ea75a49726621e5e980c562416c8fbcbea0bd Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 8 Jan 2026 14:49:25 -0400 Subject: [PATCH 343/421] [LOOP-5558] rounding basal rates consistently (#885) * rounding basal rates consistently * removing rounding basal rate loop quantity --- Loop/Managers/DeviceDataManager.swift | 4 ---- Loop/Managers/LoopDataManager.swift | 1 - Loop/Views/Presets/Components/PresetDetentView.swift | 4 ++-- Loop/Views/Presets/PresetsView.swift | 2 +- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 84d92ad437..a37e5c3f12 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1343,10 +1343,6 @@ extension DeviceDataManager: DeliveryDelegate { return pumpManager.roundToSupportedBasalRate(unitsPerHour: unitsPerHour) } - - func roundBasalRate(rate: LoopQuantity) -> LoopQuantity { - LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: roundBasalRate(unitsPerHour: rate.doubleValue(for: .internationalUnitsPerHour))) - } func roundBolusVolume(units: Double) -> Double { guard let pumpManager = pumpManager else { diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index bf21c27b60..9b53893e1d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -47,7 +47,6 @@ protocol DeliveryDelegate: AnyObject { func enact(bolus: Double?, tempBasal: TempBasalRecommendation?, decisionId: UUID?) async throws func enactBolus(units: Double, decisionId: UUID?, activationType: BolusActivationType) async throws func roundBasalRate(unitsPerHour: Double) -> Double - func roundBasalRate(rate: LoopQuantity) -> LoopQuantity func roundBolusVolume(units: Double) -> Double } diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index c05d10b451..58e7808351 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -26,7 +26,7 @@ struct PresetDetentView: View { @Environment(\.appName) private var appName let preset: SelectablePreset - let roundBasalRate: ((LoopQuantity) -> LoopQuantity)? + let roundBasalRate: ((Double) -> Double)? let didTapEdit: () -> Void var operation: Operation? { @@ -127,7 +127,7 @@ struct PresetDetentView: View { return settingsImpact } - settingsImpact.basalRate = roundBasalRate(basalRate) + settingsImpact.basalRate = LoopQuantity(unit: .internationalUnitsPerHour, doubleValue: roundBasalRate(basalRate.doubleValue(for: .internationalUnitsPerHour))) return settingsImpact } diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 3b253cce56..04103b566a 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -92,7 +92,7 @@ struct PresetsView: View { settingsManager.therapySettings.glucoseTargetRangeSchedule?.quantityRange(at: Date()) } - let roundBasalRate: ((LoopQuantity) -> LoopQuantity)? + let roundBasalRate: ((Double) -> Double)? var body: some View { NavigationStack(path: $navigationPath) { From c6be38651f8efa1d62fec79cf237a7a4b7701b34 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 12 Jan 2026 06:20:49 -0400 Subject: [PATCH 344/421] [LOOP-5615] refactored display of last bolus UI element (#886) --- .../StatusTableViewController.swift | 58 +++++++++++-------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 7868147ec8..f95796115c 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1116,36 +1116,46 @@ final class StatusTableViewController: LoopChartsTableViewController { let hoursDifference = Date().timeIntervalSince(lastManualDose.startDate) / 3600 - let lastBolusLabel = Text("Last Bolus: ") - let lastBolusValue = Text("\(formattedBolusValue) ").fontWeight(.semibold) - let icon = Text(Image(systemName: "hourglass.bottomhalf.filled")).foregroundStyle(.secondary) - let exactTime = Text("at \(lastManualDose.startDate.formatted(date: .omitted, time: .shortened))").foregroundStyle(.secondary) - let roundedTime = Text(" \(Int(hoursDifference.rounded())) hours ago").foregroundStyle(.secondary) - - Group { - switch hoursDifference { - case ..<6: - lastBolusLabel + - lastBolusValue + - exactTime - case 6..<12: - lastBolusLabel + - lastBolusValue - .foregroundStyle(.secondary) + - icon + - roundedTime - default: - lastBolusLabel + - icon + - roundedTime - } - } + Text(attributedFooter(lastManualDose: lastManualDose, hoursDifference: hoursDifference, formattedBolusValue: formattedBolusValue)) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 36) .padding(.vertical) .accessibilityIdentifier("text_ActiveInsulinFooter") } } + + func attributedFooter(lastManualDose: LastManualBolus, hoursDifference: TimeInterval, formattedBolusValue: String) -> AttributedString { + let lastBolusLabel = AttributedString("Last Bolus: ") + + var lastBolusValue = AttributedString("\(formattedBolusValue) ") + lastBolusValue.font = .system(.body, weight: .semibold) + + var icon = AttributedString("\(Image(systemName: "hourglass.bottomhalf.filled"))") + icon.foregroundColor = .secondary + + var exactTime = AttributedString("at \(lastManualDose.startDate.formatted(date: .omitted, time: .shortened))") + exactTime.foregroundColor = .secondary + + var roundedTime = AttributedString(" \(Int(hoursDifference.rounded())) hours ago") + roundedTime.foregroundColor = .secondary + + var attributedFooter: AttributedString = lastBolusLabel + switch hoursDifference { + case ..<6: + attributedFooter += lastBolusValue + attributedFooter += exactTime + case 6..<12: + lastBolusValue.foregroundColor = .secondary + attributedFooter += lastBolusValue + attributedFooter += icon + attributedFooter += roundedTime + default: + attributedFooter += icon + attributedFooter += roundedTime + } + + return attributedFooter + } private func tableView(_ tableView: UITableView, updateSubtitleFor cell: ChartTableViewCell, at indexPath: IndexPath) { switch Section(rawValue: indexPath.section)! { From d4363b13d17a56ef642745dc8f4330e8e9b8f6ac Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 12 Jan 2026 06:21:40 -0400 Subject: [PATCH 345/421] [LOOP-5691] type needs to be bolus (#887) --- .../Insulin Delivery Log/InsulinDeliveryLogViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index 1eebabca59..f7c74b8af3 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -239,7 +239,7 @@ class InsulinDeliveryLogViewModel { } private func fetchLastAutoBolus(doses: [DoseEntry]) -> DatedQuantity? { - guard let lastAutoBolusDose = doses.last(where: { $0.automatic == true }) else { + guard let lastAutoBolusDose = doses.last(where: { $0.type == .bolus && $0.automatic == true }) else { return nil } From 53c37b59d493ac50cf1bac3ae39c9f4e20c0226e Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 12 Jan 2026 17:35:22 -0400 Subject: [PATCH 346/421] [LOOP-5699] correcting single use preset filter (#888) --- Loop/Views/Presets/Components/PresetDetentView.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index 58e7808351..d07c7e619c 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -32,11 +32,13 @@ struct PresetDetentView: View { var operation: Operation? { if temporaryPresetsManager.activeOverride?.presetId == preset.id { return .end - } else if settingsManager.settings.overridePresets.contains(where: { $0.id == preset.id }) { - return .start - } else { - // if the preset is not saved, it is single use and cannot start again + } else if case .custom(let temporaryPreset) = preset, + !settingsManager.settings.overridePresets.contains(where: { $0.id == temporaryPreset.id }) + { + // if a custom preset is not saved, it is single use and cannot start again return nil + } else { + return .start } } From 7fb3be616eb8f9a7baaf1dd1b4d57a31341eadf2 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 14 Jan 2026 07:04:03 -0400 Subject: [PATCH 347/421] [LOOP-5615] reverted to single text view (#889) --- .../StatusTableViewController.swift | 73 +++++++++---------- 1 file changed, 33 insertions(+), 40 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index f95796115c..97d6b79325 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1109,52 +1109,45 @@ final class StatusTableViewController: LoopChartsTableViewController { } } } - - @ViewBuilder - private func iobFooterViewContent() -> some View { - if let lastManualDose = loopManager.lastManualBolus, let formattedBolusValue = insulinFormatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: lastManualDose.amount)) { + + private var iobFooterText: Text? { + if let lastManualDose = loopManager.lastManualBolus, + let formattedBolusValue = insulinFormatter.string(from: LoopQuantity(unit: .internationalUnit, doubleValue: lastManualDose.amount)) { let hoursDifference = Date().timeIntervalSince(lastManualDose.startDate) / 3600 - Text(attributedFooter(lastManualDose: lastManualDose, hoursDifference: hoursDifference, formattedBolusValue: formattedBolusValue)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 36) - .padding(.vertical) - .accessibilityIdentifier("text_ActiveInsulinFooter") + // Build a single Text view + let footerText: Text + let lastBolusLabel = Text("Last Bolus: ") + let lastBolusValue = Text("\(formattedBolusValue) ").fontWeight(.semibold) + let icon = Text(Image(systemName: "hourglass.bottomhalf.filled")).foregroundStyle(.secondary) + let exactTime = Text("at \(lastManualDose.startDate.formatted(date: .omitted, time: .shortened))").foregroundStyle(.secondary) + let roundedTime = Text(" \(Int(hoursDifference.rounded())) hours ago").foregroundStyle(.secondary) + + switch hoursDifference { + case ..<6: + footerText = lastBolusLabel + lastBolusValue + exactTime + case 6..<12: + footerText = lastBolusLabel + lastBolusValue.foregroundStyle(.secondary) + icon + roundedTime + default: + footerText = lastBolusLabel + icon + roundedTime + } + + return footerText + } else { + return nil } } - - func attributedFooter(lastManualDose: LastManualBolus, hoursDifference: TimeInterval, formattedBolusValue: String) -> AttributedString { - let lastBolusLabel = AttributedString("Last Bolus: ") - - var lastBolusValue = AttributedString("\(formattedBolusValue) ") - lastBolusValue.font = .system(.body, weight: .semibold) - - var icon = AttributedString("\(Image(systemName: "hourglass.bottomhalf.filled"))") - icon.foregroundColor = .secondary - - var exactTime = AttributedString("at \(lastManualDose.startDate.formatted(date: .omitted, time: .shortened))") - exactTime.foregroundColor = .secondary - - var roundedTime = AttributedString(" \(Int(hoursDifference.rounded())) hours ago") - roundedTime.foregroundColor = .secondary - - var attributedFooter: AttributedString = lastBolusLabel - switch hoursDifference { - case ..<6: - attributedFooter += lastBolusValue - attributedFooter += exactTime - case 6..<12: - lastBolusValue.foregroundColor = .secondary - attributedFooter += lastBolusValue - attributedFooter += icon - attributedFooter += roundedTime - default: - attributedFooter += icon - attributedFooter += roundedTime + + @ViewBuilder + private func iobFooterViewContent() -> some View { + if let iobFooterText = iobFooterText { + iobFooterText + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 36) + .padding(.vertical) + .accessibilityIdentifier("text_ActiveInsulinFooter") } - - return attributedFooter } private func tableView(_ tableView: UITableView, updateSubtitleFor cell: ChartTableViewCell, at indexPath: IndexPath) { From 2cc9e27db8da5d845addbe4084d1316696975ddf Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 15 Jan 2026 12:43:14 -0400 Subject: [PATCH 348/421] [LOOP-5558] round input basal to deliverable amount when determining AutomatedTreatmentState (#890) --- Loop/Managers/LoopDataManager.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 9b53893e1d..adc13245cd 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1619,14 +1619,18 @@ extension LoopDataManager: LoopControl { let now = now - guard let neutralBasal = input.basal.closestPrior(to: now)?.value, let currentBasalRate = currentBasalRate(at: now) else { + // need to compare amounts that the pump can actually deliver, instead of calculated amounts + guard let neutralBasal = input.basal.closestPrior(to: now)?.value, + let deliverableNeutralBasal = deliveryDelegate?.roundBolusVolume(units: neutralBasal), + let currentlyDeliveredBasalRate = currentBasalRate(at: now) + else { return nil } - if currentBasalRate > neutralBasal { + if currentlyDeliveredBasalRate > deliverableNeutralBasal { return .increasedInsulin - } else if currentBasalRate < neutralBasal { - if currentBasalRate == 0 { + } else if currentlyDeliveredBasalRate < deliverableNeutralBasal { + if currentlyDeliveredBasalRate == 0 { return .minimumDelivery } else { return .decreasedInsulin @@ -1640,7 +1644,7 @@ extension LoopDataManager: LoopControl { if !recentAutomaticBoluses.isEmpty { return .increasedInsulin } - return scheduledBasalRate(at: now) != neutralBasal ? .neutralOverride : .neutralNoOverride + return scheduledBasalRate(at: now) != deliverableNeutralBasal ? .neutralOverride : .neutralNoOverride } } } From c7bb0d980833d3a3940e2a3f57c0c7575263a40a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 15 Jan 2026 15:17:10 -0600 Subject: [PATCH 349/421] Add feature flag for apidra (#891) --- Common/FeatureFlags.swift | 9 +++++++++ Loop/Managers/DeviceDataManager.swift | 3 +++ 2 files changed, 12 insertions(+) diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 6484a513a9..dbbc902310 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -18,6 +18,7 @@ struct FeatureFlagConfiguration: Decodable { let fiaspInsulinModelEnabled: Bool let lyumjevInsulinModelEnabled: Bool let afrezzaInsulinModelEnabled: Bool + let apidraInsulinModelEnabled: Bool let includeServicesInSettingsEnabled: Bool let manualDoseEntryEnabled: Bool let insulinDeliveryReservoirViewEnabled: Bool @@ -72,6 +73,13 @@ struct FeatureFlagConfiguration: Decodable { self.fiaspInsulinModelEnabled = true #endif + // Swift compiler config is inverse, since the default state is enabled. + #if APIDRA_INSULIN_MODEL_DISABLED + self.apidraInsulinModelEnabled = false + #else + self.apidraInsulinModelEnabled = true + #endif + // Swift compiler config is inverse, since the default state is enabled. #if LYUMJEV_INSULIN_MODEL_DISABLED self.lyumjevInsulinModelEnabled = false @@ -228,6 +236,7 @@ extension FeatureFlagConfiguration : CustomDebugStringConvertible { "* fiaspInsulinModelEnabled: \(fiaspInsulinModelEnabled)", "* lyumjevInsulinModelEnabled: \(lyumjevInsulinModelEnabled)", "* afrezzaInsulinModelEnabled: \(afrezzaInsulinModelEnabled)", + "* apidraInsulinModelEnabled: \(apidraInsulinModelEnabled)", "* includeServicesInSettingsEnabled: \(includeServicesInSettingsEnabled)", "* mockTherapySettingsEnabled: \(mockTherapySettingsEnabled)", "* observeHealthKitCarbSamplesFromOtherApps: \(observeHealthKitCarbSamplesFromOtherApps)", diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index a37e5c3f12..ec039968ee 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -93,6 +93,9 @@ final class DeviceDataManager { if !FeatureFlags.afrezzaInsulinModelEnabled { allowed.remove(.afrezza) } + if !FeatureFlags.apidraInsulinModelEnabled { + allowed.remove(.apidra) + } for insulinType in InsulinType.allCases { if !insulinType.pumpAdministerable { From a5b30fbfddbdd536086df05a77d11b7005ddd4cf Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 22 Jan 2026 14:18:36 -0400 Subject: [PATCH 350/421] [LOOP-5496] made loop completion modal viewModel observable (#892) --- .../StatusTableViewController.swift | 24 ++++++++++--- Loop/Views/LoopStatusModalView.swift | 34 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 97d6b79325..36429a1635 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -439,6 +439,7 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView?.loopCompletionHUD.deviceInoperable = deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true || deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true || deviceManager.hasBluetoothIssue hudView?.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate hudView?.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate + updateLoopCompletionModal() guard !reloading && !deviceManager.authorizationRequired else { return @@ -1658,9 +1659,20 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView.addPumpManagerProvidedHUDView(view) } } - - @objc private func showLoopCompletionMessage(_: Any) { - let viewModel = LoopStatusModalViewModel( + + private lazy var loopCompletionModalViewModel = LoopStatusModalViewModel( + lastLoopCompleted: loopManager.lastLoopCompleted, + loopIconClosed: automaticDosingEnabled, + hasBluetoothIssue: deviceManager.hasBluetoothIssue, + isDeliverySuspended: deviceManager.isSuspended, + isPumpInSignalLoss: deviceManager.pumpManager?.inSignalLoss == true, + isPumpInoperable: deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true, + isCGMInWarmup: deviceManager.cgmManager?.cgmManagerStatus.inSensorWarmup == true, + isCGMInSignalLoss: deviceManager.cgmManager?.inSignalLoss == true, + isCGMInoperable: deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true) + + private func updateLoopCompletionModal() { + loopCompletionModalViewModel.update( lastLoopCompleted: loopManager.lastLoopCompleted, loopIconClosed: automaticDosingEnabled, hasBluetoothIssue: deviceManager.hasBluetoothIssue, @@ -1670,9 +1682,13 @@ final class StatusTableViewController: LoopChartsTableViewController { isCGMInWarmup: deviceManager.cgmManager?.cgmManagerStatus.inSensorWarmup == true, isCGMInSignalLoss: deviceManager.cgmManager?.inSignalLoss == true, isCGMInoperable: deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true) + } + + @objc private func showLoopCompletionMessage(_: Any) { + updateLoopCompletionModal() let modalVC = UIHostingController( - rootView: LoopStatusModalView(viewModel: viewModel, + rootView: LoopStatusModalView(viewModel: loopCompletionModalViewModel, onDismiss: { [weak self] in self?.dismiss(animated: false) }, diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 73cac4f446..b1486e539c 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -15,7 +15,8 @@ struct LoopStatusModalView: View { @State private var appear = false - let viewModel: LoopStatusModalViewModel + @Bindable var viewModel: LoopStatusModalViewModel + let onDismiss: () -> Void let onNavigateToSettings: () -> Void @@ -27,13 +28,17 @@ struct LoopStatusModalView: View { } } + private var deviceInoperable: Bool { + viewModel.isCGMInoperable || viewModel.isPumpInoperable || viewModel.hasBluetoothIssue + } + var body: some View { VStack { closeButton .padding(5) .frame(maxWidth: .infinity, alignment: .trailing) - LoopCircleView(closedLoop: viewModel.loopIconClosed, freshness: viewModel.freshness) + LoopCircleView(closedLoop: viewModel.loopIconClosed, freshness: viewModel.freshness, deviceInoperable: deviceInoperable) .environment(\.loopStatusColorPalette, loopStatusColors) .padding(.bottom) @@ -133,7 +138,8 @@ struct LoopStatusModalView: View { } } -struct LoopStatusModalViewModel { +@Observable +class LoopStatusModalViewModel { private var dateTimeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium @@ -184,6 +190,27 @@ struct LoopStatusModalViewModel { var isCGMInSignalLoss: Bool var isCGMInoperable: Bool + func update(lastLoopCompleted: Date?, + loopIconClosed: Bool, + hasBluetoothIssue: Bool, + isDeliverySuspended: Bool, + isPumpInSignalLoss: Bool, + isPumpInoperable: Bool, + isCGMInWarmup: Bool, + isCGMInSignalLoss: Bool, + isCGMInoperable: Bool) + { + self.lastLoopCompleted = lastLoopCompleted + self.loopIconClosed = loopIconClosed + self.hasBluetoothIssue = hasBluetoothIssue + self.isDeliverySuspended = isDeliverySuspended + self.isPumpInSignalLoss = isPumpInSignalLoss + self.isPumpInoperable = isPumpInoperable + self.isCGMInWarmup = isCGMInWarmup + self.isCGMInoperable = isCGMInoperable + self.isCGMInSignalLoss = isCGMInSignalLoss + } + var copy: (title: String, message: String) { guard loopIconClosed else { if hasBluetoothIssue || isPumpInoperable || isPumpInSignalLoss { @@ -202,7 +229,6 @@ struct LoopStatusModalViewModel { return (titleAutomationOff, NSLocalizedString("Make sure your devices are connected and within bluetooth range.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on.", comment: "message when automation is off, glucose value is not fresh and devices are good")) } } - if hasBluetoothIssue || isPumpInoperable { return (titleUnavailable, NSLocalizedString("Tap your CGM or insulin pump status icons right away for more information and steps to resolve the issue.", comment: "message when automation is on and there is a bluetooth or pump issue")) From e17d004cfc00b778732b294cf47b30a5eddad4a1 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 26 Jan 2026 11:02:20 -0800 Subject: [PATCH 351/421] [LOOP-5707] Migrate Presets to LoopKit (#893) --- Loop.xcodeproj/project.pbxproj | 214 +-- Loop/Extensions/Character+IsEmoji.swift | 15 - .../Components/AdjustedGlucoseRangeView.swift | 46 - .../Presets/Components/CardSection.swift | 73 - .../Components/CardSectionScrollView.swift | 52 - .../Components/CorrectionRangePreview.swift | 134 -- .../Presets/Components/DayPickerPopup.swift | 62 - .../InsulinNeedsAdjustmentPreview.swift | 82 -- .../Components/InsulinScaleAdjustView.swift | 220 --- .../Components/PercentPickerView.swift | 114 -- .../Views/Presets/Components/PresetCard.swift | 137 -- .../Presets/Components/PresetDetentView.swift | 3 +- .../Presets/Components/PresetStatsView.swift | 211 --- .../Components/RepeatOptionsView.swift | 47 - .../CreatePresetNameAndScheduledEdit.swift | 302 ---- Loop/Views/Presets/CreatePresetView.swift | 210 --- Loop/Views/Presets/DurationPickerView.swift | 161 --- Loop/Views/Presets/EditPresetView.swift | 530 ------- .../ExistingPresetInsulinNeedsEdit.swift | 87 -- .../Presets/ExistingPresetRangeEdit.swift | 156 --- .../Presets/InsulinScaleInformationView.swift | 140 -- Loop/Views/Presets/NewCustomPreset.swift | 159 --- Loop/Views/Presets/NewPresetRangeEdit.swift | 146 -- Loop/Views/Presets/NoticeView.swift | 40 - Loop/Views/Presets/PresetRangeEditor.swift | 173 --- Loop/Views/Presets/PresetSymbolView.swift | 46 - Loop/Views/Presets/PresetsHistoryView.swift | 1 + Loop/Views/Presets/PresetsView.swift | 44 +- Loop/Views/Presets/ReviewNewPresetView.swift | 184 --- .../Components/InsetContent.swift | 36 - .../Components/TimelineSteps.swift | 160 --- .../Training Content/PresetsTraining.swift | 485 ------- .../PresetsTrainingContent.swift | 1236 ----------------- .../Components/CommonUseStep.swift | 0 .../Components/EstimatedReadTime.swift | 0 .../Components/IntensitySlider.swift | 1 + .../Components/PlayMediaButton.swift | 0 .../Components/PresetsTrainingCard.swift | 9 +- .../TherapySettingsExampleView.swift | 0 .../Components/TintedContent.swift | 0 .../Training/PresetsTrainingContent.swift | 1055 ++++++++++++++ .../PresetsTrainingView.swift | 13 +- Loop/Views/WarningPanel.swift | 37 - LoopCore/SelectablePreset.swift | 502 ------- WatchApp/ContentView.swift | 2 +- 45 files changed, 1154 insertions(+), 6171 deletions(-) delete mode 100644 Loop/Extensions/Character+IsEmoji.swift delete mode 100644 Loop/Views/Presets/Components/AdjustedGlucoseRangeView.swift delete mode 100644 Loop/Views/Presets/Components/CardSection.swift delete mode 100644 Loop/Views/Presets/Components/CardSectionScrollView.swift delete mode 100644 Loop/Views/Presets/Components/CorrectionRangePreview.swift delete mode 100644 Loop/Views/Presets/Components/DayPickerPopup.swift delete mode 100644 Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift delete mode 100644 Loop/Views/Presets/Components/InsulinScaleAdjustView.swift delete mode 100644 Loop/Views/Presets/Components/PercentPickerView.swift delete mode 100644 Loop/Views/Presets/Components/PresetCard.swift delete mode 100644 Loop/Views/Presets/Components/PresetStatsView.swift delete mode 100644 Loop/Views/Presets/Components/RepeatOptionsView.swift delete mode 100644 Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift delete mode 100644 Loop/Views/Presets/CreatePresetView.swift delete mode 100644 Loop/Views/Presets/DurationPickerView.swift delete mode 100644 Loop/Views/Presets/EditPresetView.swift delete mode 100644 Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift delete mode 100644 Loop/Views/Presets/ExistingPresetRangeEdit.swift delete mode 100644 Loop/Views/Presets/InsulinScaleInformationView.swift delete mode 100644 Loop/Views/Presets/NewCustomPreset.swift delete mode 100644 Loop/Views/Presets/NewPresetRangeEdit.swift delete mode 100644 Loop/Views/Presets/NoticeView.swift delete mode 100644 Loop/Views/Presets/PresetRangeEditor.swift delete mode 100644 Loop/Views/Presets/PresetSymbolView.swift delete mode 100644 Loop/Views/Presets/ReviewNewPresetView.swift delete mode 100644 Loop/Views/Presets/Training Content/Components/InsetContent.swift delete mode 100644 Loop/Views/Presets/Training Content/Components/TimelineSteps.swift delete mode 100644 Loop/Views/Presets/Training Content/PresetsTraining.swift delete mode 100644 Loop/Views/Presets/Training Content/PresetsTrainingContent.swift rename Loop/Views/Presets/{Training Content => Training}/Components/CommonUseStep.swift (100%) rename Loop/Views/Presets/{Training Content => Training}/Components/EstimatedReadTime.swift (100%) rename Loop/Views/Presets/{Training Content => Training}/Components/IntensitySlider.swift (99%) rename Loop/Views/Presets/{Training Content => Training}/Components/PlayMediaButton.swift (100%) rename Loop/Views/Presets/{Training Content => Training}/Components/PresetsTrainingCard.swift (76%) rename Loop/Views/Presets/{Training Content => Training}/Components/TherapySettingsExampleView.swift (100%) rename Loop/Views/Presets/{Training Content => Training}/Components/TintedContent.swift (100%) create mode 100644 Loop/Views/Presets/Training/PresetsTrainingContent.swift rename Loop/Views/Presets/{Training Content => Training}/PresetsTrainingView.swift (94%) delete mode 100644 Loop/Views/WarningPanel.swift delete mode 100644 LoopCore/SelectablePreset.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 08ce121575..03dfbffcd1 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -45,7 +45,6 @@ 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 */; }; @@ -222,23 +221,23 @@ 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 */; }; - 840BB39D2E67796D00537FFB /* CommonUseStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840BB39C2E67796D00537FFB /* CommonUseStep.swift */; }; 84213C752D932F0F00642E78 /* InsulinDeliveryLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */; }; 84213C892D94941000642E78 /* InsulinDeliveryLogEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */; }; 84213C8B2D94948900642E78 /* InsulinDeliveryOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */; }; 84213C8D2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */; }; + 842E40A72F22F7E2000CCCE0 /* TintedContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E40A02F22F7E2000CCCE0 /* TintedContent.swift */; }; + 842E40A92F22F7E2000CCCE0 /* EstimatedReadTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E40982F22F7E2000CCCE0 /* EstimatedReadTime.swift */; }; + 842E40AA2F22F7E2000CCCE0 /* PresetsTrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E40A32F22F7E2000CCCE0 /* PresetsTrainingView.swift */; }; + 842E40AB2F22F7E2000CCCE0 /* IntensitySlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E409F2F22F7E2000CCCE0 /* IntensitySlider.swift */; }; + 842E40AD2F22F7E2000CCCE0 /* PresetsTrainingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E40A52F22F7E2000CCCE0 /* PresetsTrainingContent.swift */; }; + 842E40AE2F22F7E2000CCCE0 /* PlayMediaButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E409E2F22F7E2000CCCE0 /* PlayMediaButton.swift */; }; + 842E40AF2F22F7E2000CCCE0 /* PresetsTrainingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E409A2F22F7E2000CCCE0 /* PresetsTrainingCard.swift */; }; + 842E40B12F22F7E2000CCCE0 /* CommonUseStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E409B2F22F7E2000CCCE0 /* CommonUseStep.swift */; }; + 842E40B22F22F7E2000CCCE0 /* TherapySettingsExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E409D2F22F7E2000CCCE0 /* TherapySettingsExampleView.swift */; }; 843F32EA2E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */; }; - 8443566B2E6F8325000EBD1A /* TintedContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8443566A2E6F8325000EBD1A /* TintedContent.swift */; }; - 84475DF02E5E644D00FC5E7C /* PresetsTraining.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475DEF2E5E644D00FC5E7C /* PresetsTraining.swift */; }; - 84475DF22E5E64A700FC5E7C /* PresetsTrainingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475DF12E5E64A700FC5E7C /* PresetsTrainingContent.swift */; }; - 84475E0A2E5ECD4F00FC5E7C /* EstimatedReadTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475E092E5ECD4F00FC5E7C /* EstimatedReadTime.swift */; }; - 84475E0C2E5EDF1800FC5E7C /* InsetContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475E0B2E5EDF1800FC5E7C /* InsetContent.swift */; }; - 84475E0E2E5F00B900FC5E7C /* TimelineSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475E0D2E5F00B900FC5E7C /* TimelineSteps.swift */; }; - 84475E102E5F870800FC5E7C /* PresetsTrainingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84475E0F2E5F870800FC5E7C /* PresetsTrainingCard.swift */; }; 847F23432E4543140035C864 /* ActivePresetBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847F23422E4543140035C864 /* ActivePresetBanner.swift */; }; 849466D02EF1EAD300A90718 /* LocalizablePlural.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */; }; 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; - 849E06232E5E41BA00A71614 /* PresetsTrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849E06222E5E41BA00A71614 /* PresetsTrainingView.swift */; }; 84A7B5502D2D972C00B6D202 /* Image+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */; }; 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */; }; @@ -247,18 +246,11 @@ 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */; }; 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E62A4A4DEF000B658B /* PumpView.swift */; }; 84BC5BDF2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BC5BDE2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift */; }; - 84C170EF2CCA37680098E52F /* PresetCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C170EE2CCA37680098E52F /* PresetCard.swift */; }; - 84C77CF22E6A054B00839FEC /* PlayMediaButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C77CF12E6A054B00839FEC /* PlayMediaButton.swift */; }; - 84C77CF42E6A17FB00839FEC /* IntensitySlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C77CF32E6A17FB00839FEC /* IntensitySlider.swift */; }; 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A62D09053A00CB271F /* StatusTableView.swift */; }; 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A82D09800700CB271F /* PresetDetentView.swift */; }; 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; - 84E8BBC42CC9B9890078E6CF /* AdjustedGlucoseRangeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */; }; - 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */; }; - 84E8BBC82CC9D34B0078E6CF /* TherapySettingsExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */; }; 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC92CCA16290078E6CF /* PresetsView.swift */; }; 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; - 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */; }; 84F20DFD2D0B9C3A0089DF02 /* EditPresetDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */; }; 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */; }; 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */; }; @@ -403,30 +395,17 @@ 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 */; }; - C105095B2D78D35100118A37 /* CreatePresetNameAndScheduledEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105095A2D78D32C00118A37 /* CreatePresetNameAndScheduledEdit.swift */; }; - C105095D2D7A1DB700118A37 /* CardSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105095C2D7A1DB300118A37 /* CardSection.swift */; }; - C105095F2D7A311200118A37 /* ReviewNewPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105095E2D7A310B00118A37 /* ReviewNewPresetView.swift */; }; - C10509612D7B3DF400118A37 /* CardSectionScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509602D7B3DEA00118A37 /* CardSectionScrollView.swift */; }; - C10509652D7B6B1900118A37 /* CorrectionRangePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509642D7B6B0000118A37 /* CorrectionRangePreview.swift */; }; - C10509672D7F7A4900118A37 /* InsulinScaleAdjustView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509662D7F7A3700118A37 /* InsulinScaleAdjustView.swift */; }; - C105096D2D80E23A00118A37 /* DayPickerPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105096C2D80E22C00118A37 /* DayPickerPopup.swift */; }; - C105096F2D8237F300118A37 /* EditPresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105096E2D8237EF00118A37 /* EditPresetView.swift */; }; - C10509712D84A80900118A37 /* RepeatOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509702D84A80500118A37 /* RepeatOptionsView.swift */; }; C10509752D8B309400118A37 /* Environment+SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509742D8B308E00118A37 /* Environment+SettingsManager.swift */; }; C10509772D8B591200118A37 /* StatusTableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509762D8B590D00118A37 /* StatusTableViewModel.swift */; }; C10509792D8B636900118A37 /* Environment+TemporaryPresetsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10509782D8B635F00118A37 /* Environment+TemporaryPresetsManager.swift */; }; C10C57E52E6F767A00A4825C /* CircleTintedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57E42E6F767500A4825C /* CircleTintedButton.swift */; }; C10C57EC2E7070FF00A4825C /* PresetsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57EB2E7070FB00A4825C /* PresetsListView.swift */; }; C10C57EE2E7081D200A4825C /* PresetDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57ED2E7081C900A4825C /* PresetDetailView.swift */; }; - C10C57F22E70851F00A4825C /* SelectablePreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105097A2D8B947700118A37 /* SelectablePreset.swift */; }; - C10C57F32E70851F00A4825C /* SelectablePreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = C105097A2D8B947700118A37 /* SelectablePreset.swift */; }; - C10C57F82E7085D600A4825C /* PresetSymbolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */; }; C10C57FA2E708B2D00A4825C /* PresetWatchCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57F92E708B1D00A4825C /* PresetWatchCard.swift */; }; C10C57FC2E70B8B900A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57FB2E70B87500A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift */; }; C10C57FE2E71E87D00A4825C /* ActiveOverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57FD2E71E87400A4825C /* ActiveOverrideView.swift */; }; C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C110888C2A3913C600BA4898 /* BuildDetails.swift */; }; C11445B22DA98A3400034864 /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */; }; - C11445B42DB2EBE400034864 /* ExistingPresetInsulinNeedsEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11445B32DB2EBDC00034864 /* ExistingPresetInsulinNeedsEdit.swift */; }; C11613492983096D00777E7C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C11613472983096D00777E7C /* InfoPlist.strings */; }; C116134C2983096D00777E7C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C116134A2983096D00777E7C /* Localizable.strings */; }; C11B9D5B286778A800500CF8 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C11B9D5A286778A800500CF8 /* SwiftCharts */; }; @@ -453,22 +432,18 @@ 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 */; }; - C14F68C92D4AC54300BC3B8D /* DurationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14F68C82D4AC54000BC3B8D /* DurationPickerView.swift */; }; C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */; }; C152B9F72C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */; }; C1550B0C2E6F249A009369DC /* LoopCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1550B0B2E6F249A009369DC /* LoopCircleView.swift */; }; C15A8C492A7305B1009D736B /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; }; - C1620D392DE0E5120033DEB5 /* NoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1620D382DE0E50D0033DEB5 /* NoticeView.swift */; }; C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */; }; C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */; }; C16575732538AFF6004AE16E /* CGMStalenessMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */; }; C16575752539FD60004AE16E /* LoopCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575742539FD60004AE16E /* LoopCoreConstants.swift */; }; C16575762539FEF3004AE16E /* LoopCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575742539FD60004AE16E /* LoopCoreConstants.swift */; }; - C16971F92D1231B5001B7DF6 /* PresetRangeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16971F82D1231AB001B7DF6 /* PresetRangeEditor.swift */; }; 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 */; }; - C16E34C32E94443300581D20 /* InsulinNeedsAdjustmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16E34C22E94442E00581D20 /* InsulinNeedsAdjustmentPreview.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 */; }; @@ -506,11 +481,6 @@ C19E23B62E83513400C20D83 /* PresetActivateCrownConfirm.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E23B52E83512A00C20D83 /* PresetActivateCrownConfirm.swift */; }; C19E23B82E83566700C20D83 /* CircularProgressWithCheckmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E23B72E83566300C20D83 /* CircularProgressWithCheckmark.swift */; }; C19F48742560ABFB003632D7 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; - C1AC03962D6E07D6004D4D2B /* CreatePresetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC03952D6E07D3004D4D2B /* CreatePresetView.swift */; }; - C1AC039A2D6E3C88004D4D2B /* InsulinScaleInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC03992D6E3C7B004D4D2B /* InsulinScaleInformationView.swift */; }; - C1AC039C2D6E7551004D4D2B /* ExistingPresetRangeEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC039B2D6E751C004D4D2B /* ExistingPresetRangeEdit.swift */; }; - C1AC039E2D6FC8C8004D4D2B /* NewPresetRangeEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC039D2D6FC8BB004D4D2B /* NewPresetRangeEdit.swift */; }; - C1AC03A02D6FCB2F004D4D2B /* NewCustomPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AC039F2D6FCB2C004D4D2B /* NewCustomPreset.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 */; }; @@ -528,7 +498,6 @@ C1DCEDDD2E983A22001A7BB0 /* AutomatedTreatmentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEDDC2E983A1B001A7BB0 /* AutomatedTreatmentState.swift */; }; C1DCEDF42E999D5E001A7BB0 /* LastManualBolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */; }; C1DCEDF52E999D5E001A7BB0 /* LastManualBolus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */; }; - C1DCEE452EB16662001A7BB0 /* WarningPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DCEE442EB1665F001A7BB0 /* WarningPanel.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 */; }; @@ -785,7 +754,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 = ""; }; - 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 = ""; }; @@ -1136,22 +1104,22 @@ 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; }; - 840BB39C2E67796D00537FFB /* CommonUseStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonUseStep.swift; sourceTree = ""; }; 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLog.swift; sourceTree = ""; }; 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogEvent.swift; sourceTree = ""; }; 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryOverview.swift; sourceTree = ""; }; 84213C8C2D949E8100642E78 /* InsulinDeliveryLogEventRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogEventRow.swift; sourceTree = ""; }; + 842E40982F22F7E2000CCCE0 /* EstimatedReadTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedReadTime.swift; sourceTree = ""; }; + 842E409A2F22F7E2000CCCE0 /* PresetsTrainingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingCard.swift; sourceTree = ""; }; + 842E409B2F22F7E2000CCCE0 /* CommonUseStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonUseStep.swift; sourceTree = ""; }; + 842E409D2F22F7E2000CCCE0 /* TherapySettingsExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySettingsExampleView.swift; sourceTree = ""; }; + 842E409E2F22F7E2000CCCE0 /* PlayMediaButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMediaButton.swift; sourceTree = ""; }; + 842E409F2F22F7E2000CCCE0 /* IntensitySlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntensitySlider.swift; sourceTree = ""; }; + 842E40A02F22F7E2000CCCE0 /* TintedContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TintedContent.swift; sourceTree = ""; }; + 842E40A32F22F7E2000CCCE0 /* PresetsTrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingView.swift; sourceTree = ""; }; + 842E40A52F22F7E2000CCCE0 /* PresetsTrainingContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingContent.swift; sourceTree = ""; }; 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogViewModel.swift; sourceTree = ""; }; - 8443566A2E6F8325000EBD1A /* TintedContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TintedContent.swift; sourceTree = ""; }; - 84475DEF2E5E644D00FC5E7C /* PresetsTraining.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTraining.swift; sourceTree = ""; }; - 84475DF12E5E64A700FC5E7C /* PresetsTrainingContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingContent.swift; sourceTree = ""; }; - 84475E092E5ECD4F00FC5E7C /* EstimatedReadTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedReadTime.swift; sourceTree = ""; }; - 84475E0B2E5EDF1800FC5E7C /* InsetContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetContent.swift; sourceTree = ""; }; - 84475E0D2E5F00B900FC5E7C /* TimelineSteps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSteps.swift; sourceTree = ""; }; - 84475E0F2E5F870800FC5E7C /* PresetsTrainingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingCard.swift; sourceTree = ""; }; 847F23422E4543140035C864 /* ActivePresetBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivePresetBanner.swift; sourceTree = ""; }; 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; - 849E06222E5E41BA00A71614 /* PresetsTrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingView.swift; sourceTree = ""; }; 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Optional.swift"; sourceTree = ""; }; 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; @@ -1160,19 +1128,11 @@ 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelineProvider.swift; sourceTree = ""; }; 84AA81E62A4A4DEF000B658B /* PumpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpView.swift; sourceTree = ""; }; 84BC5BDE2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryEventDetailsView.swift; sourceTree = ""; }; - 84C170EE2CCA37680098E52F /* PresetCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetCard.swift; sourceTree = ""; }; - 84C77CF12E6A054B00839FEC /* PlayMediaButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMediaButton.swift; sourceTree = ""; }; - 84C77CF32E6A17FB00839FEC /* IntensitySlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntensitySlider.swift; sourceTree = ""; }; 84D1F1A62D09053A00CB271F /* StatusTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableView.swift; sourceTree = ""; }; 84D1F1A82D09800700CB271F /* PresetDetentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDetentView.swift; sourceTree = ""; }; 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.swift; sourceTree = ""; }; - 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetSymbolView.swift; sourceTree = ""; }; - 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustedGlucoseRangeView.swift; sourceTree = ""; }; - 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PercentPickerView.swift; sourceTree = ""; }; - 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySettingsExampleView.swift; sourceTree = ""; }; 84E8BBC92CCA16290078E6CF /* PresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsView.swift; sourceTree = ""; }; 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; - 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetStatsView.swift; sourceTree = ""; }; 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPresetDurationView.swift; sourceTree = ""; }; 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsHistoryView.swift; sourceTree = ""; }; 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlowViewModel.swift; sourceTree = ""; }; @@ -1365,19 +1325,9 @@ C1004E322981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; C1004E342981F77B00B8CF94 /* 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; }; - C105095A2D78D32C00118A37 /* CreatePresetNameAndScheduledEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePresetNameAndScheduledEdit.swift; sourceTree = ""; }; - C105095C2D7A1DB300118A37 /* CardSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardSection.swift; sourceTree = ""; }; - C105095E2D7A310B00118A37 /* ReviewNewPresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewNewPresetView.swift; sourceTree = ""; }; - C10509602D7B3DEA00118A37 /* CardSectionScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardSectionScrollView.swift; sourceTree = ""; }; - C10509642D7B6B0000118A37 /* CorrectionRangePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangePreview.swift; sourceTree = ""; }; - C10509662D7F7A3700118A37 /* InsulinScaleAdjustView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinScaleAdjustView.swift; sourceTree = ""; }; - C105096C2D80E22C00118A37 /* DayPickerPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayPickerPopup.swift; sourceTree = ""; }; - C105096E2D8237EF00118A37 /* EditPresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPresetView.swift; sourceTree = ""; }; - C10509702D84A80500118A37 /* RepeatOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatOptionsView.swift; sourceTree = ""; }; C10509742D8B308E00118A37 /* Environment+SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+SettingsManager.swift"; sourceTree = ""; }; C10509762D8B590D00118A37 /* StatusTableViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewModel.swift; sourceTree = ""; }; C10509782D8B635F00118A37 /* Environment+TemporaryPresetsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+TemporaryPresetsManager.swift"; sourceTree = ""; }; - C105097A2D8B947700118A37 /* SelectablePreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectablePreset.swift; sourceTree = ""; }; C1092BFD29F8116700AE3D1C /* apply-info-customizations.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "apply-info-customizations.sh"; sourceTree = ""; }; C10C57E42E6F767500A4825C /* CircleTintedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleTintedButton.swift; sourceTree = ""; }; C10C57E82E705CCB00A4825C /* SettingsRequestUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRequestUserInfo.swift; sourceTree = ""; }; @@ -1388,7 +1338,6 @@ C10C57FD2E71E87400A4825C /* ActiveOverrideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveOverrideView.swift; sourceTree = ""; }; C110888C2A3913C600BA4898 /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = ""; }; C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; - C11445B32DB2EBDC00034864 /* ExistingPresetInsulinNeedsEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExistingPresetInsulinNeedsEdit.swift; sourceTree = ""; }; C11613482983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; C116134B2983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; C116134D2983096D00777E7C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/ckcomplication.strings; sourceTree = ""; }; @@ -1436,7 +1385,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 = ""; }; - C14F68C82D4AC54000BC3B8D /* DurationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationPickerView.swift; sourceTree = ""; }; C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntry.swift; sourceTree = ""; }; C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntryTests.swift; sourceTree = ""; }; C1550B0B2E6F249A009369DC /* LoopCircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCircleView.swift; sourceTree = ""; }; @@ -1450,16 +1398,13 @@ 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 = ""; }; - C1620D382DE0E50D0033DEB5 /* NoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeView.swift; sourceTree = ""; }; C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusViewModelTests.swift; sourceTree = ""; }; C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitor.swift; sourceTree = ""; }; C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitorTests.swift; sourceTree = ""; }; C16575742539FD60004AE16E /* LoopCoreConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCoreConstants.swift; sourceTree = ""; }; - C16971F82D1231AB001B7DF6 /* PresetRangeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetRangeEditor.swift; sourceTree = ""; }; 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 = ""; }; - C16E34C22E94442E00581D20 /* InsulinNeedsAdjustmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinNeedsAdjustmentPreview.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 = ""; }; @@ -1515,11 +1460,6 @@ C19E387D298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; C19F496225630504003632D7 /* Minizip.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Minizip.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationActionSelection.swift; sourceTree = ""; }; - C1AC03952D6E07D3004D4D2B /* CreatePresetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePresetView.swift; sourceTree = ""; }; - C1AC03992D6E3C7B004D4D2B /* InsulinScaleInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinScaleInformationView.swift; sourceTree = ""; }; - C1AC039B2D6E751C004D4D2B /* ExistingPresetRangeEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExistingPresetRangeEdit.swift; sourceTree = ""; }; - C1AC039D2D6FC8BB004D4D2B /* NewPresetRangeEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPresetRangeEdit.swift; sourceTree = ""; }; - C1AC039F2D6FCB2C004D4D2B /* NewCustomPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCustomPreset.swift; sourceTree = ""; }; 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 = ""; }; C1AD62FE29BBFAA80002685D /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1574,7 +1514,6 @@ C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistedProperty.swift; sourceTree = ""; }; C1DCEDDC2E983A1B001A7BB0 /* AutomatedTreatmentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomatedTreatmentState.swift; sourceTree = ""; }; C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastManualBolus.swift; sourceTree = ""; }; - C1DCEE442EB1665F001A7BB0 /* WarningPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WarningPanel.swift; sourceTree = ""; }; C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SimpleBolusView.swift; sourceTree = ""; }; C1E2773D224177C000354103 /* ClockKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ClockKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/ClockKit.framework; sourceTree = DEVELOPER_DIR; }; C1E2774722433D7A00354103 /* MKRingProgressView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MKRingProgressView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2134,7 +2073,6 @@ children = ( C1ED6C632E7C6DA6002F91C2 /* Models */, C1ED6C602E79BB9C002F91C2 /* NotificationManager.swift */, - C105097A2D8B947700118A37 /* SelectablePreset.swift */, C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */, 43DE92581C5479E4001FFDE1 /* GetBolusRecommendationUserInfo.swift */, 43C05CB721EBEA54006FB252 /* HKUnit.swift */, @@ -2163,7 +2101,6 @@ 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 */, @@ -2223,7 +2160,6 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( - C1DCEE442EB1665F001A7BB0 /* WarningPanel.swift */, 84213C732D932EF400642E78 /* Insulin Delivery Log */, B4001CED28CBBC82002FB414 /* AlertManagementView.swift */, 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */, @@ -2482,22 +2418,30 @@ path = "Insulin Delivery Log"; sourceTree = ""; }; - 84475E082E5ECD3400FC5E7C /* Components */ = { + 842E40A12F22F7E2000CCCE0 /* Components */ = { isa = PBXGroup; children = ( - 84475E092E5ECD4F00FC5E7C /* EstimatedReadTime.swift */, - 84475E0B2E5EDF1800FC5E7C /* InsetContent.swift */, - 84475E0D2E5F00B900FC5E7C /* TimelineSteps.swift */, - 84475E0F2E5F870800FC5E7C /* PresetsTrainingCard.swift */, - 840BB39C2E67796D00537FFB /* CommonUseStep.swift */, - 84E8BBC72CC9D34B0078E6CF /* TherapySettingsExampleView.swift */, - 84C77CF12E6A054B00839FEC /* PlayMediaButton.swift */, - 84C77CF32E6A17FB00839FEC /* IntensitySlider.swift */, - 8443566A2E6F8325000EBD1A /* TintedContent.swift */, + 842E40982F22F7E2000CCCE0 /* EstimatedReadTime.swift */, + 842E409A2F22F7E2000CCCE0 /* PresetsTrainingCard.swift */, + 842E409B2F22F7E2000CCCE0 /* CommonUseStep.swift */, + 842E409D2F22F7E2000CCCE0 /* TherapySettingsExampleView.swift */, + 842E409E2F22F7E2000CCCE0 /* PlayMediaButton.swift */, + 842E409F2F22F7E2000CCCE0 /* IntensitySlider.swift */, + 842E40A02F22F7E2000CCCE0 /* TintedContent.swift */, ); path = Components; sourceTree = ""; }; + 842E40A62F22F7E2000CCCE0 /* Training */ = { + isa = PBXGroup; + children = ( + 842E40A12F22F7E2000CCCE0 /* Components */, + 842E40A32F22F7E2000CCCE0 /* PresetsTrainingView.swift */, + 842E40A52F22F7E2000CCCE0 /* PresetsTrainingContent.swift */, + ); + path = Training; + sourceTree = ""; + }; 84AA81D12A4A2778000B658B /* Components */ = { isa = PBXGroup; children = ( @@ -2552,56 +2496,21 @@ 84E8BBAF2CC979300078E6CF /* Presets */ = { isa = PBXGroup; children = ( - C1620D382DE0E50D0033DEB5 /* NoticeView.swift */, - C11445B32DB2EBDC00034864 /* ExistingPresetInsulinNeedsEdit.swift */, C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */, - C105095A2D78D32C00118A37 /* CreatePresetNameAndScheduledEdit.swift */, - C1AC03952D6E07D3004D4D2B /* CreatePresetView.swift */, - C14F68C82D4AC54000BC3B8D /* DurationPickerView.swift */, - C105096E2D8237EF00118A37 /* EditPresetView.swift */, - C1AC039B2D6E751C004D4D2B /* ExistingPresetRangeEdit.swift */, - C1AC03992D6E3C7B004D4D2B /* InsulinScaleInformationView.swift */, - C1AC039F2D6FCB2C004D4D2B /* NewCustomPreset.swift */, - C1AC039D2D6FC8BB004D4D2B /* NewPresetRangeEdit.swift */, - C16971F82D1231AB001B7DF6 /* PresetRangeEditor.swift */, 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */, 84E8BBC92CCA16290078E6CF /* PresetsView.swift */, - 84DEF35C2E566757006126F9 /* PresetSymbolView.swift */, - C105095E2D7A310B00118A37 /* ReviewNewPresetView.swift */, + 842E40A62F22F7E2000CCCE0 /* Training */, 84E8BBC22CC9B9780078E6CF /* Components */, - 84E8BBB62CC990480078E6CF /* Training Content */, ); path = Presets; sourceTree = ""; }; - 84E8BBB62CC990480078E6CF /* Training Content */ = { - isa = PBXGroup; - children = ( - 84475E082E5ECD3400FC5E7C /* Components */, - 849E06222E5E41BA00A71614 /* PresetsTrainingView.swift */, - 84475DEF2E5E644D00FC5E7C /* PresetsTraining.swift */, - 84475DF12E5E64A700FC5E7C /* PresetsTrainingContent.swift */, - ); - path = "Training Content"; - sourceTree = ""; - }; 84E8BBC22CC9B9780078E6CF /* Components */ = { isa = PBXGroup; children = ( 847F23422E4543140035C864 /* ActivePresetBanner.swift */, - 84E8BBC32CC9B9800078E6CF /* AdjustedGlucoseRangeView.swift */, - C105095C2D7A1DB300118A37 /* CardSection.swift */, - C10509602D7B3DEA00118A37 /* CardSectionScrollView.swift */, - C10509642D7B6B0000118A37 /* CorrectionRangePreview.swift */, - C105096C2D80E22C00118A37 /* DayPickerPopup.swift */, 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */, - C16E34C22E94442E00581D20 /* InsulinNeedsAdjustmentPreview.swift */, - C10509662D7F7A3700118A37 /* InsulinScaleAdjustView.swift */, - 84E8BBC52CC9BF830078E6CF /* PercentPickerView.swift */, - 84C170EE2CCA37680098E52F /* PresetCard.swift */, 84D1F1A82D09800700CB271F /* PresetDetentView.swift */, - 84F20DFA2D0A56CB0089DF02 /* PresetStatsView.swift */, - C10509702D84A80500118A37 /* RepeatOptionsView.swift */, ); path = Components; sourceTree = ""; @@ -3555,12 +3464,9 @@ C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */, 4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */, 4311FB9B1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift in Sources */, - C1AC03962D6E07D6004D4D2B /* CreatePresetView.swift in Sources */, C1F2075C26D6F9B0007AB7EB /* AppExpirationAlerter.swift in Sources */, B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */, - C1AC03A02D6FCB2F004D4D2B /* NewCustomPreset.swift in Sources */, 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */, - C14F68C92D4AC54300BC3B8D /* DurationPickerView.swift in Sources */, 14C9706E2C5A83AF00E8A01B /* IOBChartView.swift in Sources */, E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */, B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */, @@ -3569,7 +3475,6 @@ A9B996F227238705002DC09C /* DosingDecisionStore.swift in Sources */, 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */, B4D904412AA8989100CBD826 /* StatefulPluginManager.swift in Sources */, - C10C57F82E7085D600A4825C /* PresetSymbolView.swift in Sources */, E9B355292935919E0076AB04 /* MissedMealSettings.swift in Sources */, 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, @@ -3588,34 +3493,22 @@ 14C970802C5C0A1500E8A01B /* FavoriteFoodInsightsViewModel.swift in Sources */, 149A28BB2A853E5100052EDF /* CarbEntryViewModel.swift in Sources */, C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */, - C1AC039C2D6E7551004D4D2B /* ExistingPresetRangeEdit.swift in Sources */, - 84475E0C2E5EDF1800FC5E7C /* InsetContent.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, - 84475E0A2E5ECD4F00FC5E7C /* EstimatedReadTime.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */, - 84E8BBC62CC9BF830078E6CF /* PercentPickerView.swift in Sources */, - 84475E0E2E5F00B900FC5E7C /* TimelineSteps.swift in Sources */, B43B5C522EAFB1BE0096A6AE /* InsulinSuspendedTableViewCell.swift in Sources */, - C1DCEE452EB16662001A7BB0 /* WarningPanel.swift in Sources */, - 84F20DFB2D0A56CB0089DF02 /* PresetStatsView.swift in Sources */, 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, - C16971F92D1231B5001B7DF6 /* PresetRangeEditor.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, - 84C77CF22E6A054B00839FEC /* PlayMediaButton.swift in Sources */, - 849E06232E5E41BA00A71614 /* PresetsTrainingView.swift in Sources */, 84F20DFD2D0B9C3A0089DF02 /* EditPresetDurationView.swift in Sources */, 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */, 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */, C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */, - C105095B2D78D35100118A37 /* CreatePresetNameAndScheduledEdit.swift in Sources */, 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */, 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */, 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */, A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */, - 84475DF22E5E64A700FC5E7C /* PresetsTrainingContent.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, C16F51192B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift in Sources */, 1452F4AB2A851EDF00F8B9E4 /* FavoriteFoodAddEditView.swift in Sources */, @@ -3642,7 +3535,6 @@ 437CEEE41CDE5C0A003C8C80 /* UIImage.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 */, C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, @@ -3650,7 +3542,6 @@ 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, 843F32EA2E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift in Sources */, 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */, - C1AC039E2D6FC8C8004D4D2B /* NewPresetRangeEdit.swift in Sources */, 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */, B455C7332BD14E25002B847E /* Comparable.swift in Sources */, A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */, @@ -3667,7 +3558,6 @@ C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */, E98A55EF24EDD6E60008715D /* DosingDecisionStoreProtocol.swift in Sources */, B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */, - 840BB39D2E67796D00537FFB /* CommonUseStep.swift in Sources */, E9C00EF524C623EF00628F35 /* LoopSettings+Loop.swift in Sources */, 4389916B1E91B689000EEF90 /* ChartSettings+Loop.swift in Sources */, C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, @@ -3675,14 +3565,21 @@ B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */, 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */, DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, - C105095D2D7A1DB700118A37 /* CardSection.swift in Sources */, + 842E40A72F22F7E2000CCCE0 /* TintedContent.swift in Sources */, + 842E40A92F22F7E2000CCCE0 /* EstimatedReadTime.swift in Sources */, + 842E40AA2F22F7E2000CCCE0 /* PresetsTrainingView.swift in Sources */, + 842E40AB2F22F7E2000CCCE0 /* IntensitySlider.swift in Sources */, + 842E40AD2F22F7E2000CCCE0 /* PresetsTrainingContent.swift in Sources */, + 842E40AE2F22F7E2000CCCE0 /* PlayMediaButton.swift in Sources */, + 842E40AF2F22F7E2000CCCE0 /* PresetsTrainingCard.swift in Sources */, + 842E40B12F22F7E2000CCCE0 /* CommonUseStep.swift in Sources */, + 842E40B22F22F7E2000CCCE0 /* TherapySettingsExampleView.swift in Sources */, A9CBE458248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift in Sources */, 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */, 4374B5EF209D84BF00D17AA8 /* OSLog.swift in Sources */, 1DB619AC270BAD3D006C9D07 /* VersionUpdateViewModel.swift in Sources */, A9C62D882331703100535612 /* Service.swift in Sources */, 89CAB36324C8FE96009EE3CE /* PredictedGlucoseChartView.swift in Sources */, - C105096D2D80E23A00118A37 /* DayPickerPopup.swift in Sources */, DDC389F82A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift in Sources */, 847F23432E4543140035C864 /* ActivePresetBanner.swift in Sources */, 84BC5BDF2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift in Sources */, @@ -3701,8 +3598,6 @@ 89E267FF229267DF00A3F2AF /* Optional.swift in Sources */, 43785E982120E7060057DED1 /* Intents.intentdefinition in Sources */, 1D82E6A025377C6B009131FB /* TrustedTimeChecker.swift in Sources */, - C1620D392DE0E5120033DEB5 /* NoticeView.swift in Sources */, - C105096F2D8237F300118A37 /* EditPresetView.swift in Sources */, A98556852493F901000FD662 /* AlertStore+SimulatedCoreData.swift in Sources */, 899433B823FE129800FA4BEA /* OverrideBadgeView.swift in Sources */, 89D1503E24B506EB00EDE253 /* Dictionary.swift in Sources */, @@ -3713,7 +3608,6 @@ 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */, 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */, 14C9707E2C5A9EB600E8A01B /* GlucoseCarbChartView.swift in Sources */, - C10509712D84A80900118A37 /* RepeatOptionsView.swift in Sources */, 43E93FB61E469A4000EAB8DB /* NumberFormatter.swift in Sources */, 14C9706C2C5A836000E8A01B /* DoseChartView.swift in Sources */, C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */, @@ -3723,10 +3617,8 @@ C16F511B2B89363A00EFD7A1 /* SimpleInsulinDose.swift in Sources */, 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */, - C11445B42DB2EBE400034864 /* ExistingPresetInsulinNeedsEdit.swift in Sources */, E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, - C10509612D7B3DF400118A37 /* CardSectionScrollView.swift in Sources */, C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, C10509772D8B591200118A37 /* StatusTableViewModel.swift in Sources */, 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */, @@ -3734,7 +3626,6 @@ DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */, 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */, - C105095F2D7A311200118A37 /* ReviewNewPresetView.swift in Sources */, 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */, A9B607B0247F000F00792BE4 /* UserNotifications+Loop.swift in Sources */, 43F89CA322BDFBBD006BB54E /* UIActivityIndicatorView.swift in Sources */, @@ -3745,13 +3636,10 @@ A9CBE45C248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift in Sources */, 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */, - 84C77CF42E6A17FB00839FEC /* IntensitySlider.swift in Sources */, - 8443566B2E6F8325000EBD1A /* TintedContent.swift in Sources */, C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */, 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */, 439A7944211FE22F0041B75F /* NSUserActivity.swift in Sources */, 4328E0331CFC091100E199AA /* WatchContext+LoopKit.swift in Sources */, - C16E34C32E94443300581D20 /* InsulinNeedsAdjustmentPreview.swift in Sources */, 4F526D611DF8D9A900A04910 /* NetBasal.swift in Sources */, C1DE5D23251BFC4D00439E49 /* SimpleBolusView.swift in Sources */, 84213C8B2D94948900642E78 /* InsulinDeliveryOverview.swift in Sources */, @@ -3759,17 +3647,13 @@ 436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */, 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */, 14ED83F62C6421F9008B4A5C /* FavoriteFoodInsightsCardView.swift in Sources */, - C10509652D7B6B1900118A37 /* CorrectionRangePreview.swift in Sources */, C174233C259BEB0F00399C9D /* ManualEntryDoseViewModel.swift in Sources */, - 84E8BBC82CC9D34B0078E6CF /* TherapySettingsExampleView.swift in Sources */, 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, - 84475E102E5F870800FC5E7C /* PresetsTrainingCard.swift in Sources */, 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */, C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */, A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */, - C10509672D7F7A4900118A37 /* InsulinScaleAdjustView.swift in Sources */, DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */, 4F70C2101DE8FAC5006380B7 /* ExtensionDataManager.swift in Sources */, 1455ACB22C667BEE004F44F2 /* Deeplink.swift in Sources */, @@ -3787,20 +3671,16 @@ A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */, 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */, 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, - 84475DF02E5E644D00FC5E7C /* PresetsTraining.swift in Sources */, 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, 84A7B5502D2D972C00B6D202 /* Image+Optional.swift in Sources */, 8968B1122408B3520074BB48 /* UIFont.swift in Sources */, - 84E8BBC42CC9B9890078E6CF /* AdjustedGlucoseRangeView.swift in Sources */, - 84C170EF2CCA37680098E52F /* PresetCard.swift in Sources */, 1452F4A92A851C9400F8B9E4 /* FavoriteFoodAddEditViewModel.swift in Sources */, 438D42FB1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift in Sources */, C1C660D1252E4DD5009B5C32 /* LoopConstants.swift in Sources */, 432E73CB1D24B3D6009AD15D /* RemoteDataServicesManager.swift in Sources */, 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */, B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */, - C1AC039A2D6E3C88004D4D2B /* InsulinScaleInformationView.swift in Sources */, 43C2FAE11EB656A500364AFF /* GlucoseEffectVelocity.swift in Sources */, 14C970862C5C358C00E8A01B /* FavoriteFoodInsightsChartsView.swift in Sources */, ); @@ -3909,7 +3789,6 @@ C1ED6C822E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */, E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */, C1ED6C6D2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */, - C10C57F32E70851F00A4825C /* SelectablePreset.swift in Sources */, C1ED6C772E7C6F58002F91C2 /* WatchPredictedGlucose.swift in Sources */, C1D0B6312986D4D90098D215 /* LocalizedString.swift in Sources */, C1ED6C662E7C6E2F002F91C2 /* CarbBackfillRequestUserInfo.swift in Sources */, @@ -3947,7 +3826,6 @@ C1ED6C6C2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */, E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */, C1ED6C762E7C6F58002F91C2 /* WatchPredictedGlucose.swift in Sources */, - C10C57F22E70851F00A4825C /* SelectablePreset.swift in Sources */, C1D0B6302986D4D90098D215 /* LocalizedString.swift in Sources */, C1ED6C722E7C6F23002F91C2 /* SupportedBolusVolumesUserInfo.swift in Sources */, 43C05CB921EBEA54006FB252 /* HKUnit.swift in Sources */, diff --git a/Loop/Extensions/Character+IsEmoji.swift b/Loop/Extensions/Character+IsEmoji.swift deleted file mode 100644 index 888c583ef3..0000000000 --- a/Loop/Extensions/Character+IsEmoji.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// 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.isEmojiPresentation }) - } -} diff --git a/Loop/Views/Presets/Components/AdjustedGlucoseRangeView.swift b/Loop/Views/Presets/Components/AdjustedGlucoseRangeView.swift deleted file mode 100644 index 40b100e62a..0000000000 --- a/Loop/Views/Presets/Components/AdjustedGlucoseRangeView.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// AdjustedGlucoseRangeView.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopAlgorithm -import LoopKitUI -import SwiftUI - -struct AdjustedGlucoseRangeView: View { - - @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - - @State var lowerBound: LoopQuantity - @State var upperBound: LoopQuantity - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 8) - .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) - .frame(maxWidth: .infinity) - - VStack(spacing: 0) { - Text("Adjusted Range") - .font(.subheadline) - .padding(.bottom, 4) - - Group { - Text(displayGlucosePreference.format(lowerBound, includeUnit: false)).foregroundColor(.accentColor) + - Text("-").foregroundColor(.secondary).fontWeight(.light) + - Text(displayGlucosePreference.format(upperBound, includeUnit: false)).foregroundColor(.accentColor) - } - .font(.system(size: UIFontMetrics.default.scaledValue(for: 42), weight: .semibold)) - - Text(displayGlucosePreference.unit.localizedUnitString(in: .medium) ?? displayGlucosePreference.unit.unitString) - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.vertical, 10) - .padding(.horizontal, 16) - } - } -} diff --git a/Loop/Views/Presets/Components/CardSection.swift b/Loop/Views/Presets/Components/CardSection.swift deleted file mode 100644 index cc16d02e99..0000000000 --- a/Loop/Views/Presets/Components/CardSection.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// CardSection.swift -// Loop -// -// Created by Pete Schwamb on 3/6/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -// Simple rounded card view with arbitrary content. Can be used to make screens that look like grouped section table views, -// that need to animate height (List/TableViews have problems with resizing views and animating them). Similar to Card in -// LoopKitUI, but unlike Card, has requirement on the type of content except that it is a View. - -struct CardSection: View { - let header: Header? - let footer: Footer? - let content: Content - - let borderColor: Color - - // Initializer for custom view header - init(borderColor: Color = .clear, @ViewBuilder content: () -> Content, @ViewBuilder header: () -> Header, @ViewBuilder footer: () -> Footer) { - self.borderColor = borderColor - self.content = content() - self.header = header() - self.footer = footer() - } - - // Initializer for string header - init(_ headerText: String? = nil, borderColor: Color = .clear, @ViewBuilder content: () -> Content, footerText: String? = nil) where Header == Text, Footer == Text { - self.borderColor = borderColor - self.content = content() - self.header = headerText.map { Text($0) } - self.footer = footerText.map { Text($0) } - } - - // Initializer for no header - init(borderColor: Color = .clear, @ViewBuilder content: () -> Content) where Header == Text, Footer == Text { - self.borderColor = borderColor - self.content = content() - self.header = nil - self.footer = nil - } - - var body: some View { - VStack(alignment: .leading) { - if let header = header { - header - .font(.footnote) - .textCase(.uppercase) - .foregroundStyle(.secondary) - } - VStack { - content - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(RoundedRectangle(cornerRadius: 10) - .fill(Color(UIColor.tertiarySystemBackground)) - .stroke(borderColor, lineWidth: borderColor == .clear ? 0 : 1) - .frame(maxWidth: .infinity)) - if let footer = footer { - footer - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.leading) - } - } - .padding(.top, 10) - } -} - diff --git a/Loop/Views/Presets/Components/CardSectionScrollView.swift b/Loop/Views/Presets/Components/CardSectionScrollView.swift deleted file mode 100644 index 4c8b526166..0000000000 --- a/Loop/Views/Presets/Components/CardSectionScrollView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// CardSectionScrollView.swift -// Loop -// -// Created by Pete Schwamb on 3/7/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -// Container designed to hold CardSection views in a scrollview, and an optional action area -// that the scrollview would flow under, with a shadow effect. Together, they replace a List (TableView) -// with grouped styling, and allow rows to have their height animated as expected, avoiding the animation -// issues that resizing rows in Lists presents. - -import SwiftUI - -struct CardSectionScrollView: View { - let content: Content - let actionArea: ActionArea? - - // Initializer for custom view header - init(@ViewBuilder content: () -> Content, @ViewBuilder actionArea: () -> ActionArea) { - self.content = content() - self.actionArea = actionArea() - } - - // Initializer for no action area - init(@ViewBuilder content: () -> Content) where ActionArea == Text { - self.content = content() - self.actionArea = nil - } - - var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading) { - content - } - .padding() - } - if let actionArea { - VStack(spacing: 12) { - actionArea - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(.secondarySystemGroupedBackground).shadow(radius: 5)) - } - } - .background(Color(.systemGroupedBackground)) - .edgesIgnoringSafeArea(actionArea != nil ? .bottom : []) - } -} diff --git a/Loop/Views/Presets/Components/CorrectionRangePreview.swift b/Loop/Views/Presets/Components/CorrectionRangePreview.swift deleted file mode 100644 index 89ddb89120..0000000000 --- a/Loop/Views/Presets/Components/CorrectionRangePreview.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// CorrectionRangePreview.swift -// Loop -// -// Created by Pete Schwamb on 3/7/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopAlgorithm -import LoopKit -import LoopKitUI - -public struct CorrectionRangePreview: View { - @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - @Environment(\.guidanceColors) private var guidanceColors - @Environment(\.appName) private var appName - - private var range: ClosedRange? - private var guardrail: Guardrail - private var scheduledRange: ClosedRange - private var showDisclosure: Bool - private var veryHighInsulinNeeds: Bool - - init(range: ClosedRange?, guardrail: Guardrail, scheduledRange: ClosedRange, veryHighInsulinNeeds: Bool, showDisclosure: Bool = false) { - self.range = range - self.guardrail = guardrail - self.scheduledRange = scheduledRange - self.veryHighInsulinNeeds = veryHighInsulinNeeds - self.showDisclosure = showDisclosure - } - - func boundText(for bound: LoopQuantity) -> Text { - let color = guardrail.color(for: bound, guidanceColors: guidanceColors) - let text = displayGlucosePreference.format(bound, includeUnit: false) - switch guardrail.classification(for: bound) { - case .withinRecommendedRange: - return Text(text) - .foregroundColor(.accentColor) - .font(.system(size: 34, weight: .bold)) - case .outsideRecommendedRange: - return ( - Text(text) - .foregroundColor(color) - .font(.system(size: 34, weight: .bold)) - ) - } - } - - func correctionRangeLabel(range: ClosedRange) -> Text { - boundText(for: range.lowerBound) + - Text("-").foregroundColor(.secondary) - .font(.system(size: 34, weight: .light)) - + - boundText(for: range.upperBound) + - Text(" ") + - Text(displayGlucosePreference.unit.localizedShortUnitString) - .font(.system(.body)) - .foregroundColor(.secondary) - .baselineOffset(12) - } - - private var correctionRangeCrossedThresholds: [SafetyClassification.Threshold] { - guard let range else { return [] } - - let thresholds: [SafetyClassification.Threshold] = [range.lowerBound, range.upperBound].compactMap { bound in - switch guardrail.classification(for: bound) { - case .withinRecommendedRange: - return nil - case .outsideRecommendedRange(let threshold): - return threshold - } - } - - return thresholds - } - - var requiresHighInsulinNeedsMitigation: Bool { - if veryHighInsulinNeeds, let range { - return range.lowerBound < TemporaryScheduleOverride.highInsulinNeedsMitigationCorrectionRangeLimit - } - return veryHighInsulinNeeds - } - - var highInsulinNeedsWarningText: String { - String(format: NSLocalizedString("%1$@ will set your correction range to 110 mg/dL or higher when this preset is enabled.", comment: "The format string for the high insulin needs preset warning text on the preset preview screen. (1: app name)"), appName) - } - - private var guardrailWarningIfNecessary: some View { - let crossedThresholds = self.correctionRangeCrossedThresholds - - return Group { - if !crossedThresholds.isEmpty { - WarningPanel(severity: crossedThresholds.map { $0.severity }.max()!) { - Text(SafetyClassification.captionForCrossedThresholds(crossedThresholds, isRange: true)) - .accessibilityIdentifier("text_CorrectionRangeWarning"); - } - } else if requiresHighInsulinNeedsMitigation { - WarningPanel { - Text(highInsulinNeedsWarningText) - } - } - } - } - - public var body: some View { - VStack(alignment: .center, spacing: 12) { - HStack { - Text("Correction Range") - .font(.headline) - Spacer() - if showDisclosure { - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } - }.padding(.bottom, 10) - VStack(spacing: 4) { - if let range { - correctionRangeLabel(range: range).accessibilityIdentifier("text_CorrectionRangePreview") - Text("Adjusted Range") - } else { - correctionRangeLabel(range: scheduledRange).accessibilityIdentifier("text_CorrectionRangePreview") - Text("Scheduled Range") - } - } - guardrailWarningIfNecessary - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.primary) - .padding(.bottom, 5) - .padding(.horizontal, 2) - } - .foregroundColor(.primary) - } -} diff --git a/Loop/Views/Presets/Components/DayPickerPopup.swift b/Loop/Views/Presets/Components/DayPickerPopup.swift deleted file mode 100644 index 2953aa39e9..0000000000 --- a/Loop/Views/Presets/Components/DayPickerPopup.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// DayPickerPopup.swift -// Loop -// -// Created by Pete Schwamb on 3/11/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopKit - -struct DayPickerPopup: View { - @Binding var selectedDays: PresetScheduleRepeatOptions - - var body: some View { - VStack(spacing: 10) { - Text("Select Days") - .font(.headline) - - ForEach(PresetScheduleRepeatOptions.allCases, id: \.rawValue) { day in - MultipleSelectionRow( - day: day, - isSelected: selectedDays.contains(day) - ) { - toggleDay(day) - } - } - } - .padding() - .frame(width: 250) - } - - private func toggleDay(_ day: PresetScheduleRepeatOptions) { - if selectedDays.contains(day) { - selectedDays.remove(day) - } else { - selectedDays.insert(day) - } - } -} - -// Selection row (unchanged) -struct MultipleSelectionRow: View { - let day: PresetScheduleRepeatOptions - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack { - Text(day.description) - Spacer() - if isSelected { - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - } - } - .padding(.vertical, 4) - } - .foregroundColor(.primary) - } -} diff --git a/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift b/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift deleted file mode 100644 index 13a1ff708a..0000000000 --- a/Loop/Views/Presets/Components/InsulinNeedsAdjustmentPreview.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// InsulinNeedsAdjustmentPreview.swift -// Loop -// -// Created by Pete Schwamb on 10/6/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopAlgorithm -import LoopKit -import LoopKitUI - -public struct InsulinNeedsAdjustmentPreview: View { - @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - @Environment(\.guidanceColors) private var guidanceColors - - var range: ClosedRange? - var guardrail: Guardrail - private var insulinPercentage: Double - var showDisclosure: Bool - - init(insulinPercentage: Double, guardrail: Guardrail, showDisclosure: Bool = false) { - self.insulinPercentage = insulinPercentage - self.guardrail = guardrail - self.showDisclosure = showDisclosure - } - - var valueColor: Color { - switch Guardrail.presetInsulinNeeds.classification(for: .init(unit: .percent, doubleValue: insulinPercentage)) { - case .withinRecommendedRange: - return .accentColor - case .outsideRecommendedRange(let threshold): - switch threshold { - case .minimum, .maximum: - return guidanceColors.critical - case .belowWarning, .aboveWarning: - return guidanceColors.critical - case .belowRecommended, .aboveRecommended: - return guidanceColors.warning - } - } - } - - private var guardrailWarningIfNecessary: some View { - - let classification = Guardrail.presetInsulinNeeds.classification(for: .init(unit: .percent, doubleValue: insulinPercentage)) - - return Group { - if case .outsideRecommendedRange(let threshold) = classification { - WarningPanel(severity: threshold.severity) { - Text(SafetyClassification.captionForCrossedThresholds([threshold], isRange: true)) - .accessibilityIdentifier("text_InsulinNeedsWarning"); - } - } - } - } - - public var body: some View { - VStack(alignment: .center, spacing: 8) { - HStack { - Text("Overall Insulin") - .font(.headline) - Spacer() - if showDisclosure { - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } - }.padding(.bottom, 10) - Text("\(Int(insulinPercentage))%") - .font(.system(size: 34, weight: .semibold)) - .foregroundColor(valueColor) - Text("of scheduled") - guardrailWarningIfNecessary - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.primary) - .padding(.bottom, 5) - .padding(.horizontal, 2) - } - .foregroundColor(.primary) - } -} diff --git a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift b/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift deleted file mode 100644 index a3fb2bd82e..0000000000 --- a/Loop/Views/Presets/Components/InsulinScaleAdjustView.swift +++ /dev/null @@ -1,220 +0,0 @@ -// -// InsulinScaleAdjustView.swift -// Loop -// -// Created by Pete Schwamb on 3/10/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopAlgorithm -import LoopKit -import LoopKitUI - -public struct InsulinScaleAdjustView: View { - @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference - @Environment(\.guidanceColors) private var guidanceColors - @Environment(\.settingsManager) private var settingsManager - - @State private var presentInfoView: Bool = false - - @Binding var insulinMultiplier: Double - let guardrail: Guardrail - - var insulinPercentage: Double { - (insulinMultiplier * 100).rounded() - } - - public var body: some View { - // Header Section - VStack(spacing: 16) { - HStack { - Text("Overall Insulin Needs") - .foregroundColor(.secondary) - .font(.subheadline) - .padding(.vertical) - - Button(action: { - presentInfoView = true; - }) { - Image(systemName: "info.circle") - } - .buttonStyle(BorderlessButtonStyle()) - } - .padding(.top, -5) - - - Text("Set your overall insulin needs") - .font(.title2) - .fontWeight(.bold) - - Text("Use the + and - buttons to set whether you need") + - Text(" more ").fontWeight(.bold) + - Text("or") + - Text(" less ").fontWeight(.bold) + - Text("insulin than usual.") - - adjustInsulinControls - - Divider() - - settingsImpact - - } - .multilineTextAlignment(.center) - .sheet(isPresented: $presentInfoView) { - InsulinScaleInformationView() - } - } - - var valueColor: Color { - switch Guardrail.presetInsulinNeeds.classification(for: .init(unit: .percent, doubleValue: insulinPercentage)) { - case .withinRecommendedRange: - return .insulin - case .outsideRecommendedRange(let threshold): - switch threshold { - case .minimum, .maximum: - return guidanceColors.critical - case .belowWarning, .aboveWarning: - return guidanceColors.critical - case .belowRecommended, .aboveRecommended: - return guidanceColors.warning - } - } - } - - private var adjustInsulinControls: some View { - HStack(spacing: 24) { - Button(action: { - decreaseInsulinMultiplier() - }) { - Text(Image(systemName: "minus.circle.fill").symbolRenderingMode(.hierarchical)) - .font(.system(size: 44, weight: .bold)) - .foregroundColor(.insulin) - } - .buttonStyle(BorderlessButtonStyle()) - - - Text("\(Int(insulinPercentage))%") - .font(.system(size: 50, weight: .bold)) - .foregroundColor(valueColor) - - Button(action: { - increaseInsulinMultiplier() - }) { - Text(Image(systemName: "plus.circle.fill").symbolRenderingMode(.hierarchical)) - .font(.system(size: 44, weight: .bold)) - .foregroundColor(.insulin) - } - .buttonStyle(BorderlessButtonStyle()) - } - } - - private func decreaseInsulinMultiplier() { - if insulinPercentage > guardrail.absoluteBounds.lowerBound.doubleValue(for: .percent) { - insulinMultiplier = insulinPercentage.snap(direction: .down) / 100 - } - } - - private func increaseInsulinMultiplier() { - if insulinPercentage < guardrail.absoluteBounds.upperBound.doubleValue(for: .percent) { - insulinMultiplier = insulinPercentage.snap(direction: .up) / 100 - } - } - - private var settingsImpact: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 6) { - Text("Settings Impact") - .font(.headline) - - if insulinPercentage < 100 { - Text("This adjustment will make your settings weaker.") - .fixedSize(horizontal: false, vertical: true) - } else if (insulinPercentage > 100) { - Text("This adjustment will make your settings stronger.") - .fixedSize(horizontal: false, vertical: true) - } else { - Text("No change to insulin settings.") - } - } - - exampleSettings - - // Footer Note - Text("Note: These example values are based on your current settings. Values may be different when you enable the preset.") - .font(.footnote) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .font(.subheadline) - .multilineTextAlignment(.leading) - } - - private var sensitivityUnit: LoopUnit { - switch displayGlucosePreference.unit { - case .milligramsPerDeciliter: - return .milligramsPerDeciliterPerInternationalUnit - case .millimolesPerLiter: - return .millimolesPerLiterPerInternationalUnit - default: - fatalError() - } - } - - - private var exampleSettings: some View { - Group { - let impact = settingsManager.therapySettings.impact(for: insulinMultiplier) - if let basalRate = impact.basalRate, let carbRatio = impact.carbRatio, let isf = impact.isf { - HStack(spacing: 0) { - SettingAdjustmentPreview( - value: basalRate, - displayUnit: .internationalUnitsPerHour, - name: "Basal Rate", - highlighted: insulinPercentage != 100 - ) - - Spacer() - - SettingAdjustmentPreview( - value: carbRatio, - displayUnit: .gramsPerUnit, - name: "Carb Ratio", - highlighted: insulinPercentage != 100 - ) - - Spacer() - - SettingAdjustmentPreview( - value: isf, - displayUnit: displayGlucosePreference.unit.unitDivided(by: .internationalUnit), - name: "ISF", - highlighted: insulinPercentage != 100 - ) - } - } - } - } -} - -extension Double { - enum StepDirection { case up, down } - - func snap(step: Double = 5, direction: StepDirection) -> Double { - let value = self / step - let tolerance = 1e-9 // Smooths out floating point math quirks - let isExactMultiple = abs(value.rounded() - value) < tolerance - - if isExactMultiple { - return direction == .up ? self + step : self - step - } else { - switch direction { - case .up: - return ceil(value) * step - case .down: - return floor(value) * step - } - } - } -} diff --git a/Loop/Views/Presets/Components/PercentPickerView.swift b/Loop/Views/Presets/Components/PercentPickerView.swift deleted file mode 100644 index da040a45d0..0000000000 --- a/Loop/Views/Presets/Components/PercentPickerView.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// PercentPickerView.swift -// Loop -// -// Created by Cameron Ingham on 10/23/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -struct PercentPickerView: View { - - @Binding var value: Int - - let range: ClosedRange - let stepCount: Int - let disabled: Bool - - let numberFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .none - return formatter - }() - - init(value: Binding, range: ClosedRange = 0...300, stepCount: Int = 5) { - self._value = value - self.range = range - self.stepCount = stepCount - self.disabled = false - } - - init(value: Int, range: ClosedRange = 0...300, stepCount: Int = 5) { - self._value = .constant(value) - self.range = range - self.stepCount = stepCount - self.disabled = true - } - - var downButton: some View { - Button { - withAnimation { - if value - stepCount <= range.lowerBound { - value = range.lowerBound - } else { - value = value - stepCount - } - } - } label: { - Text(Image(systemName: "minus.circle.fill").symbolRenderingMode(.hierarchical)).font(.system(size: UIFontMetrics.default.scaledValue(for: 40), weight: .semibold)) - } - .buttonStyle(PickerButtonStyle(disabled: disabled)) - } - - var valueText: some View { - Text("\(numberFormatter.string(from: Double(value)) ?? "100")%") - .font(.system(size: UIFontMetrics.default.scaledValue(for: 50), weight: .semibold).monospacedDigit()) - .contentTransition(.numericText()) - } - - var upButton: some View { - Button { - withAnimation { - if value + stepCount >= range.upperBound { - value = range.upperBound - } else { - value = value + stepCount - } - } - } label: { - Text(Image(systemName: "plus.circle.fill").symbolRenderingMode(.hierarchical)).font(.system(size: UIFontMetrics.default.scaledValue(for: 40), weight: .semibold)) - } - .buttonStyle(PickerButtonStyle(disabled: disabled)) - } - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 8) - .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) - .frame(maxWidth: .infinity) - - ViewThatFits(in: .horizontal) { - HStack(spacing: 16) { - downButton - - valueText - - upButton - } - - VStack(spacing: 0) { - valueText - - HStack(spacing: 32) { - downButton - - upButton - } - } - } - .foregroundColor(.accentColor) - .padding(.vertical, 10) - .padding(.horizontal, 16) - } - } -} - -private struct PickerButtonStyle: ButtonStyle { - let disabled: Bool - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .scaleEffect(configuration.isPressed && !disabled ? 1.15 : 1) - } -} diff --git a/Loop/Views/Presets/Components/PresetCard.swift b/Loop/Views/Presets/Components/PresetCard.swift deleted file mode 100644 index 3560c1d4d8..0000000000 --- a/Loop/Views/Presets/Components/PresetCard.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// PresetCard.swift -// Loop -// -// Created by Cameron Ingham on 10/24/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopAlgorithm -import LoopKitUI -import SwiftUI -import LoopKit -import LoopCore - -struct PresetCard: View { - - @Environment(\.colorPalette) private var colorPalette - @Environment(\.isEnabled) private var isEnabled - @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager - @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - - let presetId: String - let icon: PresetSymbol? - let presetName: String - let duration: PresetDuration - let insulinMultiplier: Double? - let correctionRange: ClosedRange? - let guardrail: Guardrail? - let expectedEndTime: PresetExpectedEndTime? - let isScheduled: Bool - let activityPresetIsModified: Bool? - - var presetTitle: some View { - HStack(spacing: 6) { - if let icon, !icon.isEmpty { - PresetSymbolView(icon) - } - - Text(presetName) - .fontWeight(.semibold) - .accessibilityIdentifier("text_Preset\(presetName)") - - if activityPresetIsModified == false { - Text(Image(systemName: "checkmark.seal.fill")) - .font(.subheadline) - .foregroundStyle(Color.accentColor) - } - } - } - - var reminderIcon: some View { - Text(Image(systemName: "alarm")) - .font(.footnote) - .foregroundColor(.carbs) - .accessibilityLabel(Text("Scheduled reminder")) - } - - var presetDuration: some View { - Group { Text(Image(systemName: "timer")) + Text(" \(duration.localizedTitle)") } - .font(.footnote) - .foregroundColor(.secondary) - .accessibilityLabel(Text(duration.accessibilityLabel)) - } - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - ViewThatFits(in: .horizontal) { - HStack { - VStack(alignment: .leading) { - if let expectedEndTime { - HStack(spacing: 8) { - Text(Image(systemName: "timer")) - + - Text(" \(expectedEndTime.localizedTitle)") - .accessibilityLabel(Text(expectedEndTime.accessibilityLabel)) - } - .font(.footnote) - .foregroundColor(.white) - .padding(.horizontal, 6) - .padding(.vertical, 5) - .background(Color.presets) - .cornerRadius(8) - } - presetTitle - } - - Spacer() - - if expectedEndTime == nil { - presetDuration - if isScheduled { - reminderIcon - } - } - } - - VStack(alignment: .leading, spacing: 10) { - presetTitle - - presetDuration - } - } - - Divider() - .padding(.horizontal, -10) - - PresetStatsView( - insulinMultiplier: insulinMultiplier, - correctionRange: correctionRange, - guardrail: guardrail, - therapySettingsImpactDisplayState: .hide, - isScheduled: isScheduled && expectedEndTime != nil, - isActive: temporaryPresetsManager.activePreset?.id == presetId - ) - } - .padding(10) - .background(RoundedRectangle(cornerRadius: 8) - .fill(Color(UIColor.tertiarySystemBackground)) - .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) - .frame(maxWidth: .infinity)) - .opacity(isEnabled ? 1 : 0.6) - } -} - -extension Color { - init(presetSymbolTint: PresetSymbol.SymbolTint?, palette: LoopUIColorPalette) { - guard let presetSymbolTint else { - self = .primary - return - } - - switch presetSymbolTint { - case .preMeal: - self = palette.carbTintColor - } - } -} diff --git a/Loop/Views/Presets/Components/PresetDetentView.swift b/Loop/Views/Presets/Components/PresetDetentView.swift index d07c7e619c..0c74ad9f03 100644 --- a/Loop/Views/Presets/Components/PresetDetentView.swift +++ b/Loop/Views/Presets/Components/PresetDetentView.swift @@ -176,7 +176,8 @@ struct PresetDetentView: View { guardrail: settingsManager.correctionRangeGuardrailForPreset(preset), therapySettingsImpactDisplayState: operation == .end ? .show(settingsImpact) : .hide, isScheduled: false, - isActive: temporaryPresetsManager.activePreset?.id == preset.id + isActive: temporaryPresetsManager.activePreset?.id == preset.id, + effectiveCorrectionRange: temporaryPresetsManager.effectiveCorrectionRange ) .padding(.horizontal) diff --git a/Loop/Views/Presets/Components/PresetStatsView.swift b/Loop/Views/Presets/Components/PresetStatsView.swift deleted file mode 100644 index ca142af5cc..0000000000 --- a/Loop/Views/Presets/Components/PresetStatsView.swift +++ /dev/null @@ -1,211 +0,0 @@ -// -// PresetStatsView.swift -// Loop -// -// Created by Cameron Ingham on 12/11/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import LoopAlgorithm -import LoopKit -import LoopKitUI -import SwiftUI - -struct PresetStatsView: View { - enum TherapySettingsImpactDisplayState { - case hide - case show(TherapySettings.InsulinMultiplierImpact) - } - - @Environment(\.guidanceColors) private var guidanceColors - @Environment(\.settingsManager) private var settingsManager - @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager - @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - - let insulinMultiplier: Double? - let correctionRange: ClosedRange? - let guardrail: Guardrail? - let therapySettingsImpactDisplayState: TherapySettingsImpactDisplayState - let isScheduled: Bool - let isActive: Bool - - private var numberFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .percent - return formatter - } - - private var insulinMultiplierSafetyClassification: SafetyClassification? { - guard let insulinMultiplier else { return nil } - return Guardrail.presetInsulinNeeds.classification(for: LoopQuantity(unit: .percent, doubleValue: insulinMultiplier * 100)) - } - - var overallInsulinView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Overall Insulin") - .font(.subheadline) - .foregroundColor(.secondary) - .accessibilitySortPriority(2) - - let percent = numberFormatter.string(from: insulinMultiplier ?? 1)! - let color = guidanceColor(for: insulinMultiplierSafetyClassification) ?? .primary - HStack(alignment: .top) { - if insulinMultiplierSafetyClassification != .withinRecommendedRange { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(color) - } - - Group { Text(percent).bold() + Text(" of scheduled") } - .font(.subheadline) - .accessibilitySortPriority(1) - .foregroundStyle(color) - } - } - .accessibilityElement(children: .contain) - } - - func guidanceColor(for classification: SafetyClassification?) -> Color? { - guard let classification else { return nil } - - switch classification { - case .outsideRecommendedRange(let threshold): - switch threshold { - case .aboveRecommended, .belowRecommended: - return guidanceColors.warning - case .aboveWarning, .belowWarning: - return guidanceColors.critical - case .maximum, .minimum: - return guidanceColors.critical - } - case .withinRecommendedRange: - return nil - } - } - - func annotatedRangeText(target: ClosedRange) -> some View { - let lowerColor = guardrail?.color(for: target.lowerBound, guidanceColors: guidanceColors) ?? .primary - let upperColor = guardrail?.color(for: target.upperBound, guidanceColors: guidanceColors) ?? .primary - - let units = Text(" \(displayGlucosePreference.unit.localizedUnitString(in: .medium) ?? displayGlucosePreference.unit.unitString)") - .foregroundStyle(upperColor) - let lower = Text(displayGlucosePreference.format(target.lowerBound, includeUnit: false)) - .foregroundStyle(lowerColor) - .bold() - let upper = Text(displayGlucosePreference.format(target.upperBound, includeUnit: false)) - .foregroundStyle(upperColor) - .bold() - let warningSymbol = Text("\(Image(systemName: "exclamationmark.triangle.fill"))") - - let lowerClassification = guardrail?.classification(for: target.lowerBound) ?? .withinRecommendedRange - let upperClassification = guardrail?.classification(for: target.upperBound) ?? .withinRecommendedRange - - var accessibilityId = "text_PresetCorrectionRange_" - - switch (lowerClassification, upperClassification) { - case (.withinRecommendedRange, .withinRecommendedRange): - accessibilityId += "WithinRange" - case (.withinRecommendedRange, .outsideRecommendedRange): - accessibilityId += "UpperWarning" - accessibilityId += upperColor == .red ? "Red" : "Orange" - case (.outsideRecommendedRange, .outsideRecommendedRange): - accessibilityId += "LowerWarning" - accessibilityId += lowerColor == .red ? "Red" : "Orange" - accessibilityId += "UpperWarning" - accessibilityId += upperColor == .red ? "Red" : "Orange" - case (.outsideRecommendedRange, .withinRecommendedRange): - accessibilityId += "LowerWarning" - accessibilityId += lowerColor == .red ? "Red" : "Orange" - } - - return Group { - switch (lowerClassification, upperClassification) { - case (.withinRecommendedRange, .withinRecommendedRange): - lower + Text(" - ") + upper + units - case (.withinRecommendedRange, .outsideRecommendedRange): - lower + Text(" - ") + warningSymbol.foregroundStyle(upperColor) + upper + units - case (.outsideRecommendedRange, .outsideRecommendedRange): - warningSymbol.foregroundStyle(lowerColor) + lower + Text("-").foregroundStyle(lowerColor) + warningSymbol.foregroundStyle(upperColor) + upper + units - case (.outsideRecommendedRange, .withinRecommendedRange): - warningSymbol.foregroundStyle(lowerColor) + lower + Text("-") + upper + units - } - }.accessibilityIdentifier(accessibilityId) - } - - var correctionRangeView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Correction Range") - .font(.subheadline) - .foregroundColor(.secondary) - .accessibilitySortPriority(2) - - Group { - if !isActive, let target = correctionRange { - annotatedRangeText(target: target) - } else if isActive, let range = temporaryPresetsManager.effectiveCorrectionRange() { - annotatedRangeText(target: range) - } else { - Text("Scheduled Range") - .bold() - } - } - .font(.subheadline) - .accessibilitySortPriority(1) - } - .accessibilityElement(children: .contain) - } - - var body: some View { - VStack(alignment: .leading, spacing: 24) { - ViewThatFits(in: .horizontal) { - HStack(spacing: 0) { - overallInsulinView - Spacer() - correctionRangeView - if isScheduled, !isActive { - Spacer() - Text(Image(systemName: "alarm")) - .font(.footnote) - .foregroundColor(.carbs) - .accessibilityLabel(Text("Scheduled reminder")) - } - } - - VStack(alignment: .leading, spacing: 16) { - overallInsulinView - correctionRangeView - } - } - - if case let .show(insulinMultiplierImpact) = therapySettingsImpactDisplayState, (insulinMultiplier ?? 1) != 1, let basalRate = insulinMultiplierImpact.basalRate, let carbRatio = insulinMultiplierImpact.carbRatio, let isf = insulinMultiplierImpact.isf { - VStack(alignment: .leading, spacing: 8) { - Text("Settings Impact") - .font(.subheadline) - .foregroundColor(.secondary) - - ViewThatFits(in: .horizontal) { - HStack(spacing: 0) { - SettingAdjustmentPreview(value: basalRate, displayUnit: .internationalUnitsPerHour, name: "Basal Rate", highlighted: false) - - Spacer() - - SettingAdjustmentPreview(value: carbRatio, name: "Carb Ratio", highlighted: false) - - Spacer() - - SettingAdjustmentPreview(value: isf, displayUnit: displayGlucosePreference.unit.unitDivided(by: .internationalUnit), name: "ISF", highlighted: false) - } - - VStack(alignment: .leading, spacing: 8) { - SettingAdjustmentPreview(value: basalRate, displayUnit: .internationalUnitsPerHour, name: "Basal Rate", highlighted: false) - - SettingAdjustmentPreview(value: carbRatio, name: "Carb Ratio", highlighted: false) - - SettingAdjustmentPreview(value: isf, displayUnit: displayGlucosePreference.unit.unitDivided(by: .internationalUnit), name: "ISF", highlighted: false) - } - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} diff --git a/Loop/Views/Presets/Components/RepeatOptionsView.swift b/Loop/Views/Presets/Components/RepeatOptionsView.swift deleted file mode 100644 index f7e96ea90d..0000000000 --- a/Loop/Views/Presets/Components/RepeatOptionsView.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// RepeatOptionsView.swift -// Loop -// -// Created by Pete Schwamb on 3/14/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// -import SwiftUI -import LoopKit - -struct RepeatOptionView: View { - let repeatOptions: PresetScheduleRepeatOptions - - @ScaledMetric var dayTextSize: Double = 12 - - private var selectedDays: [PresetScheduleRepeatOptions] { - PresetScheduleRepeatOptions.allCases.filter { repeatOptions.contains($0) } - } - - private var isSingleDay: Bool { - selectedDays.count == 1 - } - - var body: some View { - if repeatOptions == .none { - Text(repeatOptions.description) - .tint(.secondary) - } else if isSingleDay { - Text(selectedDays[0].description) - .foregroundColor(.secondary) - } else { - HStack(spacing: 4) { - ForEach(PresetScheduleRepeatOptions.allCases, id: \.rawValue) { day in - Text(String(day.veryShortDescription)) - .font(.system(size: dayTextSize)) - .frame(width: dayTextSize+8, height: dayTextSize+8) - .background( - Circle() - .fill(repeatOptions.contains(day) ? Color.accentColor : Color.gray.opacity(0.2)) - ) - .foregroundColor(repeatOptions.contains(day) ? .white : .gray) - } - } - } - } -} - diff --git a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift b/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift deleted file mode 100644 index 4acc6ea322..0000000000 --- a/Loop/Views/Presets/CreatePresetNameAndScheduledEdit.swift +++ /dev/null @@ -1,302 +0,0 @@ -// -// CreatePresetNameAndScheduledEdit.swift -// Loop -// -// Created by Pete Schwamb on 3/5/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - - -import LoopKit -import LoopKitUI -import SwiftUI - -enum RepeatOption: CaseIterable { - case never - case weekly -} - -extension RepeatOption: CustomStringConvertible { - var description: String { - switch self { - case .never: - NSLocalizedString( - "Never", - comment: "Repeat option never for a preset schedule" - ) - case .weekly: - NSLocalizedString( - "Weekly", - comment: "Repeat option weekly for a preset schedule" - ) - } - } -} - -struct CreatePresetNameAndScheduledEdit: View { - @Environment(\.dismiss) private var dismiss - - @Binding var preset: NewCustomPreset - @Binding var path: NavigationPath - - @State private var isDurationPickerExpanded = false - - @FocusState private var isTextFieldFocused: Bool - - @State private var selectedRepeatOption: RepeatOption - @State private var showingDayPicker: Bool = false - - var onCancel: () -> Void - - init( - preset: Binding, - path: Binding, - isDurationPickerExpanded: Bool = false, - onCancel: @escaping () -> Void - ) { - self._preset = preset - self._path = path - self.isDurationPickerExpanded = isDurationPickerExpanded - self.selectedRepeatOption = preset.wrappedValue.repeatOptions == .none ? .never : .weekly - self.onCancel = onCancel - } - - var body: some View { - CardSectionScrollView { - CardSection { - // Save Preset Toggle - HStack { - Text("Save Preset") - .font(.body) - - Spacer() - - Toggle("", isOn: $preset.savePreset.animation()) - .toggleStyle(SwitchToggleStyle(tint: .green)) - .labelsHidden() - .padding(.vertical, -6) - } - } - - Text("Toggle off for a single use preset") - .font(.footnote) - .foregroundColor(.secondary) - .padding(.horizontal, 10) - - // Name Field - if preset.savePreset { - CardSection { - HStack { - Text("Name") - .font(.body) - - Spacer() - - TextField("", text: $preset.name, prompt: Text("Required")) - .multilineTextAlignment(.trailing) - .focused($isTextFieldFocused) - .foregroundColor(.secondary) - } - } - } - - // Duration Section - CardSection { - VStack(alignment: .leading) { - HStack { - Text("Duration") - .foregroundColor(.primary) - Spacer() - Group { - if let duration = preset.duration { - Text(duration.localizedTitle) - Image(systemName: "chevron.right") - } else { - Text("Required") - .foregroundStyle(.placeholder) - } - } - .foregroundColor(.secondary) - } - .contentShape(Rectangle()) - .onTapGesture { - isTextFieldFocused = false - withAnimation() { - isDurationPickerExpanded.toggle() - } - } - - if isDurationPickerExpanded { - DurationPickerView( - durationType: Binding( - get: { - return preset.duration ?? .duration(.hours(1)) - }, - set: { duration in - preset.duration = duration - } - ) - ) - } - } - } - - // Schedule Toggle - if preset.savePreset { - CardSection { - HStack { - Text("Schedule") - .font(.body) - - Spacer() - - Toggle("", isOn: Binding(get: { - return preset.startDate != nil - }, set: { newValue in - withAnimation { - if newValue { - preset.startDate = Date().addingTimeInterval(.hours(1)) - } else { - preset.startDate = nil - preset.repeatOptions = .none - } - } - })) - .toggleStyle(SwitchToggleStyle(tint: .green)) - .labelsHidden() - .padding(.vertical, -4) - } - - if preset.startDate != nil { - Divider() - HStack { - if selectedRepeatOption == .never { - Text("Date") - } else { - Text("Start Date") - } - Spacer() - DatePicker( - "", - selection: Binding(get: { - preset.startDate ?? Date() - }, set: { newValue in - preset.startDate = newValue - }), - in: Date()..., - displayedComponents: [.date, .hourAndMinute] - ) - } - Divider() - .padding(.top, -4) - HStack { - Text("Repeat") - Spacer() - Picker("Repeat", selection: $selectedRepeatOption.animation()) { - ForEach(RepeatOption.allCases, id: \.self) { option in - Text(String(describing: option)) - } - } - .tint(.secondary) - .pickerStyle(MenuPickerStyle()) - .padding(.trailing, -8) - } - - if selectedRepeatOption == .weekly { - Divider() - .padding(.top, -4) - HStack { - Text("Selected days") - .foregroundColor(.primary) - HStack { - Spacer() - RepeatOptionView(repeatOptions: preset.repeatOptions) - .padding(.vertical, 6) - .onTapGesture { - withAnimation { - showingDayPicker = true - } - } - } - .popover(isPresented: $showingDayPicker, arrowEdge: .bottom) { - DayPickerPopup(selectedDays: Binding( - get: { - preset.repeatOptions - }, set: { newValue in - preset.repeatOptions = newValue.union(requiredRepeatOption ?? .none) - })) - .cornerRadius(12) - .presentationCompactAdaptation(.popover) - } - } - } - } - } - if preset.repeatOptions != .none { - Text(preset.scheduleDescription()) - .font(.footnote) - .foregroundColor(.secondary) - .padding(.horizontal, 10) - .padding(.top, 4) - } - } - } actionArea: { - Button("Continue") { - path.append(CreatePresetPage.summary) - } - .disabled(!allowSave) - .buttonStyle(ActionButtonStyle(.primary)) - } - .onChange(of: selectedRepeatOption, { oldValue, newValue in - if newValue == .weekly { - assignRepeatDays() - } - if newValue == .never { - preset.repeatOptions = .none - } - }) - .onChange(of: preset.startDate, { oldValue, newValue in - if newValue != nil { - assignRepeatDays() - } - }) - .animation(.easeInOut, value: preset.duration) - - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Create a Preset") - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Cancel") { - onCancel() - } - } - } - } - - private var requiredRepeatOption: PresetScheduleRepeatOptions? { - guard let startDate = preset.startDate else { return nil } - guard selectedRepeatOption == .weekly else { return nil } - return .allCases[Calendar.current.component(.weekday, from: startDate) - 1] - } - - func assignRepeatDays() { - guard let requiredRepeatOption else { - return - } - preset.repeatOptions = requiredRepeatOption - } - - var allowSave: Bool { - return (!preset.savePreset && preset.duration != nil) || (preset.savePreset && !preset.name.isEmpty && preset.duration != nil) - } -} - -// Preview Provider -struct PresetCreationView_Previews: PreviewProvider { - @State static var preset: NewCustomPreset = .init() - @State static var path: NavigationPath = .init() - - static var previews: some View { - CreatePresetNameAndScheduledEdit(preset: $preset, path: $path, onCancel: {}) - } -} diff --git a/Loop/Views/Presets/CreatePresetView.swift b/Loop/Views/Presets/CreatePresetView.swift deleted file mode 100644 index 520171d581..0000000000 --- a/Loop/Views/Presets/CreatePresetView.swift +++ /dev/null @@ -1,210 +0,0 @@ -// -// CreatePresetView.swift -// Loop -// -// Created by Pete Schwamb on 2/15/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopAlgorithm -import LoopKitUI -import LoopKit - - -enum CreatePresetPage: Hashable { - case correctionRange - case nameAndSchedule - case summary -} - -struct SettingAdjustmentPreview: View { - let value: LoopQuantity - let displayUnit: LoopUnit - let name: String - private let valueFormatter: QuantityFormatter - private let unitFormatter: QuantityFormatter - private let highlighted: Bool - - init(value: LoopQuantity, displayUnit: LoopUnit? = nil, name: String, highlighted: Bool = false) { - self.value = value - self.displayUnit = displayUnit ?? value.unit - self.name = name - self.valueFormatter = QuantityFormatter(for: value.unit) - self.unitFormatter = QuantityFormatter(for: self.displayUnit) - if self.displayUnit == .internationalUnitsPerHour { - // Basal rates get special treatment here. Loop's default max for basal rate is 3 digits, - // to support pumps that support that. The value shown here does not represent an actual - // set basal rate, but rather a value computed by loop, used in computing insulin effects, - // and is somewhat independent of pump supported rates. 2 digits is generally enough - // precision here. - self.valueFormatter.numberFormatter.maximumFractionDigits = 2 - } - - self.highlighted = highlighted - } - - var valueRow: some View { - (Text(valueFormatter.string(from: value, includeUnit: false) ?? "NA") - .bold() + Text(" ") + - Text(displayUnit.shortLocalizedUnitString())) - .fixedSize(horizontal: false, vertical: true) - } - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - if highlighted { - valueRow.foregroundColor(.insulin) - } else { - valueRow - } - Text(name) - } - } -} - -struct CreatePresetView: View { - @Environment(\.settingsManager) private var settingsManager - @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager - @Environment(\.dismiss) private var dismiss - - @State private var path = NavigationPath() - @State private var preset = NewCustomPreset() - - var scheduledRange: ClosedRange? { - settingsManager.settings.glucoseTargetRangeSchedule?.quantityRange(at: Date()) - } - - var suspendThreshold: GlucoseThreshold? { - settingsManager.settings.suspendThreshold - } - - - var body: some View { - NavigationStack(path: $path) { - VStack(spacing: 0) { - Form { - InsulinScaleAdjustView(insulinMultiplier: $preset.insulinMultiplier, guardrail: Guardrail.presetInsulinNeeds) - } - - actionArea - } - .edgesIgnoringSafeArea(.bottom) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) - .navigationDestination(for: CreatePresetPage.self) { page in - switch page { - case .correctionRange: - Group { - if let scheduledRange { - NewPresetRangeEdit( - preset: $preset, - path: $path, - guardrail: Guardrail.temporaryPresetCorrectionRange, - scheduledRange: scheduledRange, - onCancel: { dismiss() } - ) - } - } - case .nameAndSchedule: - CreatePresetNameAndScheduledEdit(preset: $preset, path: $path, onCancel: { dismiss() }) - case .summary: - if let scheduledRange { - ReviewNewPresetView( - preset: $preset, - path: $path, - scheduledRange: scheduledRange, - onCancel: { dismiss() }, - onComplete: { startPreset in - dismiss() - if let temporaryPreset = preset.temporaryPreset { - if preset.savePreset { - settingsManager.createPreset(temporaryPreset) - Task { - await temporaryPresetsManager.scheduleNextPresetReminder() - } - } - if startPreset { - let temporaryScheduleOverride = temporaryPreset.createOverride(enactTrigger: .local, isCustom: !preset.savePreset) - temporaryPresetsManager.scheduleOverride = temporaryScheduleOverride - } - } - } - ) - } - } - } - .navigationTitle("Create a Preset") - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Cancel") { - dismiss() - } - } - } - } - } - - var exceededThreshold: SafetyClassification.Threshold? { - switch Guardrail.presetInsulinNeeds.classification(for: .init(unit: .percent, doubleValue: preset.insulinMultiplier * 100)) { - case .withinRecommendedRange: - return nil - case .outsideRecommendedRange(let threshold): - return threshold - } - - } - - var guardrailWarningIfNecessary: some View { - Group { - if let threshold = exceededThreshold { - WarningView(title: threshold.insulinNeedsScaleWarningTitle, caption: threshold.insulinNeedsScaleWarningCaption, severity: threshold.severity) - .padding() - } - } - } - - private var actionArea: some View { - VStack(spacing: 0) { - guardrailWarningIfNecessary - actionButton - } - .background(Color(.secondarySystemGroupedBackground).shadow(radius: 5)) - } - - private var actionButton: some View { - Button("Continue") { - path.append(CreatePresetPage.correctionRange) - } - .buttonStyle(ActionButtonStyle(.primary)) - .padding() - } -} - -extension SafetyClassification.Threshold { - public var insulinNeedsScaleWarningTitle: Text { - switch self { - case .belowRecommended, .belowWarning, .minimum: - return Text("Insulin adjustment is below the safety threshold") - case .aboveRecommended, .aboveWarning, .maximum: - return Text("Insulin adjustment is above the safety threshold") - } - } - - public var insulinNeedsScaleWarningCaption: Text { - switch self { - case .belowRecommended, .belowWarning, .minimum: - return Text("Using this adjustment may lead to an under delivery of insulin. Monitor your glucose while this preset is in use.") - case .aboveRecommended: - return Text("Using this adjustment may lead to an over delivery of insulin. Monitor your glucose while this preset is in use.") - case .aboveWarning, .maximum: - return Text("Using this adjustment may lead to an over delivery of insulin and result in serious injury. Monitor your glucose while this preset is in use.") - } - } - -} - - -#Preview { - CreatePresetView() -} diff --git a/Loop/Views/Presets/DurationPickerView.swift b/Loop/Views/Presets/DurationPickerView.swift deleted file mode 100644 index 93a89eba69..0000000000 --- a/Loop/Views/Presets/DurationPickerView.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// DurationPickerView.swift -// Loop -// -// Created by Pete Schwamb on 1/29/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopCore - -struct DurationPickerView: View { - @Binding var durationType: PresetDuration - @State private var lastUsedDuration: TimeInterval - @State private var allowIndefinite: Bool - - // Available values (respecting min 5min and max 8hr constraints) - private let availableHours = Array(0...8) - private let availableMinutes = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55] - - init(durationType: Binding, allowIndefinite: Bool = true) { - self._durationType = durationType - - // Initialize lastUsedDuration based on current durationType or default to 1 hour - let initialDuration: TimeInterval - switch durationType.wrappedValue { - case .duration(let interval): - initialDuration = interval - case .indefinite, .untilCarbsEntered: - initialDuration = 3600 // 1 hour default - } - self._lastUsedDuration = State(initialValue: initialDuration) - self.allowIndefinite = allowIndefinite - } - - private var hours: Binding { - Binding( - get: { - Int(lastUsedDuration / 3600) - }, - set: { newHours in - let existingMinutes = minutes.wrappedValue - let newInterval = TimeInterval(newHours * 3600 + existingMinutes * 60) - lastUsedDuration = newInterval - if !isIndefinite.wrappedValue { - durationType = .duration(newInterval) - } - } - ) - } - - private var minutes: Binding { - Binding( - get: { - Int((lastUsedDuration.truncatingRemainder(dividingBy: 3600)) / 60) - }, - set: { newMinutes in - let existingHours = hours.wrappedValue - let newInterval = TimeInterval(existingHours * 3600 + newMinutes * 60) - lastUsedDuration = newInterval - if !isIndefinite.wrappedValue { - durationType = .duration(newInterval) - } - } - ) - } - - private var isIndefinite: Binding { - Binding( - get: { - if case .indefinite = durationType { - return true - } - return false - }, - set: { isOn in - if isOn { - durationType = .indefinite - } else { - durationType = .duration(lastUsedDuration) - } - } - ) - } - - var picker: some View { - HStack(spacing: 16) { - HStack(spacing: 8) { - Picker("Hours", selection: hours) { - ForEach(availableHours, id: \.self) { hour in - Text("\(hour)") - .tag(hour) - } - } - .pickerStyle(.wheel) - .frame(width: 60) - .clipped() - .disabled(isIndefinite.wrappedValue) - .opacity(isIndefinite.wrappedValue ? 0.5 : 1) - - Text("hour") - .foregroundColor(isIndefinite.wrappedValue ? .gray.opacity(0.5) : .gray) - } - - HStack(spacing: 8) { - Picker("Minutes", selection: minutes) { - ForEach(availableMinutes, id: \.self) { minute in - Text("\(minute)") - .tag(minute) - } - } - .pickerStyle(.wheel) - .frame(width: 60) - .clipped() - .disabled(isIndefinite.wrappedValue) - .opacity(isIndefinite.wrappedValue ? 0.5 : 1) - - Text("min") - .foregroundColor(isIndefinite.wrappedValue ? .gray.opacity(0.5) : .gray) - } - } - .padding(.horizontal) - .onChange(of: hours.wrappedValue) { _, _ in - enforceConstraints() - } - .onChange(of: minutes.wrappedValue) { _, _ in - enforceConstraints() - } - } - - var body: some View { - VStack { - if !isIndefinite.wrappedValue { - picker - } - - if allowIndefinite { - HStack { - Text("Until I turn off") - Spacer() - Toggle("", isOn: isIndefinite) - .labelsHidden() - } - } - } - } - - private func enforceConstraints() { - if !isIndefinite.wrappedValue { - if lastUsedDuration < 300 { // Less than 5 minutes - lastUsedDuration = 300 - durationType = .duration(300) - } else if lastUsedDuration > 28800 { // More than 8 hours - lastUsedDuration = 28800 - durationType = .duration(28800) - } else { - durationType = .duration(lastUsedDuration) - } - } - } -} diff --git a/Loop/Views/Presets/EditPresetView.swift b/Loop/Views/Presets/EditPresetView.swift deleted file mode 100644 index 6f97ac0103..0000000000 --- a/Loop/Views/Presets/EditPresetView.swift +++ /dev/null @@ -1,530 +0,0 @@ -// -// EditPresetView.swift -// Loop -// -// Created by Pete Schwamb on 12/09/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// - -import Foundation -import LoopKit -import SwiftUI -import LoopKitUI -import LoopAlgorithm -import LoopCore - -struct EditPresetView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.colorPalette) private var colorPalette - @Environment(\.settingsManager) private var settingsManager - @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference - - enum Destination { - case editCorrectionRange - case editInsulinNeeds - } - - fileprivate enum AlertState { - case confirmDelete - case trainingIncomplete - } - - @State private var trainingCompletion: PresetsTrainingCompletion - @State private var preset: SelectablePreset - @State private var navigationPath = NavigationPath() - @State private var isDurationPickerExpanded = false - @State private var showingDayPicker: Bool = false - @State private var showPresetsTrainingSheet: Bool = false - @State private var activeAlert: AlertState? - - @FocusState private var isTextFieldFocused: Bool - - private var originalPreset: SelectablePreset - private var scheduledRange: ClosedRange - private var onSave: (SelectablePreset) throws -> Void - private var onDelete: (SelectablePreset) throws -> Void - - private var scheduleFooter: String? { - guard preset.repeatOptions != .none, - let timeString = preset.scheduleStartDate?.formatted(date: .omitted, time: .shortened) - else { return nil } - - return String( - format: NSLocalizedString( - "Repeats weekly on %1$@ at %2$@", - comment: "preset weekly repeat footer (1: repeat day(s)) (2: repeat time)" - ), - String(describing: preset.repeatOptions), - timeString - ) - } - - private var activityPresetIsModified: Bool? { - guard case let .activity(activityPreset) = preset else { return nil } - - return activityPreset.isModifiedFromDefault - } - - init( - preset: SelectablePreset, - scheduledRange: ClosedRange, - trainingCompletion: PresetsTrainingCompletion, - onSave: @escaping ((SelectablePreset) throws -> Void), - onDelete: @escaping ((SelectablePreset) throws -> Void) - ) { - self.preset = preset - self.originalPreset = preset - self.scheduledRange = scheduledRange - self.trainingCompletion = trainingCompletion - self.onSave = onSave - self.onDelete = onDelete - } - - var trainingNeededSection: some View { - Button { - showPresetsTrainingSheet = true - } label: { - CardSection("Temporary Settings Adjustments", borderColor: .accentColor) { - HStack(spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Group { - Text(Image(systemName: "info.circle")) - .foregroundStyle(Color.accentColor) + - Text(" Extra Training Needed") - } - .font(.callout.weight(.semibold)) - .frame(maxWidth: .infinity, alignment: .leading) - - Text("Complete the training to change this preset’s settings. You can still update the details.") - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.leading) - } - - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } - } - .foregroundColor(.primary) - } - } - - var sensitivitySection: some View { - Button { - if !preset.isPreMeal && !trainingCompletion.isComplete { - activeAlert = .trainingIncomplete - } else if preset.canAdjustSensitivity { - navigationPath.append(Destination.editInsulinNeeds) - } - } label: { - CardSection(preset.isPreMeal || trainingCompletion.isComplete ? "Temporary Settings Adjustments" : nil) { - InsulinNeedsAdjustmentPreview( - insulinPercentage: preset.insulinNeedsScaleFactor * 100, - guardrail: Guardrail.presetInsulinNeeds, - showDisclosure: preset.canAdjustSensitivity - ) - if (!preset.canAdjustSensitivity) { - (Text(Image(systemName: "info.circle")) + Text(" Overall insulin cannot be adjusted for this preset")) - .foregroundColor(.secondary) - .font(.footnote) - .italic() - .padding(.top, 4) - } - } - .foregroundColor(.primary) - } - } - - var body: some View { - NavigationStack(path: $navigationPath) { - ScrollViewReader { scrollViewProxy in - CardSectionScrollView { - presetTitle - - if !preset.isPreMeal && !trainingCompletion.isComplete { - trainingNeededSection - } - - sensitivitySection - - CardSection { - Button { - if !preset.isPreMeal && !trainingCompletion.isComplete { - activeAlert = .trainingIncomplete - } else { - navigationPath.append(Destination.editCorrectionRange) - } - } label: { - CorrectionRangePreview( - range: preset.correctionRange, - guardrail: settingsManager.correctionRangeGuardrailForPreset(preset), - scheduledRange: scheduledRange, - veryHighInsulinNeeds: preset.veryHighInsulinNeeds, - showDisclosure: true - ) - }.accessibilityIdentifier("button_CorrectionRange") - } - - if let activityPresetIsModified { - Group { - if activityPresetIsModified { - Button { - if case let .activity(activityPreset) = preset { - withAnimation { - preset = .activity(ActivityPreset(activityType: activityPreset.activityType, preset: activityPreset.activityType.defaultPreset(duration: activityPreset.preset.duration, scheduleStartDate: activityPreset.preset.scheduleStartDate, repeatOptions: activityPreset.preset.repeatOptions ?? .none))) - } - } - } label: { - Group { - Text(Image(systemName: "arrow.uturn.backward")) + Text(" ") + Text("Revert to recommended values") - } - .font(.body.weight(.semibold)) - .foregroundStyle(Color.accentColor) - } - .buttonStyle(ActionButtonStyle(.secondary)) - } else { - Group { - Text(Image(systemName: "checkmark.seal.fill")) + Text(" ") + Text("Recommended starting values") - } - .font(.subheadline) - .foregroundStyle(Color.accentColor) - .frame(maxWidth: .infinity) - } - } - .padding(.vertical, 4) - } - - presetDetailsCard - - // Duration Section - if preset.canAdjustDuration { - durationCard(scrollViewProxy) - } - - // Schedule Toggle - if preset.allowsScheduling { - schedulingCard(scrollViewProxy) - } - - if preset.canBeDeleted { - deletePresetButton - } - } - .animation(.easeInOut, value: preset.duration) - } - .navigationBarItems(trailing: dismissButton) - .navigationDestination(for: Destination.self) { dest in - switch dest { - case .editInsulinNeeds: - ExistingPresetInsulinNeedsEdit( - insulinScaleFactor: $preset.insulinNeedsScaleFactor, - presetUsesScheduledRange: preset.correctionRange == nil - ) - case .editCorrectionRange: - ExistingPresetRangeEdit( - range: $preset.correctionRange, - guardrail: settingsManager.correctionRangeGuardrailForPreset(preset), - scheduledRange: scheduledRange, - allowsScheduledRange: preset.canAdjustSensitivity, - isPreMeal: preset.isPreMeal, - presetAdjustsInsulinNeeds: preset.insulinNeedsScaleFactor != 1, - veryHighInsulinNeeds: preset.veryHighInsulinNeeds - ) - } - } - .onChange(of: preset.scheduleStartDate, { _, newValue in - if newValue != nil { - assignRepeatDays() - } - }) - .onChange(of: preset) { _, _ in - do { - try onSave(preset) - } catch { - print(error) - } - } - .alert(alertTitle, isPresented: isAlertPresented, presenting: activeAlert) { alertState in - alertActions(for: alertState) - } message: { alertState in - alertMessage(for: alertState) - } - .sheet(isPresented: $showPresetsTrainingSheet) { - PresetsTrainingView(trainingCompletion: trainingCompletion) - } - } - } - - private var presetDetailsCard: some View { - CardSection("Preset Details") { - HStack { - Text("Name") - Spacer() - if preset.canChangeName { - TextField("", text: $preset.name, prompt: Text("Required")) - .multilineTextAlignment(.trailing) - .focused($isTextFieldFocused) - .foregroundColor(.secondary) - } else { - HStack(spacing: 4) { - if case let .activity(activityPreset) = preset { - Text(Image(systemName: activityPreset.activityType.symbol.value)) - } - - Text(preset.name) - } - .foregroundColor(.secondary) - } - } - } - } - - private func durationCard(_ proxy: ScrollViewProxy) -> some View { - CardSection { - VStack(alignment: .leading) { - HStack { - Text("Duration") - .foregroundColor(.primary) - Spacer() - Group { - Text(preset.duration.localizedTitle) - Image(systemName: "chevron.right") - } - .foregroundColor(.secondary) - } - .contentShape(Rectangle()) - .onTapGesture { - isTextFieldFocused = false - withAnimation { - isDurationPickerExpanded.toggle() - Task { - if isDurationPickerExpanded { - try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay - withAnimation { - proxy.scrollTo("durationPicker", anchor: .bottom) - } - } - } - } - } - - if isDurationPickerExpanded { - DurationPickerView( - durationType: $preset.duration, - allowIndefinite: preset.allowsIndefiniteDuration - ) - .id("durationPicker") // Assign an ID for scrolling - } - } - } - .id("durationSection") // Optional: ID for the entire duration section - } - - private var deletePresetButton: some View { - Button("Delete Preset") { - activeAlert = .confirmDelete - } - .buttonStyle(ActionButtonStyle(.destructive)) - .padding(.top) - } - - private func schedulingCard(_ proxy: ScrollViewProxy) -> some View { - CardSection(content: { - HStack { - Text("Schedule") - .font(.body) - - Spacer() - - Toggle("", isOn: Binding(get: { - return preset.isScheduled - }, set: { newValue in - withAnimation { - if newValue { - preset.scheduleStartDate = Date().addingTimeInterval(.hours(1)) - Task { - try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay - withAnimation { - proxy.scrollTo("repeatOption", anchor: .bottom) - } - } - } else { - preset.scheduleStartDate = nil - preset.repeatOptions = .none - } - } - })) - .toggleStyle(SwitchToggleStyle(tint: .green)) - .labelsHidden() - .padding(.vertical, -4) - } - - if preset.isScheduled { - Divider() - HStack { - if preset.repeatOptions != .none { - Text("Next Date") - } else { - Text("Start Date") - } - Spacer() - DatePicker( - "", - selection: Binding(get: { - preset.nextScheduledStartAfter(Date()) ?? Date() - }, set: { newValue in - preset.repeatOptions = .none - preset.scheduleStartDate = newValue - }), - in: Date().addingTimeInterval(.minutes(1))..., - displayedComponents: [.date, .hourAndMinute] - ) - } - Divider() - .padding(.top, -4) - HStack { - Text("Repeat") - Spacer() - Picker("Repeat", selection: Binding( - get: { preset.repeatOptions == .none ? .never : .weekly }, - set: { newValue in - if newValue == .never { - preset.repeatOptions = .none - } else { - Task { - if let requiredRepeatOption { - preset.repeatOptions = requiredRepeatOption - } - try? await Task.sleep(nanoseconds: 200_000_000) // ~0.2s delay - withAnimation { - proxy.scrollTo("selectedDays", anchor: .bottom) - } - } - } - } - ).animation()) { - ForEach(RepeatOption.allCases, id: \.self) { option in - Text(String(describing: option)) - } - } - .tint(.secondary) - .pickerStyle(MenuPickerStyle()) - .padding(.trailing, -8) - } - .id("repeatOption") // Assign an ID for scrolling - - - if preset.repeatOptions != .none { - Divider() - .padding(.top, -4) - HStack { - Text("Selected days") - .foregroundColor(.primary) - HStack { - Spacer() - RepeatOptionView(repeatOptions: preset.repeatOptions) - .padding(.vertical, 6) - .onTapGesture { - withAnimation { - showingDayPicker = true - } - } - } - .popover(isPresented: $showingDayPicker, arrowEdge: .bottom) { - DayPickerPopup(selectedDays: Binding( - get: { - preset.repeatOptions - }, set: { newValue in - preset.repeatOptions = newValue.union(requiredRepeatOption ?? .none) - })) - .cornerRadius(12) - .presentationCompactAdaptation(.popover) - } - } - .id("selectedDays") // Assign an ID for scrolling - } - } - }, footerText: scheduleFooter) - } - - private var requiredRepeatOption: PresetScheduleRepeatOptions? { - guard let startDate = preset.scheduleStartDate else { return nil } - return .allCases[Calendar.current.component(.weekday, from: startDate) - 1] - } - - func assignRepeatDays() { - guard let requiredRepeatOption else { - return - } - preset.repeatOptions = requiredRepeatOption - } - - private var dismissButton: some View { - Button("Done") { - dismiss() - }.bold() - } - - var presetTitle: some View { - HStack(spacing: 6) { - if let icon = preset.icon, !icon.isEmpty { - PresetSymbolView(icon, iconSize: 34) - } - - Text(preset.name) - .font(.system(size: 34, weight: .semibold)) - .foregroundColor(.primary) - } - } - - private var alertTitle: String { - switch activeAlert { - case .confirmDelete: return "Delete “\(preset.name)”?" - case .trainingIncomplete: return "Extra Training Needed" - case .none: return "" - } - } - - private var isAlertPresented: Binding { - Binding( - get: { activeAlert != nil }, - set: { if !$0 { activeAlert = nil } } - ) - } - - @ViewBuilder - private func alertActions(for alertState: AlertState) -> some View { - switch alertState { - case .confirmDelete: - Button("Go Back", role: .cancel) { - activeAlert = nil - } - Button("Yes, Delete", role: .destructive) { - do { - try onDelete(preset) - dismiss() - } catch { - print(error) - } - } - case .trainingIncomplete: - Button("Start Training") { - showPresetsTrainingSheet = true - activeAlert = nil - } - Button("Close", role: .cancel) { - activeAlert = nil - } - } - } - - @ViewBuilder - private func alertMessage(for alertState: AlertState) -> some View { - switch alertState { - case .confirmDelete: - Text("Are you sure you want to delete this preset?") - case .trainingIncomplete: - Text("Complete the training to change this preset’s settings.") - } - } -} diff --git a/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift b/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift deleted file mode 100644 index 0af40dcae4..0000000000 --- a/Loop/Views/Presets/ExistingPresetInsulinNeedsEdit.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// ExistingPresetInsulinNeedsEdit.swift -// Loop -// -// Created by Pete Schwamb on 4/18/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopAlgorithm -import LoopKit -import LoopKitUI - -struct ExistingPresetInsulinNeedsEdit: View { - @Environment(\.dismiss) private var dismiss - - var guardrail: Guardrail - @Binding var scaleFactor: Double - @State var editedScale: Double - var presetUsesScheduledRange: Bool = false - - init(insulinScaleFactor: Binding, presetUsesScheduledRange: Bool) { - - _scaleFactor = insulinScaleFactor - editedScale = insulinScaleFactor.wrappedValue - guardrail = Guardrail.presetInsulinNeeds - self.presetUsesScheduledRange = presetUsesScheduledRange - } - - var body: some View { - CardSectionScrollView { - CardSection { - InsulinScaleAdjustView(insulinMultiplier: $editedScale, guardrail: Guardrail.presetInsulinNeeds) - } - } actionArea: { - if let crossedThreshold { - WarningView( - title: crossedThreshold.insulinNeedsScaleWarningTitle, - caption: crossedThreshold.insulinNeedsScaleWarningCaption, - severity: crossedThreshold.severity - ) - } else if presetUsesScheduledRange && editedScale == 1 { - NoticeView( - title: Text("Adjust Overall Insulin Needs"), - caption: Text("With correction range set to using your scheduled range, overall insulin needs adjustment is required.") - ) - } - actionButton - } - .navigationBarBackButtonHidden(editedScale != scaleFactor) - .navigationBarItems( - trailing: cancelButton - ) - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Edit Preset") - } - - private var cancelButton: some View { - Group { - if editedScale != scaleFactor { - Button("Cancel") { - dismiss() - } - .foregroundColor(.blue) - } - } - } - - - private var actionButton: some View { - Button("Save") { - scaleFactor = editedScale - dismiss() - } - .disabled(editedScale == scaleFactor || (editedScale == 1 && presetUsesScheduledRange)) - .buttonStyle(ActionButtonStyle(.primary)) - } - - var crossedThreshold: SafetyClassification.Threshold? { - switch guardrail.classification(for: LoopQuantity(unit: .percent, doubleValue: editedScale * 100)) { - case .withinRecommendedRange: - return nil - case .outsideRecommendedRange(let threshold): - return threshold - } - } -} diff --git a/Loop/Views/Presets/ExistingPresetRangeEdit.swift b/Loop/Views/Presets/ExistingPresetRangeEdit.swift deleted file mode 100644 index 3675191312..0000000000 --- a/Loop/Views/Presets/ExistingPresetRangeEdit.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// EditPresetRangeView.swift -// Loop -// -// Created by Pete Schwamb on 2/25/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopAlgorithm -import LoopKit -import LoopKitUI - -struct ExistingPresetRangeEdit: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appName) private var appName - - @Binding var range: ClosedRange? - var guardrail: Guardrail - private var scheduledRange: ClosedRange - @State private var editedRange: ClosedRange? - private var allowsScheduledRange: Bool - private var isPreMeal: Bool = false - private var presetAdjustsInsulinNeeds: Bool - private var veryHighInsulinNeeds: Bool - - init( - range: Binding?>, - guardrail: Guardrail, - scheduledRange: ClosedRange, - allowsScheduledRange: Bool = true, - isPreMeal: Bool = false, - presetAdjustsInsulinNeeds: Bool, - veryHighInsulinNeeds: Bool - ) { - self._range = range - self.editedRange = range.wrappedValue - self.guardrail = guardrail - self.scheduledRange = scheduledRange - self.allowsScheduledRange = allowsScheduledRange - self.isPreMeal = isPreMeal - self.presetAdjustsInsulinNeeds = presetAdjustsInsulinNeeds - self.veryHighInsulinNeeds = veryHighInsulinNeeds - } - - var requiresHighInsulinNeedsMitigation: Bool { - if veryHighInsulinNeeds, let editedRange { - return editedRange.lowerBound < TemporaryScheduleOverride.highInsulinNeedsMitigationCorrectionRangeLimit - } - return veryHighInsulinNeeds - } - - var highInsulinNeedsWarningText: String { - String(format: NSLocalizedString("For presets with insulin needs of 170%% or greater, %1$@ will set your correction range to 110 mg/dL or higher when this preset is enabled.", comment: "The format string for the high insulin needs preset warning text. (1: app name)"), appName) - } - - var body: some View { - CardSectionScrollView { - CardSection { - PresetRangeEditor( - range: $editedRange, - guardrail: guardrail, - scheduledRange: scheduledRange, - allowsScheduledRange: allowsScheduledRange, - isPreMeal: isPreMeal - ) - } - } actionArea: { - if !crossedThresholds.isEmpty { - CorrectionRangeGuardrailWarning(crossedThresholds: crossedThresholds) - } else if (editedRange == nil && !presetAdjustsInsulinNeeds) { - NoticeView( - title: Text("Set an Adjusted Correction Range"), - caption: Text("With overall insulin needs at 100%, an adjusted correction range is required.")) - } else if requiresHighInsulinNeedsMitigation { - WarningView( - title: Text("Correction range adjustment when preset is enabled"), - caption: Text(highInsulinNeedsWarningText)) - } - actionButton - } - .navigationBarBackButtonHidden(editedRange != range) - .navigationBarItems( - trailing: cancelButton - ) - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Edit Preset") - } - - private var cancelButton: some View { - Group { - if editedRange != range { - Button("Cancel") { - dismiss() - } - .foregroundColor(.blue) - .accessibilityIdentifier("button_Cancel") - } - } - } - - - private var actionButton: some View { - Button("Save") { - range = editedRange - dismiss() - } - .disabled(editedRange == range || (editedRange == nil && !presetAdjustsInsulinNeeds)) - .buttonStyle(ActionButtonStyle(.primary)) - .accessibilityIdentifier("button_Save") - } - - - var crossedThresholds: [SafetyClassification.Threshold] { - if let range = editedRange ?? range { - let lowerBound = range.lowerBound - let upperBound = range.upperBound - return [lowerBound, upperBound].compactMap { (bound) -> SafetyClassification.Threshold? in - switch guardrail.classification(for: bound) { - case .withinRecommendedRange: - return nil - case .outsideRecommendedRange(let threshold): - return threshold - } - } - } else { - return [] - } - } -} - -private struct CorrectionRangeGuardrailWarning: View { - var crossedThresholds: [SafetyClassification.Threshold] - - var body: some View { - assert(!crossedThresholds.isEmpty) - return GuardrailWarning( - therapySetting: .glucoseTargetRange, - title: crossedThresholds.count == 1 ? singularWarningTitle(for: crossedThresholds.first!) : multipleWarningTitle, - thresholds: crossedThresholds - ) - } - - private func singularWarningTitle(for threshold: SafetyClassification.Threshold) -> Text { - switch threshold { - case .minimum, .belowWarning, .belowRecommended: - return Text("Low Correction Value", comment: "Title text for the low correction value warning") - case .aboveRecommended, .aboveWarning, .maximum: - return Text("High Correction Value", comment: "Title text for the high correction value warning") - } - } - - private var multipleWarningTitle: Text { - Text("Correction Values", comment: "Title text for multi-value correction value warning") - } -} diff --git a/Loop/Views/Presets/InsulinScaleInformationView.swift b/Loop/Views/Presets/InsulinScaleInformationView.swift deleted file mode 100644 index 42e9a0da5c..0000000000 --- a/Loop/Views/Presets/InsulinScaleInformationView.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// InsulinScaleInformationView.swift -// Loop -// -// Created by Pete Schwamb on 2/25/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - - -import SwiftUI - -struct InsulinScaleInformationView: View { - @Environment(\.dismiss) private var dismiss - - var body: some View { - VStack(spacing: 0) { - // Close button - VStack { - Button("Close") { - dismiss() - } - .font(.title3) - .frame(maxWidth: .infinity, alignment: .trailing) - .padding() - } - .background(Color(.systemBackground)) - - // Header - VStack(alignment: .leading) { - Text("Overall Insulin") - .font(.largeTitle) - .fontWeight(.bold) - .padding(.vertical) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - .background(Color(.systemBackground)) - - Divider() - - ScrollView { - VStack(alignment: .leading, spacing: 24) { - // Description Text - Text("Overall insulin should be adjusted when your body needs more or less insulin than usual.") - .padding(.top) - - Text("At 100%, your settings remain unchanged from your scheduled settings.") - - // What gets affected - VStack(alignment: .leading, spacing: 16) { - Text("Changing the percentage will affect:") - .fontWeight(.medium) - - BulletPoint(text: "Basal Rate") - BulletPoint(text: "Carb Ratio") - BulletPoint(text: "Insulin Sensitivity Factor (ISF)") - } - - // Decision guidance - VStack(alignment: .leading, spacing: 8) { - Text("Before deciding to adjust your overall insulin,") - Text("ask yourself, does my body need more or less than usual?") - .fontWeight(.bold) - } - - // Tip section - TipSection() - } - .padding() - } - } - } -} - -struct BulletPoint: View { - let text: String - - var body: some View { - HStack(alignment: .top) { - Circle() - .fill(Color.blue.opacity(0.3)) - .frame(width: 8, height: 8) - .padding(.top, 6) - - Text(text) - .padding(.leading, 4) - } - } -} - -struct TipSection: View { - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: "lightbulb.fill") - .foregroundColor(.blue) - - Text("Tip") - .font(.headline) - .foregroundColor(.blue) - } - .padding(.bottom, 4) - - HStack(alignment: .top) { - Circle() - .fill(Color.blue.opacity(0.3)) - .frame(width: 8, height: 8) - .padding(.top, 6) - - VStack(alignment: .leading) { - Text("A percentage ") + - Text("below 100%").fontWeight(.semibold) + - Text(" tells the system you need less insulin") - } - } - - HStack(alignment: .top) { - Circle() - .fill(Color.blue.opacity(0.3)) - .frame(width: 8, height: 8) - .padding(.top, 6) - - VStack(alignment: .leading) { - Text("A percentage ") + - Text("above 100%").fontWeight(.semibold) + - Text(" indicates you need more insulin") - } - } - } - .padding() - .background(Color.blue.opacity(0.1)) - .cornerRadius(8) - } -} - -struct OverallInsulinView_Previews: PreviewProvider { - static var previews: some View { - InsulinScaleInformationView() - } -} diff --git a/Loop/Views/Presets/NewCustomPreset.swift b/Loop/Views/Presets/NewCustomPreset.swift deleted file mode 100644 index bb01da31f9..0000000000 --- a/Loop/Views/Presets/NewCustomPreset.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// NewCustomPreset.swift -// Loop -// -// Created by Pete Schwamb on 2/26/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import LoopAlgorithm -import UIKit -import LoopKit -import LoopCore - -extension PresetScheduleRepeatOptions: @retroactive CustomStringConvertible { - public var description: String { - let calendar = Calendar.current - let weekdaySymbols = calendar.weekdaySymbols - - // Handle single day case - if let weekdayIndex = calendarWeekdayIndex { - return weekdaySymbols[weekdayIndex - 1] // -1 because array is 0-based - } - - // Handle multiple days - return NSLocalizedString("multiple days", comment: "Preset schedule repeat option multiple days") - } - - var veryShortDescription: String { - let calendar = Calendar.current - let weekdaySymbols = calendar.veryShortWeekdaySymbols - - // Handle single day case - if let weekdayIndex = calendarWeekdayIndex { - return weekdaySymbols[weekdayIndex - 1] // -1 because array is 0-based - } - - // Handle multiple days - return NSLocalizedString("Multiple", comment: "Preset schedule repeat option multiple days") - } -} - -struct NewCustomPreset { - var savePreset: Bool - var insulinMultiplier: Double = 1 - var correctionRange: ClosedRange? - var name: String = "" - var duration: PresetDuration? - var startDate: Date? - var repeatOptions: PresetScheduleRepeatOptions - - init( - savePreset: Bool = true, - insulinMultiplier: Double = 1, - correctionRange: ClosedRange? = nil, - name: String = "", - duration: PresetDuration? = nil, - startDate: Date? = nil, - repeatOptions: PresetScheduleRepeatOptions = .none - ) { - self.savePreset = savePreset - self.insulinMultiplier = insulinMultiplier - self.correctionRange = correctionRange - self.name = name - self.duration = duration - self.startDate = startDate - self.repeatOptions = repeatOptions - } - - var veryHighInsulinNeeds: Bool { - return TemporaryScheduleOverride.isInMitigationRange(insulinNeedsScaleFactor: insulinMultiplier) - } -} - -extension NewCustomPreset { - func scheduleDescription() -> String { - guard let startDate = startDate, !repeatOptions.isEmpty else { - return "" - } - - // Get date formatter for time (will use user's locale) - let timeFormatter = DateFormatter() - timeFormatter.timeStyle = .short // Uses locale-appropriate short time format (e.g., "10:00 AM" or "10:00") - let timeString = timeFormatter.string(from: startDate) - - // Get all selected days - let selectedDays = PresetScheduleRepeatOptions.allCases - .filter { repeatOptions.contains($0) } - .map { $0.description } // Already localized via your existing description - - // Format the days string based on count - let daysString: String - switch selectedDays.count { - case 1: - daysString = selectedDays[0] - case 2: - daysString = String( - format: NSLocalizedString("%@ and %@", comment: "Format for two days"), - selectedDays[0], - selectedDays[1] - ) - default: - let lastDay = selectedDays.last ?? "" - let otherDays = selectedDays.dropLast().joined(separator: NSLocalizedString(", ", comment: "Separator for multiple days")) - daysString = String( - format: NSLocalizedString("%@, and %@", comment: "Format for three or more days"), - otherDays, - lastDay - ) - } - - // Combine with localized format string - return String( - format: NSLocalizedString("Repeats weekly on %@ at %@", comment: "Weekly repeat schedule format"), - daysString, - timeString - ) - } -} - -extension NewCustomPreset { - var temporaryPreset: TemporaryPreset? { - guard let duration else { - return nil - } - let overrideDuration = duration.presetDuration - - let settings = TemporaryPresetSettings( - targetRange: correctionRange, - insulinNeedsScaleFactor: insulinMultiplier - ) - - let split = name.splitSymbolAndTitle() - var symbol: PresetSymbol? = nil - if let emoji = split.emoji { - symbol = .emoji(emoji) - } - - return TemporaryPreset( - symbol: symbol, - name: split.name, - settings: settings, - duration: overrideDuration, - scheduleStartDate: startDate, - repeatOptions: repeatOptions - ) - } -} - -private extension String { - func splitSymbolAndTitle() -> (emoji: String?, name: String) { - let trimmed = trimmingCharacters(in: .whitespaces) - if let first = trimmed.first, first.isEmoji { - let name = String(dropFirst()).trimmingCharacters(in: .whitespaces) - return (emoji: String(first), name: name) - } else { - return (emoji: nil, name: trimmed) - } - } -} diff --git a/Loop/Views/Presets/NewPresetRangeEdit.swift b/Loop/Views/Presets/NewPresetRangeEdit.swift deleted file mode 100644 index 2ec5ff9929..0000000000 --- a/Loop/Views/Presets/NewPresetRangeEdit.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// CreatePresetEditRangeView.swift -// Loop -// -// Created by Pete Schwamb on 2/26/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopAlgorithm -import LoopKit -import LoopKitUI - -struct NewPresetRangeEdit: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.appName) private var appName - - @Binding var preset: NewCustomPreset - @Binding var path: NavigationPath - var guardrail: Guardrail - var scheduledRange: ClosedRange - var onCancel: () -> Void - - @State private var editedRange: ClosedRange? - - init(preset: Binding, path: Binding, guardrail: Guardrail, scheduledRange: ClosedRange, onCancel: @escaping () -> Void) { - self._preset = preset - self._path = path - self._editedRange = .init(initialValue: preset.wrappedValue.correctionRange) - self.guardrail = guardrail - self.scheduledRange = scheduledRange - self.onCancel = onCancel - } - - var requiresHighInsulinNeedsMitigation: Bool { - if preset.veryHighInsulinNeeds, let editedRange { - return editedRange.lowerBound < TemporaryScheduleOverride.highInsulinNeedsMitigationCorrectionRangeLimit - } - return preset.veryHighInsulinNeeds - } - - var highInsulinNeedsWarningText: String { - String(format: NSLocalizedString("For presets with insulin needs of 170%% or greater, %1$@ will set your correction range to 110 mg/dL or higher when this is preset enabled.", comment: "The format string for the high insulin needs preset warning text. (1: app name)"), appName) - } - - var body: some View { - CardSectionScrollView { - CardSection { - PresetRangeEditor( - range: $editedRange, - guardrail: guardrail, - scheduledRange: scheduledRange, - isPreMeal: false - ) - } - } actionArea: { - if !crossedThresholds.isEmpty { - CorrectionRangeGuardrailWarning(crossedThresholds: crossedThresholds) - } else if preset.insulinMultiplier == 1 && editedRange == nil { - NoticeView( - title: Text("Set an Adjusted Correction Range"), - caption: Text("With overall insulin needs at 100%, an adjusted correction range is required.")) - } else if requiresHighInsulinNeedsMitigation { - WarningView( - title: Text("Correction range adjustment when preset is enabled"), - caption: Text(highInsulinNeedsWarningText)) - } - actionButton - } - - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Create a Preset") - .navigationBarItems( - trailing: cancelButton - ) - } - - private var cancelButton: some View { - Button("Cancel") { - onCancel() - } - .foregroundColor(.blue) - } - - private var actionButtonText: String { - if editedRange == nil { - NSLocalizedString("Continue with scheduled range", comment: "Continue button for new preset range edit when range is not edited") - } else { - NSLocalizedString("Continue with adjusted range", comment: "Continue button for new preset range edit when range edited") - } - } - - private var actionButton: some View { - Button(actionButtonText) { - preset.correctionRange = editedRange - path.append(CreatePresetPage.nameAndSchedule) - } - .disabled(preset.insulinMultiplier == 1 && editedRange == nil) - .buttonStyle(ActionButtonStyle(.primary)) - } - - - var crossedThresholds: [SafetyClassification.Threshold] { - if let range = editedRange ?? preset.correctionRange { - let lowerBound = range.lowerBound - let upperBound = range.upperBound - return [lowerBound, upperBound].compactMap { (bound) -> SafetyClassification.Threshold? in - switch guardrail.classification(for: bound) { - case .withinRecommendedRange: - return nil - case .outsideRecommendedRange(let threshold): - return threshold - } - } - } else { - return [] - } - } - -} - -private struct CorrectionRangeGuardrailWarning: View { - var crossedThresholds: [SafetyClassification.Threshold] - - var body: some View { - assert(!crossedThresholds.isEmpty) - return GuardrailWarning( - therapySetting: .glucoseTargetRange, - title: crossedThresholds.count == 1 ? singularWarningTitle(for: crossedThresholds.first!) : multipleWarningTitle, - thresholds: crossedThresholds - ) - } - - private func singularWarningTitle(for threshold: SafetyClassification.Threshold) -> Text { - switch threshold { - case .minimum, .belowWarning, .belowRecommended: - return Text("Low Correction Value", comment: "Title text for the low correction value warning") - case .aboveRecommended, .aboveWarning, .maximum: - return Text("High Correction Value", comment: "Title text for the high correction value warning") - } - } - - private var multipleWarningTitle: Text { - Text("Correction Values", comment: "Title text for multi-value correction value warning") - } -} diff --git a/Loop/Views/Presets/NoticeView.swift b/Loop/Views/Presets/NoticeView.swift deleted file mode 100644 index 21d37ea69d..0000000000 --- a/Loop/Views/Presets/NoticeView.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// NoticeView.swift -// Loop -// -// Created by Pete Schwamb on 5/23/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// -import SwiftUI - -public struct NoticeView: View { - var title: Text - var caption: Text - - public init(title: Text, caption: Text) { - self.title = title - self.caption = caption - } - - public var body: some View { - HStack { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .center) { - title - .font(Font(UIFont.preferredFont(forTextStyle: .title3))) - .bold() - .fixedSize(horizontal: false, vertical: true) - } - - caption - .font(.callout) - .foregroundColor(Color(.secondaryLabel)) - .fixedSize(horizontal: false, vertical: true) - } - .accessibilityElement(children: .combine) - - Spacer() - } - .padding(.vertical, 8) - } -} diff --git a/Loop/Views/Presets/PresetRangeEditor.swift b/Loop/Views/Presets/PresetRangeEditor.swift deleted file mode 100644 index db11748267..0000000000 --- a/Loop/Views/Presets/PresetRangeEditor.swift +++ /dev/null @@ -1,173 +0,0 @@ -// -// EditPressRangeView.swift -// Loop -// -// Created by Pete Schwamb on 12/17/24. -// Copyright © 2024 LoopKit Authors. All rights reserved. -// -import SwiftUI -import LoopAlgorithm -import LoopKit -import LoopKitUI - -struct PresetRangeEditor: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.guidanceColors) private var guidanceColors - @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference - @Environment(\.settingsManager) private var settingsManager - - - @State private var presentInfoView: Bool = false - @Binding var range: ClosedRange? - var guardrail: Guardrail - private var scheduledRange: ClosedRange - private var allowsScheduledRange: Bool - private var isPreMeal: Bool - - init(range: Binding?>, guardrail: Guardrail, scheduledRange: ClosedRange, allowsScheduledRange: Bool = true, isPreMeal: Bool) { - self._range = range - self.guardrail = guardrail - self.scheduledRange = scheduledRange - self.allowsScheduledRange = allowsScheduledRange - self.isPreMeal = isPreMeal - } - - var displayedRange: ClosedRange { - return range ?? scheduledRange - } - - func boundText(for bound: LoopQuantity) -> Text { - let color = guardrail.color(for: bound, guidanceColors: guidanceColors) - let text = displayGlucosePreference.format(bound, includeUnit: false) - switch guardrail.classification(for: bound) { - case .withinRecommendedRange: - return Text(text) - .foregroundColor(range == nil ? .secondary : .accentColor) - .font(.system(size: 42, weight: .semibold)) - case .outsideRecommendedRange: - return ( - Text(Image(systemName: "exclamationmark.triangle.fill")) - .font(.system(size: 29, weight: .regular)) - .baselineOffset(3.0) - .foregroundColor(color) + - Text(text) - .foregroundColor(color) - .font(.system(size: 42, weight: .semibold)) - ) - } - } - - var body: some View { - VStack(spacing: 24) { - VStack(spacing: 8) { - HStack { - Text("Correction Range") - .foregroundColor(.secondary) - .font(.system(size: 14)) - Button(action: { - presentInfoView = true; - }) { - Image(systemName: "info.circle") - } - } - .padding(.top, 10) - - - Text("Set your correction range") - .font(.title2) - .fontWeight(.bold) - .multilineTextAlignment(.center) - .padding(.top, 10) - - Text("To reduce the risk of highs or lows, you may want to set an adjusted range if you think your glucose will vary more than usual.") - .multilineTextAlignment(.center) - - if allowsScheduledRange { - Toggle("Use Scheduled Range", isOn: Binding(get: { - range == nil - }, set: { newValue in - withAnimation { - if (newValue) { - range = nil - } else { - range = scheduledRange - } - } - })) - .padding(.vertical) - } - } - - VStack(spacing: 0) { - if (range == nil) { - Text("Currently Scheduled Correction Range") - } else { - Text("Adjusted Range") - - } - - ( - boundText(for: (displayedRange).lowerBound) + - Text("-").foregroundColor(.secondary) - .font(.system(size: 42, weight: .light)) - + - boundText(for: (displayedRange).upperBound) - ) - .accessibilityIdentifier("text_AdjustedCorrectionRange") - - - Text(displayGlucosePreference.unit.localizedShortUnitString) - .foregroundColor(.secondary) - } - - if range != nil { - Divider() - .animation(.default, value: range != nil) - - GlucoseRangePicker(range: Binding( - get: { displayedRange }, - set: { range = $0 }), - unit: displayGlucosePreference.unit, - minValue: settingsManager.settings.suspendThreshold?.quantity, - guardrail: guardrail) - .padding(.vertical, -20) - } - - HStack(spacing: 8) { - Image(systemName: "info.circle") - .foregroundColor(.accentColor) - - tipText.font(.system(size: 14)) - } - .padding() - .overlay( /// apply a rounded border - RoundedRectangle(cornerRadius: 8) - .stroke(.gray, lineWidth: 1) - ) - .padding(.bottom) - } - .font(.subheadline) - .sheet(isPresented: $presentInfoView) { - CorrectionRangeInformationView() - } - } - - - private var tipText: some View { - Group { - if isPreMeal { - Text("To help avoid post-meal highs, set a range ") - + Text("lower") - .italic() - .bold() - + Text(" than your typical correction range.") - } else { - Text("To help avoid lows, set a range ") - + Text("higher") - .italic() - .bold() - + Text(" than your typical correction range.") - } - } - } -} diff --git a/Loop/Views/Presets/PresetSymbolView.swift b/Loop/Views/Presets/PresetSymbolView.swift deleted file mode 100644 index 324ddc5d36..0000000000 --- a/Loop/Views/Presets/PresetSymbolView.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// PresetSymbolView.swift -// Loop -// -// Created by Cameron Ingham on 8/20/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import LoopKit -import SwiftUI - -struct PresetSymbolView: View { - - @Environment(\.colorPalette) private var colorPalette - - let symbol: PresetSymbol - let iconSize: Double - - init(_ symbol: PresetSymbol, iconSize: Double = 17) { - self.symbol = symbol - self.iconSize = iconSize - } - - var body: some View { - Group { - switch symbol.symbolType { - case .emoji: - Text(symbol.value) - .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize - 2))) - case .image: - Text(Image(symbol.value)) - .foregroundStyle(Color(presetSymbolTint: symbol.tint, palette: colorPalette)) - .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize))) - case .systemImage: - Text(Image(systemName: symbol.value)) - .foregroundStyle(Color(presetSymbolTint: symbol.tint, palette: colorPalette)) - .font(.system(size: UIFontMetrics.default.scaledValue(for: iconSize))) - } - } - .fontDesign(.monospaced) - } -} - -#Preview { - PresetSymbolView(.emoji("🍎"), iconSize: 22) -} diff --git a/Loop/Views/Presets/PresetsHistoryView.swift b/Loop/Views/Presets/PresetsHistoryView.swift index 8a1a83346a..6c0531dc68 100644 --- a/Loop/Views/Presets/PresetsHistoryView.swift +++ b/Loop/Views/Presets/PresetsHistoryView.swift @@ -7,6 +7,7 @@ // import LoopKit +import LoopKitUI import SwiftUI struct PresetsHistoryView: View { diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 04103b566a..3db340edfd 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -60,16 +60,24 @@ struct PresetsView: View { @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager @Environment(\.dismiss) private var dismiss - @State private var trainingCompletion: PresetsTrainingCompletion = PresetsTrainingCompletion() + @State private var trainingCompletion: PresetsTrainingCompletion @State private var editMode: EditMode = .inactive @State private var showingMenu: Bool = false @State private var presentCreateView: Bool = false @State private var presentTrainingNeededAlert: Bool = false + @State private var showPresetsTrainingSheet: Bool = false @State private var activeSheet: ActiveSheet? @State private var navigationPath = NavigationPath() @AppStorage("presetsSortAscending") private var presetsSortAscending: Bool = true @AppStorage("presetsSortOrder") private var selectedSortOption: PresetSortOption = .name + + init( + roundBasalRate: ((Double) -> Double)? + ) { + self.trainingCompletion = PresetsTrainingCompletion(allowDebugFeatures: FeatureFlags.allowDebugFeatures) + self.roundBasalRate = roundBasalRate + } var isDescending: Bool { !presetsSortAscending } @@ -103,7 +111,9 @@ struct PresetsView: View { PresetCard( activePreset, guardrail: settingsManager.correctionRangeGuardrailForPreset(activePreset), - expectedEndTime: temporaryPresetsManager.activeOverride?.expectedEndTime + expectedEndTime: temporaryPresetsManager.activeOverride?.expectedEndTime, + activePresetId: { temporaryPresetsManager.activePreset?.id }, + effectiveCorrectionRange: temporaryPresetsManager.effectiveCorrectionRange ) .onTapGesture { activeSheet = .presetDetent(activePreset) @@ -148,7 +158,9 @@ struct PresetsView: View { ForEach(presetsSorted) { preset in PresetCard( preset, - guardrail: settingsManager.correctionRangeGuardrailForPreset(preset) + guardrail: settingsManager.correctionRangeGuardrailForPreset(preset), + activePresetId: { temporaryPresetsManager.activePreset?.id }, + effectiveCorrectionRange: temporaryPresetsManager.effectiveCorrectionRange ) .cornerRadius(12) .onTapGesture { @@ -243,12 +255,19 @@ struct PresetsView: View { await temporaryPresetsManager.unschedulePresetReminderIfNeeded(preset) await temporaryPresetsManager.scheduleNextPresetReminder() } - } + }, + correctionRangeGuardrailForPreset: settingsManager.correctionRangeGuardrailForPreset, + impactForInsulinMultiplier: { settingsManager.therapySettings.impact(for: $0) }, + showPresetsTrainingSheet: { showPresetsTrainingSheet = true }, + suspendThreshold: { settingsManager.settings.suspendThreshold } ) + .sheet(isPresented: $showPresetsTrainingSheet) { + PresetsTrainingView(trainingCompletionConfiguration: .trainingCompletion(trainingCompletion)) + } } } case .training(let navigationPath, let startingAt, let editPresetWhenComplete): - PresetsTrainingView(navigationPath: navigationPath, startingAt: startingAt, trainingCompletion: trainingCompletion) { + PresetsTrainingView(navigationPath: navigationPath, startingAt: startingAt, trainingCompletionConfiguration: .trainingCompletion(trainingCompletion)) { if let editPresetWhenComplete { activeSheet = .editPreset(editPresetWhenComplete) } @@ -256,7 +275,14 @@ struct PresetsView: View { } } .sheet(isPresented: $presentCreateView) { - CreatePresetView() + CreatePresetView( + createPreset: settingsManager.createPreset, + impactForInsulinMultiplier: { settingsManager.therapySettings.impact(for: $0) }, + scheduleNextPresetReminder: temporaryPresetsManager.scheduleNextPresetReminder, + scheduledRange: { scheduledRange }, + setScheduleOverride: { temporaryPresetsManager.scheduleOverride = $0 }, + suspendThreshold: { settingsManager.settings.suspendThreshold } + ) } .alert(isPresented: $presentTrainingNeededAlert) { trainingNeededAlert @@ -336,7 +362,7 @@ struct PresetsView: View { } extension PresetCard { - init (_ preset: SelectablePreset, guardrail: Guardrail, expectedEndTime: PresetExpectedEndTime? = nil) { + init (_ preset: SelectablePreset, guardrail: Guardrail, expectedEndTime: PresetExpectedEndTime? = nil, activePresetId: @escaping () -> String?, effectiveCorrectionRange: @escaping () -> ClosedRange?) { var activityPresetIsModified: Bool? = nil if case let .activity(activityPreset) = preset { activityPresetIsModified = activityPreset.isModifiedFromDefault @@ -352,7 +378,9 @@ extension PresetCard { guardrail: guardrail, expectedEndTime: expectedEndTime, isScheduled: preset.isScheduled, - activityPresetIsModified: activityPresetIsModified + activityPresetIsModified: activityPresetIsModified, + activePresetId: activePresetId, + effectiveCorrectionRange: effectiveCorrectionRange ) } } diff --git a/Loop/Views/Presets/ReviewNewPresetView.swift b/Loop/Views/Presets/ReviewNewPresetView.swift deleted file mode 100644 index 997411b1f5..0000000000 --- a/Loop/Views/Presets/ReviewNewPresetView.swift +++ /dev/null @@ -1,184 +0,0 @@ -// -// ReviewNewPresetView.swift -// Loop -// -// Created by Pete Schwamb on 3/6/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopKit -import LoopKitUI -import LoopUI -import LoopAlgorithm - -struct ReviewNewPresetView: View { - @Environment(\.dismiss) private var dismiss - - @Binding var preset: NewCustomPreset - @Binding var path: NavigationPath - var scheduledRange: ClosedRange - var onCancel: () -> Void - var onComplete: (_ startPreset: Bool) -> Void - - // Add a timer to trigger updates - @State private var currentDate = Date() - private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - - // Computed property to check if start date is too soon - private var isStartDateTooSoon: Bool { - guard let startDate = preset.startDate, preset.savePreset else { return false } - return startDate < currentDate.addingTimeInterval(60) - } - - var body: some View { - CardSectionScrollView { - VStack(alignment: .leading) { - Text("New Preset") - .font(.largeTitle) - .fontWeight(.bold) - .padding(.top, 40) - } - - VStack(alignment: .leading, spacing: 10) { - Text("Review Settings") - .fontWeight(.semibold) - Text("Review your preset settings below. To make any changes, navigate back to the setting you’d like to edit. You can edit these settings after saving your preset as well.") - .font(.footnote) - } - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.accentColor) - .cornerRadius(10) - .padding(.top, 10) - - CardSection("Temporary Settings Adjustments") { - InsulinNeedsAdjustmentPreview(insulinPercentage: preset.insulinMultiplier * 100, guardrail: Guardrail.presetInsulinNeeds) - } - - CardSection { - CorrectionRangePreview( - range: preset.correctionRange, - guardrail: Guardrail.temporaryPresetCorrectionRange, - scheduledRange: scheduledRange, - veryHighInsulinNeeds: preset.veryHighInsulinNeeds - ) - } - - // Name Field - if preset.savePreset { - CardSection { - HStack { - Text("Name") - .font(.body) - - Spacer() - - Text(preset.name) - .font(.body) - .foregroundColor(.secondary) - - } - } - } - - // Duration Section - CardSection { - VStack(alignment: .leading, spacing: 0) { - HStack { - Text("Duration") - .foregroundColor(.primary) - Spacer() - Group { - if let duration = preset.duration { - Text(duration.localizedTitle) - } else { - Text("Required") - } - } - .foregroundColor(.secondary) - } - } - } - - // Schedule Toggle - if preset.savePreset, let startDate = preset.startDate { - CardSection { - HStack { - if preset.repeatOptions != .none { - Text("Start Date") - } else { - Text("Start at") - } - Spacer() - Text(DateFormatter.localizedString(from: startDate, dateStyle: .short, timeStyle: .short)) - .foregroundColor(.secondary) - } - if preset.repeatOptions != .none { - Divider() - HStack { - Text("Repeat weekly on") - Spacer() - RepeatOptionView(repeatOptions: preset.repeatOptions) - } - .padding(.vertical, 4) - } - } - - Text("Tidepool Loop will always ask you to confirm before turning on a scheduled preset.") - .font(.footnote) - .foregroundColor(.secondary) - .padding(.horizontal, 10) - .padding(.top, 4) - - } - } actionArea: { - if isStartDateTooSoon { - WarningView( - title: Text("Invalid Start Time"), - caption: Text("Start time must be at least 1 minute in the future.") - ) - } - - if preset.savePreset, preset.startDate != nil { - Button("Save and Schedule for Later") { - onComplete(false) - } - .buttonStyle(ActionButtonStyle(.primary)) - .disabled(isStartDateTooSoon) - } else if preset.savePreset { - VStack { - Button("Start Preset") { - onComplete(true) - } - .buttonStyle(ActionButtonStyle(.primary)) - Button("Save for Later") { - onComplete(false) - } - .buttonStyle(ActionButtonStyle(.secondary)) - } - } else { - Button("Start Preset") { - onComplete(true) - } - .buttonStyle(ActionButtonStyle(.primary)) - } - } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Create a Preset") - .edgesIgnoringSafeArea([.top]) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Cancel") { - onCancel() - } - } - } - // Update currentDate every second - .onReceive(timer) { _ in - currentDate = Date() - } - } -} diff --git a/Loop/Views/Presets/Training Content/Components/InsetContent.swift b/Loop/Views/Presets/Training Content/Components/InsetContent.swift deleted file mode 100644 index 126a2ac6be..0000000000 --- a/Loop/Views/Presets/Training Content/Components/InsetContent.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// InsetContent.swift -// Loop -// -// Created by Cameron Ingham on 8/26/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -struct InsetContent: View { - - let alignment: HorizontalAlignment - let spacing: Double - let padding: Double - let content: Content - - init(alignment: HorizontalAlignment = .center, spacing: Double = 24, padding: Double = 16, @ViewBuilder content: () -> Content) { - self.alignment = alignment - self.spacing = spacing - self.padding = padding - self.content = content() - } - - var body: some View { - VStack(alignment: alignment, spacing: spacing) { - content - .frame(maxWidth: .infinity, alignment: alignment == .leading ? .leading : .center) - } - .padding(padding) - .background( - RoundedRectangle(cornerRadius: 10) - .strokeBorder(Color.gray.quinary, lineWidth: 1) - ) - } -} diff --git a/Loop/Views/Presets/Training Content/Components/TimelineSteps.swift b/Loop/Views/Presets/Training Content/Components/TimelineSteps.swift deleted file mode 100644 index 4738650e96..0000000000 --- a/Loop/Views/Presets/Training Content/Components/TimelineSteps.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// TimelineSteps.swift -// Loop -// -// Created by Cameron Ingham on 8/27/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -@resultBuilder -struct TimelineBuilder { - static func buildBlock(_ components: TimelineStep...) -> [TimelineStep] { - components - } -} - -protocol TimelineStyle { - var iconSize: Double { get } - var baseIconPadding: Double { get } - var iconTint: Color { get } - var iconBackgroundColor: Color { get } - var lineWidth: Double { get } - var stepSpacing: Double { get } - var stepSeparatorColor: Color { get } - var titleFont: Font { get } - var titleColor: Color { get } - var subtitleFont: Font { get } - var subtitleColor: Color { get } -} - -extension TimelineStyle { - var iconSize: Double { 32 } - var baseIconPadding: Double { 6 } - var iconTint: Color { .accentColor } - var iconBackgroundColor: Color { iconTint.opacity(0.1) } - var lineWidth: Double { 4 } - var stepSpacing: Double { 24 } - var stepSeparatorColor: Color { iconTint.opacity(0.1) } - var titleFont: Font { .body.weight(.semibold) } - var titleColor: Color { .primary } - var subtitleFont: Font { .subheadline } - var subtitleColor: Color { .primary } -} - -struct DefaultTimelineStyle: TimelineStyle {} - -extension TimelineStyle where Self == DefaultTimelineStyle { - static var `default`: DefaultTimelineStyle { DefaultTimelineStyle() } -} - -struct TimelineStep { - let symbol: Image - let symbolInset: Double - let title: Text - let subtitle: Text - - init(symbol: Image, symbolInset: Double = 0, title: Text, subtitle: Text) { - self.symbol = symbol - self.symbolInset = symbolInset - self.title = title - self.subtitle = subtitle - } -} - -struct Timeline: View { - private let steps: [TimelineStep] - - private var style: TimelineStyle = DefaultTimelineStyle() - - init(steps: [TimelineStep]) { - self.steps = steps - } - - public init(@TimelineBuilder _ steps: () -> [TimelineStep]) { - self.steps = steps() - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - ForEach(steps.indices, id: \.self) { index in - let step = steps[index] - - ZStack(alignment: .leading) { - GeometryReader { proxy in - style.stepSeparatorColor - .frame(width: style.lineWidth) - .padding(.top, index == 0 ? proxy.size.height / 2 : 0) - .padding(.bottom, index == steps.count - 1 ? proxy.size.height / 2 : 0) - .padding(.leading, style.iconSize / 2 - style.lineWidth / 2) - .mask { - InverseCircleMask(diameter: style.iconSize) - .fill(Color(UIColor.systemBackground), style: FillStyle(eoFill: true)) - } - } - - HStack(spacing: 12) { - step.symbol - .resizable() - .scaledToFit() - .foregroundStyle(style.iconTint) - .padding(style.baseIconPadding + step.symbolInset) - .frame(width: style.iconSize, height: style.iconSize) - .background( - Circle() - .fill(style.iconBackgroundColor) - ) - - VStack(alignment: .leading) { - step.title - .font(style.titleFont) - .foregroundStyle(style.titleColor) - - step.subtitle - .font(style.subtitleFont) - .foregroundStyle(style.subtitleColor) - } - } - } - - if index < steps.count - 1 { - style.stepSeparatorColor - .frame(width: style.lineWidth, height: style.stepSpacing) - .padding(.leading, style.iconSize / 2 - style.lineWidth / 2) - } - } - } - } - - func style(_ style: TimelineStyle) -> Timeline { - var copy = self - copy.style = style - return copy - } -} - -private struct InverseCircleMask: Shape { - - let diameter: Double - - func path(in rect: CGRect) -> Path { - var path = Path() - - // Fills all available space - path.addRect(rect) - - // Creates a hole in the middle with the specified diameter - let hole = CGRect( - x: 0, - y: (rect.height - diameter) / 2, - width: diameter, - height: diameter - ) - - // Cuts the hole out of the path - path.addEllipse(in: hole) - - return path - } -} diff --git a/Loop/Views/Presets/Training Content/PresetsTraining.swift b/Loop/Views/Presets/Training Content/PresetsTraining.swift deleted file mode 100644 index ada762997b..0000000000 --- a/Loop/Views/Presets/Training Content/PresetsTraining.swift +++ /dev/null @@ -1,485 +0,0 @@ -// -// PresetsTraining.swift -// Loop -// -// Created by Cameron Ingham on 8/26/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import Foundation -import SwiftUI - -@Observable -class PresetsTrainingCompletion { - var completedChapters: [PresetsTraining.Chapter: Bool] { - get { - guard let rawValue = UserDefaults.standard.string(forKey: "completedPresetTrainingChapters") else { - return .default - } - - return [PresetsTraining.Chapter: Bool](rawValue: rawValue) ?? .default - } - set { - withMutation(keyPath: \.completedChapters) { - UserDefaults.standard.setValue(newValue.rawValue, forKey: "completedPresetTrainingChapters") - } - } - } - - var isComplete: Bool { - completedChapters.values.allSatisfy({ $0 }) - } - - func complete(to chapter: PresetsTraining.Chapter) { - guard FeatureFlags.allowDebugFeatures else { return } - - guard let chapterIndex = PresetsTraining.Chapter.allCases.firstIndex(of: chapter) else { - return - } - - for index in 0.. String { - switch self { -// Temporarily removed -- will be moved to general onboarding with LOOP-5238 -// case .entryPoint: -// NSLocalizedString("Presets Training", comment: "") -// case .tier1(let tier1Chapter): -// switch tier1Chapter { -// case .introduction(let introduction): -// switch introduction { -// case .introduction: -// NSLocalizedString("Part 1: Introduction to Presets", comment: "") -// case .exercisingWithLoop: -// String(format: NSLocalizedString("Exercising with %1$@", comment: ""), appName) -// case .timingYourPresets: -// NSLocalizedString("Timing Your Presets for Exercise", comment: "") -// case .safeGlucoseRanges: -// NSLocalizedString("Safe Glucose Ranges for Exercise", comment: "") -// case .performanceHistory: -// NSLocalizedString("Performance History", comment: "") -// case .complete: -// NSLocalizedString("Part 1: Complete", comment: "") -// } -// } - case .tier2(let tier2Chapter): - switch tier2Chapter { - case .customizingPresets(let customizingPresets): - switch customizingPresets { - case .customizingPresets: - NSLocalizedString("Customizing Presets", comment: "") - case .overallInsulin: - NSLocalizedString("Overall Insulin", comment: "") - case .correctionRange: - NSLocalizedString("Correction Range", comment: "") - } - case .illness(let illness): - switch illness { - case .commonUses: - NSLocalizedString("Common Uses of Presets", comment: "") - case .presetsForIllness: - NSLocalizedString("Presets for Illness", comment: "") - case .overallInsulin: - NSLocalizedString("Overall Insulin", comment: "") - case .correctionRange: - NSLocalizedString("Correction Range", comment: "") - case .duration: - NSLocalizedString("Duration", comment: "") - case .impactOnBolusing: - NSLocalizedString("Impact on Bolusing", comment: "") - } - case .dailyActivities(let dailyActivities): - switch dailyActivities { - case .commonUses: - NSLocalizedString("Common Uses of Presets", comment: "") - case .presetsForDailyActivities: - NSLocalizedString("Presets for Daily Activity", comment: "") - case .overallInsulin: - NSLocalizedString("Overall Insulin", comment: "") - case .correctionRange: - NSLocalizedString("Correction Range", comment: "") - case .savedPresets: - NSLocalizedString("Saved Presets", comment: "") - } - case .exercise(let exercise): - switch exercise { - case .commonUses: - NSLocalizedString("Common Uses of Presets", comment: "") - case .presetsForExercise: - NSLocalizedString("Presets for Exercise", comment: "") - case .perceivedIntensity: - NSLocalizedString("Perceived Intensity", comment: "") - case .lightToModerateExercise: - NSLocalizedString("Light-to-Moderate Intensity Exercise", comment: "") - case .highIntensityExercise: - NSLocalizedString("High-Intensity Exercise", comment: "") - case .mixedIntensityExercise: - NSLocalizedString("Mixed-Intensity Exercise", comment: "") - case .exerciseAndGlucoseActiveInsulin, - .exerciseAndGlucoseTimeOfDay, - .exerciseAndGlucoseMealTiming, - .exerciseAndGlucoseCompetitionStress: - NSLocalizedString("Exercise and Your Glucose Levels", comment: "") - case .preventingLows: - NSLocalizedString("Preventing Lows", comment: "") - case .unplannedActivity: - NSLocalizedString("Unplanned Activity", comment: "") - } - } - case .trainingComplete: - NSLocalizedString("Training Complete", comment: "") - } - } - - func previous(startingFrom: Chapter) -> Step? { - switch self { -// Temporarily removed -- will be moved to general onboarding with LOOP-5238 -// case .entryPoint: nil -// case .tier1(let tier1Chapter): -// switch tier1Chapter { -// case .introduction(let introduction): -// switch introduction { -// case .introduction: chapter != startingFrom ? nil : .entryPoint -// case .exercisingWithLoop: .tier1(.introduction(.introduction)) -// case .timingYourPresets: .tier1(.introduction(.exercisingWithLoop)) -// case .safeGlucoseRanges: .tier1(.introduction(.timingYourPresets)) -// case .performanceHistory: .tier1(.introduction(.safeGlucoseRanges)) -// case .complete: .tier1(.introduction(.performanceHistory)) -// } -// } - case .tier2(let tier2Chapter): - switch tier2Chapter { - case .customizingPresets(let customizingPresets): - switch customizingPresets { -// Temporarily removed -- will be moved to general onboarding with LOOP-5238 -// case .customizingPresets: chapter != startingFrom ? nil : .tier1(.introduction(.complete)) - case .customizingPresets: nil - case .overallInsulin: .tier2(.customizingPresets(.customizingPresets)) - case .correctionRange: .tier2(.customizingPresets(.overallInsulin)) - } - case .illness(let illness): - switch illness { - case .commonUses: chapter != startingFrom ? nil : .tier2(.customizingPresets(.correctionRange)) - case .presetsForIllness: .tier2(.illness(.commonUses)) - case .overallInsulin: .tier2(.illness(.presetsForIllness)) - case .correctionRange: .tier2(.illness(.overallInsulin)) - case .duration: .tier2(.illness(.correctionRange)) - case .impactOnBolusing: .tier2(.illness(.duration)) - } - case .dailyActivities(let dailyActivities): - switch dailyActivities { - case .commonUses: chapter != startingFrom ? nil : .tier2(.illness(.impactOnBolusing)) - case .presetsForDailyActivities: .tier2(.dailyActivities(.commonUses)) - case .overallInsulin: .tier2(.dailyActivities(.presetsForDailyActivities)) - case .correctionRange: .tier2(.dailyActivities(.overallInsulin)) - case .savedPresets: .tier2(.dailyActivities(.correctionRange)) - } - case .exercise(let exercise): - switch exercise { - case .commonUses: chapter != startingFrom ? nil : .tier2(.dailyActivities(.savedPresets)) - case .presetsForExercise: .tier2(.exercise(.commonUses)) - case .perceivedIntensity: .tier2(.exercise(.presetsForExercise)) - case .lightToModerateExercise: .tier2(.exercise(.perceivedIntensity)) - case .highIntensityExercise: .tier2(.exercise(.lightToModerateExercise)) - case .mixedIntensityExercise: .tier2(.exercise(.highIntensityExercise)) - case .exerciseAndGlucoseActiveInsulin: .tier2(.exercise(.mixedIntensityExercise)) - case .exerciseAndGlucoseTimeOfDay: .tier2(.exercise(.exerciseAndGlucoseActiveInsulin)) - case .exerciseAndGlucoseMealTiming: .tier2(.exercise(.exerciseAndGlucoseTimeOfDay)) - case .exerciseAndGlucoseCompetitionStress: .tier2(.exercise(.exerciseAndGlucoseMealTiming)) - case .preventingLows: .tier2(.exercise(.exerciseAndGlucoseCompetitionStress)) - case .unplannedActivity: .tier2(.exercise(.preventingLows)) - } - } - case .trainingComplete: chapter != startingFrom ? nil : .tier2(.exercise(.unplannedActivity)) - } - } - - func next() -> (Step?, completedChapter: Chapter?) { - switch self { -// Temporarily removed -- will be moved to general onboarding with LOOP-5238 -// case .entryPoint: (.tier1(.introduction(.introduction)), .entry) -// case .tier1(let tier1Chapter): -// switch tier1Chapter { -// case .introduction(let introduction): -// switch introduction { -// case .introduction: (.tier1(.introduction(.exercisingWithLoop)), nil) -// case .exercisingWithLoop: (.tier1(.introduction(.timingYourPresets)), nil) -// case .timingYourPresets: (.tier1(.introduction(.safeGlucoseRanges)), nil) -// case .safeGlucoseRanges: (.tier1(.introduction(.performanceHistory)), nil) -// case .performanceHistory: (.tier1(.introduction(.complete)), nil) -// case .complete: (.tier2(.customizingPresets(.customizingPresets)), .introduction) -// } -// } - case .tier2(let tier2Chapter): - switch tier2Chapter { - case .customizingPresets(let customizingPresets): - switch customizingPresets { - case .customizingPresets: (.tier2(.customizingPresets(.overallInsulin)), nil) - case .overallInsulin: (.tier2(.customizingPresets(.correctionRange)), nil) - case .correctionRange: (.tier2(.illness(.commonUses)), .customizingPresets) - } - case .illness(let illness): - switch illness { - case .commonUses: (.tier2(.illness(.presetsForIllness)), nil) - case .presetsForIllness: (.tier2(.illness(.overallInsulin)), nil) - case .overallInsulin: (.tier2(.illness(.correctionRange)), nil) - case .correctionRange: (.tier2(.illness(.duration)), nil) - case .duration: (.tier2(.illness(.impactOnBolusing)), nil) - case .impactOnBolusing: (.tier2(.dailyActivities(.commonUses)), .illness) - } - case .dailyActivities(let dailyActivities): - switch dailyActivities { - case .commonUses: (.tier2(.dailyActivities(.presetsForDailyActivities)), nil) - case .presetsForDailyActivities: (.tier2(.dailyActivities(.overallInsulin)), nil) - case .overallInsulin: (.tier2(.dailyActivities(.correctionRange)), nil) - case .correctionRange: (.tier2(.dailyActivities(.savedPresets)), nil) - case .savedPresets: (.tier2(.exercise(.commonUses)), .dailyActivities) - } - case .exercise(let exercise): - switch exercise { - case .commonUses: (.tier2(.exercise(.presetsForExercise)), nil) - case .presetsForExercise: (.tier2(.exercise(.perceivedIntensity)), nil) - case .perceivedIntensity: (.tier2(.exercise(.lightToModerateExercise)), nil) - case .lightToModerateExercise: (.tier2(.exercise(.highIntensityExercise)), nil) - case .highIntensityExercise: (.tier2(.exercise(.mixedIntensityExercise)), nil) - case .mixedIntensityExercise: (.tier2(.exercise(.exerciseAndGlucoseActiveInsulin)), nil) - case .exerciseAndGlucoseActiveInsulin: (.tier2(.exercise(.exerciseAndGlucoseTimeOfDay)), nil) - case .exerciseAndGlucoseTimeOfDay: (.tier2(.exercise(.exerciseAndGlucoseMealTiming)), nil) - case .exerciseAndGlucoseMealTiming: (.tier2(.exercise(.exerciseAndGlucoseCompetitionStress)), nil) - case .exerciseAndGlucoseCompetitionStress: (.tier2(.exercise(.preventingLows)), nil) - case .preventingLows: (.tier2(.exercise(.unplannedActivity)), nil) - case .unplannedActivity: (.trainingComplete, .exercise) - } - } - case .trainingComplete: (nil, .trainingComplete) - } - } - - var chapter: Chapter { - switch self { -// Temporarily removed -- will be moved to general onboarding with LOOP-5238 -// case .entryPoint: .entry -// case .tier1: .introduction - case .tier2(.customizingPresets): .customizingPresets - case .tier2(.illness): .illness - case .tier2(.dailyActivities): .dailyActivities - case .tier2(.exercise): .exercise - case .trainingComplete: .trainingComplete - } - } - - var contentBackground: Color { - switch self { - case .tier2(.dailyActivities(.commonUses)), - .tier2(.exercise(.commonUses)), - .tier2(.illness(.commonUses)): - Color(UIColor.secondarySystemBackground) - default: - Color(UIColor.systemBackground) - } - } - } - - var navigationPath: [Step] - - var currentStep: Step { - navigationPath.last ?? startingAt.firstStep - } - -// Temporarily changed -- will be moved to general onboarding with LOOP-5238 -// private(set) var startingAt: Chapter = .entry - private(set) var startingAt: Chapter = .customizingPresets - - let trainingCompletion: PresetsTrainingCompletion - - init( - navigationPath: [Step] = [], - startingAt: Chapter? = nil, - trainingCompletion: PresetsTrainingCompletion = PresetsTrainingCompletion() - ) { - self.navigationPath = navigationPath - self.trainingCompletion = trainingCompletion - - if let startingAt { - self.startingAt = startingAt - } else { - var startingAt: Chapter? - - Chapter.allCases.reversed().forEach { chapter in - if trainingCompletion.completedChapters[chapter] != true { - startingAt = chapter - } - } - - if let startingAt { - self.startingAt = startingAt - } else { -// Temporarily changed -- will be moved to general onboarding with LOOP-5238 -// self.startingAt = .entry - self.startingAt = .customizingPresets - } - } - } - - func next() { - let (next, completedChapter) = currentStep.next() - if let next { - navigationPath.append(next) - } - - if let completedChapter, trainingCompletion.completedChapters[completedChapter] != true { - trainingCompletion.completedChapters[completedChapter] = true - } - } -} - -extension Dictionary: @retroactive RawRepresentable where Key == PresetsTraining.Chapter, Value == Bool { - public init?(rawValue: String) { - guard - let data = rawValue.data(using: .utf8), - let result = try? JSONDecoder().decode([Key: Value].self, from: data) - else { - return nil - } - - self = result - } - - public var rawValue: String { - guard - let data = try? JSONEncoder().encode(self), - let result = String(data: data, encoding: .utf8) - else { - return "{}" - } - - return result - } - - public static var `default`: Self = PresetsTraining.Chapter.allCases - .reduce([:]) { partialResult, chapter in - var partial = partialResult - partial[chapter] = false - return partial - } -} diff --git a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift b/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift deleted file mode 100644 index 8dfc433812..0000000000 --- a/Loop/Views/Presets/Training Content/PresetsTrainingContent.swift +++ /dev/null @@ -1,1236 +0,0 @@ -// -// PresetsTrainingContent.swift -// Loop -// -// Created by Cameron Ingham on 8/26/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import LoopAlgorithm -import LoopKit -import LoopKitUI -import SwiftUI - -extension PresetsTraining { - enum CTA { - case start - case `continue` - case close - case closeOrContinue(_ to: String, chapter: Chapter) - } -} - -protocol PresetsTrainingContent { - associatedtype B: View - func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, next: @escaping () -> Void) -> B - var cta: PresetsTraining.CTA? { get } -} - -extension PresetsTraining.Step: PresetsTrainingContent { - @ViewBuilder - func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, next: @escaping () -> Void) -> some View { - switch self { -// Temporarily removed -- will be moved to general onboarding with LOOP-5238 -// case .entryPoint: -// if let image = Image("PresetsTrainingEntryHero") { -// image -// .resizable() -// .scaledToFit() -// .frame(maxWidth: .infinity) -// } -// -// EstimatedReadTime(.minutes(3)) -// -// Text("Presets allow you temporarily adjust your settings for events like meals, exercise, illness, or hormonal changes that may affect your diabetes management.") -// -// VStack(alignment: .leading) { -// Text("We'll walk you through the following:") -// -// BulletedListView { -// Text("How Presets Work") -// Text("Using pre-configured presets") -// Text("Timing your presets for exercise") -// Text("Safe Glucose Ranges for Exercise") -// } -// .padding(.leading, 8) -// } -// -// case .tier1(let tier1Chapter): -// switch tier1Chapter { -// case .introduction(let introduction): -// switch introduction { -// case .introduction: -// if let image = Image("PresetsTrainingEntryHero") { -// image -// .resizable() -// .scaledToFit() -// .frame(maxWidth: .infinity) -// } -// -// EstimatedReadTime(.minutes(3)) -// -// VStack(alignment: .leading) { -// Text("With a preset, you can:") -// -// BulletedListView { -// Text("Adjust your overall insulin needs") -// Text("Set an adjusted correction range") -// Text("Choose a duration") -// Text("Schedule a preset in advance") -// } -// .padding(.leading, 8) -// } -// -// VStack(alignment: .leading) { -// Text("Adjusting Overall Insulin Needs") -// .font(.title2.bold()) -// -// Text("Overall insulin should be adjusted when your body needs more or less insulin than normal.") -// } -// -// VStack(alignment: .leading) { -// Text("Adjusting Correction Range") -// .font(.title2.bold()) -// -// Text("The correction range is a safety setting. Adjusting it can help reduce the risk of low glucose if you expect unusual changes.") -// } -// -// case .exercisingWithLoop: -// if let image = Image("PresetsTrainingExercisingWithLoopHero") { -// image -// .resizable() -// .scaledToFit() -// .frame(maxWidth: .infinity) -// } -// -// Text("Exercise and physical activity are common uses for presets.") -// -// Text("\(appName) has a few preset options designed to help with your insulin management. We designed these for various types of physical activities.") -// -// ActivityPreset.bulletList(full: true) -// .padding(.leading, 8) -// -// Text("These presets are a starting point to help manage your glucose. You may need to work with your healthcare provider to edit them to meet your personal diabetes needs.") -// -// case .timingYourPresets: -// Text("\(appName) suggests starting a preset for exercise at least 1 hour ahead of time. Keep it on until you finish your activity.") -// -// Text("If you forget to turn on a preset, turn it on as soon as you remember and keep it on until the activity ends.") -// -// InsetContent { -// Timeline { -// TimelineStep( -// symbol: Image(systemName: "clock"), -// title: Text("1 Hour Before"), -// subtitle: Text("Enable your preset") -// ) -// -// TimelineStep( -// symbol: Image(systemName: "figure.run"), -// title: Text("During Activity"), -// subtitle: Text("Keep preset on throughout your exercise") -// ) -// -// TimelineStep( -// symbol: Image(systemName: "checkmark"), -// symbolInset: 2, -// title: Text("Activity Ends"), -// subtitle: Text("Turn off preset when you finish exercising") -// ) -// } -// } -// -// Text("You can plan ahead and schedule presets to start at a certain date and time. The app will send you a reminder and ask if you'd like to start the preset.") -// -// case .safeGlucoseRanges: -// Text("Before starting exercise, make sure to check your glucose.") -// -// Text("Aim for your glucose to be between \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), includeUnit: false)) and \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: true)) before exercising. Based on current research, this can help prevent high and low levels during or after your workout.") -// -// InsetContent { -// Text("Safe Starting Glucose Range") -// .bold() -// -// Group { -// Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: false)) ") -// .font(.system(size: UIFontMetrics.default.scaledValue(for: 32)).weight(.heavy)) -// + Text(displayGlucosePreference.unit.localizedShortUnitString) -// } -// .foregroundStyle(colorPalette.carbTintColor) -// -// Text("\(Image(systemName: "exclamationmark.circle")) Consider a small snack to prevent lows") -// .font(.footnote) -// .foregroundStyle(.secondary) -// } -// -// Callout(.caution, title: Text("Starting a preset, especially one decreasing insulin, when your glucose is above \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: true)) may reduce its effectiveness and impact your results.")) -// .padding(.horizontal, -16) -// -// Text("Always check your glucose before, during, and after any activity to ensure safe and optimal outcomes.") -// -// case .performanceHistory: -// if let image = Image("PresetsTrainingPerformanceHistoryHero") { -// image -// .resizable() -// .scaledToFit() -// .frame(maxWidth: .infinity) -// } -// -// Text("Performance History gives you a clear picture of how each preset helped manage your glucose.") -// -// Text("You can quickly review a summary of key data during the preset and for the six hours that follow to understand the full impact of the preset’s settings.") -// -// Text("To get started, tap Presets, then Performance History, and select the preset you want to review.") -// -// Text("Performance history is available for up to seven days.") -// -// case .complete: -// Text("You can now use the following presets:") -// -// ActivityPreset.bulletList(full: false) -// -// Text("Complete Part 2 to enable preset editing and creation.") -// } -// } - case .tier2(let tier2Chapter): - switch tier2Chapter { - case .customizingPresets(let customizingPresets): - switch customizingPresets { - case .customizingPresets: - if let image = Image("PresetsTrainingCustomizingPresetsHero") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - EstimatedReadTime(.minutes(10)) - - VStack(alignment: .leading) { - Text("Learn to tailor your settings! This training will teach you how to:") - .fixedSize(horizontal: false, vertical: true) - - BulletedListView { - Text("Configure each setting") - Text("Use Presets for when you are sick") - Text("Use Presets for Daily Activities") - Text("Use Presets for Exercise") - } - .padding(.leading, 8) - } - - Text("Complete this training to learn how to edit the pre-configured presets and adjust them to fit your needs, or create your own custom presets.") - - case .overallInsulin: - if let image = Image("PresetsTrainingOverallInsulinHero") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - VStack(alignment: .leading) { - Text("The \"Overall Insulin\" percentage controls total insulin delivery by adjusting your:") - - BulletedListView { - Text("Basal Rate") - Text("Carb Ratio") - Text("Insulin Sensitivity Factor (ISF)") - } - .padding(.leading, 8) - } - - Text("At 100%, \(appName) assumes your insulin needs are the same as usual.") - - Text("When deciding to adjust your overall insulin, **ask yourself, does my body need more or less than usual?**") - - Callout(.note) { - BulletedListView { - Text("A percentage **below 100%** tells the system you need **less** insulin") - - Text("A percentage **above 100%** tells the system you need **more** insulin") - } - .font(.footnote) - .padding(.top, 8) - } - .padding(.horizontal, -16) - - case .correctionRange: - if let image = Image("PresetsTrainingCorrectionRangeHero") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - Text("Correction range is a **safety setting**. Changing it can help lower your risk of going low if you expect unusual changes.") - - Text("Changing it can lower the chance of your glucose levels going too low if you expect unusual changes.") - - Text("Choose the glucose value (or values) you want \(appName) to target when changing how much basal insulin you get.") - - Text("You don’t need to change the correction range for every preset. But before you decide to change it, ask yourself: *Am I more likely to go high or low during this time?*") - - Callout(.note) { - Text("To help avoid lows, set a range **higher** than your typical correction range.") - .font(.footnote) - .padding(.top, 8) - } - .padding(.horizontal, -16) - } - case .illness(let illness): - switch illness { - case .commonUses: - Text("You can use presets for a variety of situations. Explore the uses below to learn tips for these common scenarios.") - - VStack(spacing: 16) { - CommonUseStep( - title: Text("Presets for Illness"), - readTime: .minutes(3), - onTapGesture: next - ) - - CommonUseStep( - title: Text("Presets for Daily Activity"), - readTime: .minutes(2) - ) - .disabled(true) - - CommonUseStep( - title: Text("Presets for Exercise"), - readTime: .minutes(5) - ) - .disabled(true) - } - - case .presetsForIllness: - Text("Physical stress, like illness, can cause glucose to rise.") - - InsetContent(alignment: .leading) { - Text("**Example:** Paloma Porpoise sees her glucose is running higher than usual. She decides to create a preset to help manage it while she's sick.") - } - - Text("Let's look at the settings that can change how much insulin Paloma get.") - - case .overallInsulin: - Text("Paloma wants \(appName) to know she needs more insulin than usual.") - - TherapySettingsExampleView( - title: NSLocalizedString("Paloma’s Current Therapy Settings", comment: ""), - components: [ - .basalRate(0.5), - .carbRatio(13), - .isf(50) - ] - ) - - Text("She can do this by raising her **Overall Insulin** setting. This tells \(appName) to deliver more than her usual amount, making her insulin settings stronger.") - - if let image = Image("PresetsTrainingIllnessOverallInsulin") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - TherapySettingsExampleView( - title: NSLocalizedString("Paloma’s Adjusted Therapy Settings", comment: ""), - components: [ - .basalRate(0.6), - .carbRatio(12), - .isf(45) - ], - style: .adjusted - ) - - case .correctionRange: - Text("While sick, Paloma expects to eat less or not absorb everything she eats.") - - TherapySettingsExampleView( - title: NSLocalizedString("Paloma’s Current Therapy Settings", comment: ""), - component: .correctionRange(105...110) - ) - - Text("To help prevent lows, she will increase her correction range.") - - if let image = Image("PresetsTrainingIllnessCorrectionRange") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - TherapySettingsExampleView( - title: NSLocalizedString("Paloma’s Adjusted Therapy Settings", comment: ""), - component: .correctionRange(130...140), - style: .adjusted - ) - - case .duration: - Text("You can choose how long your preset lasts.") - - Text("Since Paloma doesn't know when she'll feel better, she sets hers to “Until I Turn Off”.") - - if let image = Image("PresetsTrainingIllnessDuration1") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - Text("To be safe, \(appName) will remind her after 24 hours that the preset is still running.") - - if let image = Image("PresetsTrainingIllnessDuration2") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - Text("While turned on, Paloma’s preset will display on the home screen and in her Presets list.") - - case .impactOnBolusing: - Text("Later that day, Paloma eats a meal with about 30g of carbs.") - - Text("How does her preset impact her bolus recommendation?") - - Text("Her preset is set to **110%**, which is more than she usually needs. This means \(appName) will make her basal rates, carb ratio, and insulin sensitivity factor (ISF) stronger. ") - - TherapySettingsExampleView( - title: NSLocalizedString("Her bolus recommendation is higher than usual because her overall insulin is set higher.", comment: ""), - component: .bolusRecommendation( - starting: 3.9, - ending: 4.3, - action: NSLocalizedString("With Preset On", comment: "") - ), - style: .adjusted - ) - } - case .dailyActivities(let dailyActivities): - switch dailyActivities { - case .commonUses: - Text("You can use presets for a variety of situations. Explore the uses below to learn tips for these common scenarios.") - - VStack(spacing: 16) { - CommonUseStep( - title: Text("Presets for Illness"), - readTime: .minutes(3) - ) - - CommonUseStep( - title: Text("Presets for Daily Activity"), - readTime: .minutes(2), - onTapGesture: next - ) - - CommonUseStep( - title: Text("Presets for Exercise"), - readTime: .minutes(5) - ) - .disabled(true) - } - - case .presetsForDailyActivities: - Text("For some people, routine chores and everyday activities can affect glucose levels similar to exercise.") - - InsetContent(alignment: .leading) { - Text("**Example:** Omar Octopus wants to create a preset for some yard work he’ll be doing around the house.") - } - - Text("Let's look at the settings that will impact Omar’s insulin delivery.") - - VStack(alignment: .leading, spacing: 16) { - Text("Learn More") - .font(.headline.weight(.semibold)) - - PlayMediaButton( - image: Image("ADLs"), - title: Text("Managing Activities of Daily Living"), - duration: .minutes(5) + .seconds(36) - ) - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color.presets.opacity(0.1)) - ) - - case .overallInsulin: - Text("Omar asks himself, **do I expect I will need more or less insulin than usual?**") - - Text("Since he doesn’t plan to push himself too hard, he expects his insulin needs to stay the same, so he leaves the setting at 100%.") - - if let image = Image("PresetsTrainingDailyActivityOverallInsulin") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - Callout(.note) { - Text("Pay attention to your insulin needs before and after exercising, playing sports, or doing unusually hard physical labor.") - } - .padding(.horizontal, -16) - - case .correctionRange: - Text("For activities that raise your risk of going low, you can set a higher temporary correction range.") - - Text("This range is usually higher than your correction range when you are not exercising.") - - Text("Because Omar has gone low while working outdoors in the past, he raises his preset correction range to help prevent another low.") - - TherapySettingsExampleView( - title: NSLocalizedString("Omar’s Current Therapy Settings", comment: ""), - component: .correctionRange(110...120) - ) - - Text("Omar sets his correction range a little higher, to \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 160), includeUnit: false)) \(displayGlucosePreference.unit.localizedShortUnitString). This tells \(appName) to step in sooner.") - - if let image = Image("PresetsTrainingDailyActivityCorrectionRange") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - case .savedPresets: - if let image = Image("PresetsTrainingDailyActivitySavedPresets") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - Text("Once saved, Omar’s new preset will display in his Presets lists.") - - Callout(.note) { - Text("If your activity has a higher risk of low glucose, start a physical activity preset at least **1 hour before you begin** and keep it on until you finish.") - - Text("If you expect your glucose to rise during the activity, you may not need a preset.") - } - .padding(.horizontal, -16) - - } - case .exercise(let exercise): - switch exercise { - case .commonUses: - Text("Presets can be used for a variety of situations. Explore the uses below to learn tips for these common scenarios.") - - VStack(spacing: 16) { - CommonUseStep( - title: Text("Presets for Illness"), - readTime: .minutes(3) - ) - - CommonUseStep( - title: Text("Presets for Daily Activity"), - readTime: .minutes(2) - ) - - CommonUseStep( - title: Text("Presets for Exercise"), - readTime: .minutes(5), - onTapGesture: next - ) - } - case .presetsForExercise: - Text("Exercise is a common reason to use a preset.") - - Text("Different kinds of exercise and their intensity levels can affect your glucose levels in different ways.") - - Text("Depending on the activity, you may notice a few common patterns when it comes to your insulin needs:") - - BulletedListView { - Text("no change needed") - Text("you need **less** insulin than usual") - Text("you need **more** insulin than usual") - } - - Callout(.note) { - Text("These patterns are based on published exercise consensus guidelines and are meant to be used as a starting point. What works for one person may not work for you.") - } - .padding(.horizontal, -16) - - case .perceivedIntensity: - Text("Recognizing how hard you feel you're working during exercise can help you understand its impact on your glucose levels.") - - Text("Consider an exercise you do regularly and think about how hard you push yourself.") - - Text("Use the slider to rate the effort on a scale of 0–10, with 10 being the hardest you’ve ever worked.") - - IntensityInfo() - - VStack(alignment: .leading, spacing: 16) { - Text("Learn More") - .font(.headline.weight(.semibold)) - - PlayMediaButton( - image: Image("Same Activity Different Intensity"), - title: Text("Same Activity, Different Intensity"), - duration: .minutes(6) + .seconds(34) - ) - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color.presets.opacity(0.1)) - ) - - case .lightToModerateExercise: - if let image = Image("PresetsTrainingExerciseLightToModerateHero") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - Text("Light-to-moderate intensity exercise can cause a drop in glucose levels. This is because your body uses glucose (or sugar) for energy during physical activity.") - - InsetContent { - VStack(spacing: 4) { - Text("Aerobic") - .font(.title2.bold()) - - Text("Continuous or exercise without breaks") - .frame(maxWidth: .infinity) - } - - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline, spacing: 16) { - Bullet(color: .secondary) - - VStack(alignment: .leading, spacing: 4) { - Text("Walking") - - HStack(alignment: .center, spacing: 2) { - Text("\(Image(systemName: "lightbulb.max"))") - - Text(" **Tip** Use your \(Image(systemName: "figure.walk")) **Walking** preset") - } - .padding(.vertical, 4) - .padding(.horizontal, 6) - .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - HStack(alignment: .firstTextBaseline, spacing: 16) { - Bullet(color: .secondary) - - Text("Hiking") - } - .frame(maxWidth: .infinity, alignment: .leading) - - HStack(alignment: .firstTextBaseline, spacing: 16) { - Bullet(color: .secondary) - - VStack(alignment: .leading, spacing: 4) { - Text("Jogging") - - HStack(alignment: .center, spacing: 2) { - Text("\(Image(systemName: "lightbulb.max"))") - - Text(" **Tip** Use your \(Image(systemName: "figure.run")) **Jogging** preset") - } - .padding(.vertical, 4) - .padding(.horizontal, 6) - .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - HStack(alignment: .firstTextBaseline, spacing: 16) { - Bullet(color: .secondary) - - Text("Swimming") - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - Text("For these activities, consider setting your insulin needs to **less than 100%**.") - - if let image = Image("PresetsTrainingExerciseLightToModerate") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - Callout(.note) { - Text("These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you.") - } - .padding(.horizontal, -16) - - case .highIntensityExercise: - if let image = Image("PresetsTrainingExerciseHighHero") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - Text("High-intensity exercise means pushing yourself to your **maximum effort**. It is so hard that talking is nearly impossible, and you can’t keep it up for very long.") - - Text("During this kind of hard exercise, your body may release hormones that raise glucose. This is more common in the morning before eating.") - - InsetContent { - VStack(spacing: 4) { - Text("Aerobic") - .font(.title2.bold()) - - Text("Explosive sprints or bursts") - .frame(maxWidth: .infinity) - } - - BulletedListView(bulletColor: .secondary) { - Text("Power lifting") - Text("CrossFit") - Text("100m sprint") - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - Text("For these activities, consider setting your insulin needs to **more than 100%**.") - - Text("That said, insulin needs vary from person to person. Some people find they don’t need to adjust their insulin at all for high-intensity exercise.") - - Text("If you haven’t noticed a rise in glucose with high-intensity exercise, it may be due to:") - - BulletedListView { - Text("Starting your exercise with high active insulin") - Text("Automated insulin adjustments by \(appName) reduce a noticeable rise in glucose") - Text("The exercise may not be vigorous enough to produce these results") - } - - if let image = Image("PresetsTrainingExerciseHigh") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - Text("When using high-insulin presets, **you may not need to start your preset 1 hour before**.") - - Callout(.note) { - Text("These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you.") - } - .padding(.horizontal, -16) - - case .mixedIntensityExercise: - if let image = Image("PresetsTrainingExerciseMixedHero") { - image - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - } - - Text("Mixed-intensity exercise may cause only small changes in glucose levels. Your glucose may go up or down.") - - InsetContent { - VStack(spacing: 4) { - Text("Aerobic") - .font(.title2.bold()) - - Text("Combination of high and low intensity") - .frame(maxWidth: .infinity) - } - - BulletedListView(bulletColor: .secondary) { - Text("Soccer") - Text("Interval Training ") - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - Text("For mixed-intensity activity:") - - BulletedListView { - Text("If your glucose goes up, you may only need a small increase in insulin — less than you would for high-intensity activity.") - - Text("If your glucose goes down, you may only need a small decrease in insulin — less than you would for low to moderate-intensity activity.") - } - - Callout(.note) { - Text("These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you.") - } - .padding(.horizontal, -16) - - VStack(alignment: .leading, spacing: 16) { - Text("Learn More") - .font(.headline.weight(.semibold)) - - PlayMediaButton( - image: Image("Mixed Exercise"), - title: Text("Navigating the Challenges of Mixed Exercise"), - duration: .minutes(3) + .seconds(27) - ) - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color.presets.opacity(0.1)) - ) - - case .exerciseAndGlucoseActiveInsulin: - Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") - - TintedContent( - tint: colorPalette.insulinTintColor, - icon: Image(systemName: "cross.vial"), - title: Text("Active Insulin") - ) { - Text("If you have active insulin in your body when you start exercising, you generally have an increased risk of low glucose.") - - TintedTip(text: Text("**Tip:** Try exercising when your active insulin is close to zero at the start of an activity.")) - } - - case .exerciseAndGlucoseTimeOfDay: - Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") - - TintedContent( - tint: colorPalette.carbTintColor, - icon: Image(systemName: "clock"), - title: Text("Time of Day") - ) { - Text("Morning exercise before eating (like a fasted jog) usually causes a smaller drop in glucose levels and may even promote a rise, compared to afternoon exercise.") - - if dynamicTypeSize < .accessibility1 { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Morning Exercise") - .foregroundStyle(colorPalette.carbTintColor) - .font(.subheadline.weight(.semibold)) - - Text("Smaller glucose drop") - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(12) - .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) - - VStack(alignment: .leading, spacing: 4) { - Text("Afternoon Exercise") - .foregroundStyle(colorPalette.guidanceColors.critical) - .font(.subheadline.weight(.semibold)) - - Text("Larger glucose drop") - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(12) - .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) - } - } else { - VStack(spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Text("Morning Exercise") - .foregroundStyle(colorPalette.carbTintColor) - .font(.subheadline.weight(.semibold)) - - Text("Smaller glucose drop") - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(12) - .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) - - VStack(alignment: .leading, spacing: 4) { - Text("Afternoon Exercise") - .foregroundStyle(colorPalette.guidanceColors.critical) - .font(.subheadline.weight(.semibold)) - - Text("Larger glucose drop") - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(12) - .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) - } - } - - TintedTip(text: Text("**Try:** If you often experience low glucose, consider exercising earlier in the day before eating.")) - } - - case .exerciseAndGlucoseMealTiming: - Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") - - TintedContent( - tint: .orange, - icon: Image(systemName: "fork.knife"), - title: Text("Meal Timing") - ) { - Text("If you often experience low glucose, you may need to reduce how much insulin you deliver for meals eaten 1-2 hours before exercising.") - - VStack(spacing: 4) { - Text("Recommended Insulin Reduction") - .font(.headline.weight(.semibold)) - .frame(maxWidth: .infinity) - - Text("25-33%") - .font(.title.weight(.heavy)) - .foregroundStyle(Color.orange) - - Text("if eating less than 2 hours before exercise") - .font(.footnote) - .foregroundStyle(.secondary) - } - .padding(12) - .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) - - TintedTip(text: Text("**Try:** Reducing your meal bolus if you expect your glucose to drop.")) - } - - case .exerciseAndGlucoseCompetitionStress: - Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") - - TintedContent( - tint: colorPalette.glucoseTintColor, - icon: Image(systemName: "trophy"), - title: Text("Competition Stress") - ) { - Text("Stress during a game, match or tournament causes your body to release hormones like adrenaline and cortisol, which may raise your glucose and cause \(appName) to increase insulin delivery.") - - BulletedListView(bulletColor: colorPalette.glucoseTintColor, bulletOpacity: 1) { - Text("Monitor your glucose and active insulin at the start of a competition day") - - Text("Stay hydrated") - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) - - TintedTip(text: Text("**Tip: If glucose rises to >270 mg/dl,** check \(appName) to see if a bolus is recommended to bring your glucose back into range.")) - } - - case .preventingLows: - Text("If you usually experience lows while exercising, watch your glucose levels closely during exercise and consider eating around 3 to 20g of fast-acting carbs.") - - Group { - if dynamicTypeSize < .accessibility1 { - HStack(alignment: .bottom, spacing: 12) { - InsetContent(spacing: 8) { - Text("Stable Glucose") - .font(.footnote) - .frame(maxWidth: .infinity) - .fixedSize(horizontal: false, vertical: true) - .frame(maxHeight: .infinity) - - Image("glucose-stable") - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) - .foregroundStyle(colorPalette.glucoseTintColor) - - VStack(spacing: 0) { - Text("3-6") - .font(.headline.weight(.semibold)) - - Text("grams") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - - InsetContent(spacing: 8) { - Text("Falling Slowly") - .font(.footnote) - .frame(maxWidth: .infinity) - .fixedSize(horizontal: false, vertical: true) - .frame(maxHeight: .infinity) - - Image("glucose-stable") - .resizable() - .scaledToFit() - .rotationEffect(.degrees(30)) - .frame(width: 28, height: 28) - .foregroundStyle(colorPalette.glucoseTintColor) - - VStack(spacing: 0) { - Text("6-9") - .font(.headline.weight(.semibold)) - - Text("grams") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - - InsetContent(spacing: 8) { - Text("Falling / Falling Quickly") - .font(.footnote) - .frame(maxWidth: .infinity) - .fixedSize(horizontal: false, vertical: true) - .frame(maxHeight: .infinity) - - HStack(spacing: 6) { - Image("glucose-stable") - .resizable() - .scaledToFit() - .rotationEffect(.degrees(90)) - .frame(width: 28, height: 28) - - Image("glucose-falling-fast") - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) - } - .foregroundStyle(colorPalette.glucoseTintColor) - - VStack(spacing: 0) { - Text("9-20") - .font(.headline.weight(.semibold)) - - Text("grams") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - .fixedSize(horizontal: false, vertical: true) - } else { - VStack(spacing: 12) { - InsetContent(spacing: 8) { - Text("Stable Glucose") - .font(.footnote) - .frame(maxWidth: .infinity) - .fixedSize(horizontal: false, vertical: true) - .frame(maxHeight: .infinity) - - Image("glucose-stable") - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) - .foregroundStyle(colorPalette.glucoseTintColor) - - Text("3-6") - .font(.headline.weight(.semibold)) - + Text(" grams") - .font(.footnote) - .foregroundStyle(.secondary) - } - - InsetContent(spacing: 8) { - Text("Falling Slowly") - .font(.footnote) - .frame(maxWidth: .infinity) - .fixedSize(horizontal: false, vertical: true) - .frame(maxHeight: .infinity) - - Image("glucose-stable") - .resizable() - .scaledToFit() - .rotationEffect(.degrees(30)) - .frame(width: 28, height: 28) - .foregroundStyle(colorPalette.glucoseTintColor) - - Text("6-9") - .font(.headline.weight(.semibold)) - + Text(" grams") - .font(.footnote) - .foregroundStyle(.secondary) - } - - InsetContent(spacing: 8) { - Text("Falling / Falling Quickly") - .font(.footnote) - .frame(maxWidth: .infinity) - .fixedSize(horizontal: false, vertical: true) - .frame(maxHeight: .infinity) - - HStack(spacing: 6) { - Image("glucose-stable") - .resizable() - .scaledToFit() - .rotationEffect(.degrees(90)) - .frame(width: 28, height: 28) - - Image("glucose-falling-fast") - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) - } - .foregroundStyle(colorPalette.glucoseTintColor) - - Text("9-20") - .font(.headline.weight(.semibold)) - + Text(" grams") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .fixedSize(horizontal: false, vertical: true) - } - } - .multilineTextAlignment(.center) - - Text("Check your glucose levels around 20 to 30 min after eating. If you're still low, consider eating the same amount.") - - Callout(.note) { - Text("If your glucose isn't dropping, eating too many carbs can raise your blood sugar, trigger more insulin, and increase the risk of low blood sugar during or after the activity.") - } - .padding(.horizontal, -16) - - case .unplannedActivity: - Text("Planning for physical activity can be tough. If you forget to set a preset ahead of time, consider these strategies:") - - InsetContent(padding: 16) { - HStack(spacing: 16) { - Image("presets-selected") - .resizable() - .renderingMode(.template) - .scaledToFit() - .frame(width: 28, height: 28) - .foregroundStyle(Color.presets) - - VStack(alignment: .leading, spacing: 2) { - Text("Start Preset") - .frame(maxWidth: .infinity, alignment: .leading) - .fontWeight(.semibold) - - Text("Turn on the preset as soon as you remember and keep it on until the activity ends") - } - } - } - - InsetContent(padding: 16) { - HStack(spacing: 16) { - Image("candy-icon") - .resizable() - .renderingMode(.template) - .scaledToFit() - .frame(width: 32, height: 32) - .foregroundStyle(colorPalette.carbTintColor) - - VStack(alignment: .leading, spacing: 2) { - Text("If glucose drops below 126 mg/dL") - .frame(maxWidth: .infinity, alignment: .leading) - .fontWeight(.semibold) - - Text("Consider eating around 10 to 20 grams of fast-acting carbs") - } - } - } - } - } - case .trainingComplete: - Text("Congratulations! You've finished the Presets training.") - - VStack(alignment: .leading, spacing: 8) { - Text("You can now:") - - BulletedListView { - Text("Edit presets") - Text("Create new presets") - } - .padding(.leading, 8) - } - - Text("You may review the training materials again at any time via the Learning Hub, located at the bottom of the Preset screen.") - } - } - - var cta: PresetsTraining.CTA? { - switch self { -// Temporarily removed -- will be moved to general onboarding with LOOP-5238 -// case .entryPoint: .start -// case .tier1(let tier1Chapter): -// switch tier1Chapter { -// case .introduction(let introduction): -// switch introduction { -// case .introduction, -// .exercisingWithLoop, -// .timingYourPresets, -// .safeGlucoseRanges, -// .performanceHistory: .continue -// case .complete: .closeOrContinue("Step 2", chapter: .introduction) -// } -// } - case .tier2(let tier2Chapter): - switch tier2Chapter { - case .customizingPresets: .continue - case .illness(let illness): - switch illness { - case .commonUses: nil - case .presetsForIllness, - .overallInsulin, - .correctionRange, - .duration, - .impactOnBolusing: .continue - } - case .dailyActivities(let dailyActivities): - switch dailyActivities { - case .commonUses: nil - case .presetsForDailyActivities, - .overallInsulin, - .correctionRange, - .savedPresets: .continue - } - case .exercise(let exercise): - switch exercise { - case .commonUses: nil - case .presetsForExercise, - .perceivedIntensity, - .lightToModerateExercise, - .highIntensityExercise, - .mixedIntensityExercise, - .exerciseAndGlucoseActiveInsulin, - .exerciseAndGlucoseTimeOfDay, - .exerciseAndGlucoseMealTiming, - .exerciseAndGlucoseCompetitionStress, - .preventingLows, - .unplannedActivity: .continue - } - } - case .trainingComplete: .close - } - } -} - -extension PresetsTraining.Chapter: PresetsTrainingContent { - @ViewBuilder - func content( - appName: String, - displayGlucosePreference: DisplayGlucosePreference, - colorPalette: LoopUIColorPalette, - dynamicTypeSize: DynamicTypeSize, - next: @escaping () -> Void - ) -> some View { - firstStep.content( - appName: appName, - displayGlucosePreference: displayGlucosePreference, - colorPalette: colorPalette, - dynamicTypeSize: dynamicTypeSize, - next: next - ) - } - - var cta: PresetsTraining.CTA? { - firstStep.cta - } -} - -extension ActivityPreset.ActivityType { - func bulletItem(full: Bool) -> Text { - if full { - return Text(Image(systemName: systemImageName)) - .fontDesign(.monospaced) - + Text(" \(name) · ") - .fontWeight(.semibold) - + Text("\(defaultInsulinNeedsScaleFactor.formatted(.percent)) of insulin") - } else { - return Text(Image(systemName: systemImageName)) - .fontDesign(.monospaced) - + Text(" \(name)") - .fontWeight(.semibold) - } - } -} - -extension ActivityPreset { - @ViewBuilder - static func bulletList(full: Bool) -> some View { - BulletedListView { - ActivityPreset.ActivityType.jogging.bulletItem(full: full) - ActivityPreset.ActivityType.walking.bulletItem(full: full) - ActivityPreset.ActivityType.biking.bulletItem(full: full) - ActivityPreset.ActivityType.strengthTraining.bulletItem(full: full) - } - } -} diff --git a/Loop/Views/Presets/Training Content/Components/CommonUseStep.swift b/Loop/Views/Presets/Training/Components/CommonUseStep.swift similarity index 100% rename from Loop/Views/Presets/Training Content/Components/CommonUseStep.swift rename to Loop/Views/Presets/Training/Components/CommonUseStep.swift diff --git a/Loop/Views/Presets/Training Content/Components/EstimatedReadTime.swift b/Loop/Views/Presets/Training/Components/EstimatedReadTime.swift similarity index 100% rename from Loop/Views/Presets/Training Content/Components/EstimatedReadTime.swift rename to Loop/Views/Presets/Training/Components/EstimatedReadTime.swift diff --git a/Loop/Views/Presets/Training Content/Components/IntensitySlider.swift b/Loop/Views/Presets/Training/Components/IntensitySlider.swift similarity index 99% rename from Loop/Views/Presets/Training Content/Components/IntensitySlider.swift rename to Loop/Views/Presets/Training/Components/IntensitySlider.swift index 3b322b574f..b57ec127d1 100644 --- a/Loop/Views/Presets/Training Content/Components/IntensitySlider.swift +++ b/Loop/Views/Presets/Training/Components/IntensitySlider.swift @@ -6,6 +6,7 @@ // Copyright © 2025 LoopKit Authors. All rights reserved. // +import LoopKitUI import SwiftUI struct IntensitySlider: UIViewRepresentable { diff --git a/Loop/Views/Presets/Training Content/Components/PlayMediaButton.swift b/Loop/Views/Presets/Training/Components/PlayMediaButton.swift similarity index 100% rename from Loop/Views/Presets/Training Content/Components/PlayMediaButton.swift rename to Loop/Views/Presets/Training/Components/PlayMediaButton.swift diff --git a/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift b/Loop/Views/Presets/Training/Components/PresetsTrainingCard.swift similarity index 76% rename from Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift rename to Loop/Views/Presets/Training/Components/PresetsTrainingCard.swift index 0eb511523d..ddd91eafac 100644 --- a/Loop/Views/Presets/Training Content/Components/PresetsTrainingCard.swift +++ b/Loop/Views/Presets/Training/Components/PresetsTrainingCard.swift @@ -6,13 +6,14 @@ // Copyright © 2025 LoopKit Authors. All rights reserved. // +import LoopKit import SwiftUI -struct PresetsTrainingCard: View { +public struct PresetsTrainingCard: View { let imageName: String? - init(trainingCompletion: PresetsTrainingCompletion) { + public init(trainingCompletion: PresetsTrainingCompletion) { if trainingCompletion.completedChapters[.customizingPresets] != true { self.imageName = "PresetsTrainingCreditEditStartCard" } else if trainingCompletion.completedChapters[.trainingComplete] != true { @@ -22,8 +23,8 @@ struct PresetsTrainingCard: View { } } - var body: some View { - if let imageName, let image = Image(imageName) { + public var body: some View { + if let imageName, let image = Image.optional(imageName) { image .resizable() .scaledToFit() diff --git a/Loop/Views/Presets/Training Content/Components/TherapySettingsExampleView.swift b/Loop/Views/Presets/Training/Components/TherapySettingsExampleView.swift similarity index 100% rename from Loop/Views/Presets/Training Content/Components/TherapySettingsExampleView.swift rename to Loop/Views/Presets/Training/Components/TherapySettingsExampleView.swift diff --git a/Loop/Views/Presets/Training Content/Components/TintedContent.swift b/Loop/Views/Presets/Training/Components/TintedContent.swift similarity index 100% rename from Loop/Views/Presets/Training Content/Components/TintedContent.swift rename to Loop/Views/Presets/Training/Components/TintedContent.swift diff --git a/Loop/Views/Presets/Training/PresetsTrainingContent.swift b/Loop/Views/Presets/Training/PresetsTrainingContent.swift new file mode 100644 index 0000000000..ade45bad56 --- /dev/null +++ b/Loop/Views/Presets/Training/PresetsTrainingContent.swift @@ -0,0 +1,1055 @@ +// +// PresetsTrainingContent.swift +// Loop +// +// Created by Cameron Ingham on 8/26/25. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +extension PresetsTraining { + enum CTA { + case start + case `continue` + case close + case closeOrContinue(_ to: String, chapter: Chapter) + } +} + +protocol PresetsTrainingContent { + associatedtype B: View + func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, next: @escaping () -> Void) -> B + var cta: PresetsTraining.CTA? { get } +} + +extension PresetsTraining.Step: PresetsTrainingContent { + + @ViewBuilder + func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, next: @escaping () -> Void) -> some View { + switch self { + case .customizingPresets(let customizingPresets): + switch customizingPresets { + case .customizingPresets: + if let image = Image.optional("PresetsTrainingCustomizingPresetsHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + EstimatedReadTime(.minutes(10)) + + VStack(alignment: .leading) { + Text("Learn to tailor your settings! This training will teach you how to:") + .fixedSize(horizontal: false, vertical: true) + + BulletedListView { + Text("Configure each setting") + Text("Use Presets for when you are sick") + Text("Use Presets for Daily Activities") + Text("Use Presets for Exercise") + } + .padding(.leading, 8) + } + + Text("Complete this training to learn how to edit the pre-configured presets and adjust them to fit your needs, or create your own custom presets.") + + case .overallInsulin: + if let image = Image.optional("PresetsTrainingOverallInsulinHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + VStack(alignment: .leading) { + Text("The \"Overall Insulin\" percentage controls total insulin delivery by adjusting your:") + + BulletedListView { + Text("Basal Rate") + Text("Carb Ratio") + Text("Insulin Sensitivity Factor (ISF)") + } + .padding(.leading, 8) + } + + Text("At 100%, \(appName) assumes your insulin needs are the same as usual.") + + Text("When deciding to adjust your overall insulin, **ask yourself, does my body need more or less than usual?**") + + Callout(.note) { + BulletedListView { + Text("A percentage **below 100%** tells the system you need **less** insulin") + + Text("A percentage **above 100%** tells the system you need **more** insulin") + } + .font(.footnote) + .padding(.top, 8) + } + .padding(.horizontal, -16) + + case .correctionRange: + if let image = Image.optional("PresetsTrainingCorrectionRangeHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Correction range is a **safety setting**. Changing it can help lower your risk of going low if you expect unusual changes.") + + Text("Changing it can lower the chance of your glucose levels going too low if you expect unusual changes.") + + Text("Choose the glucose value (or values) you want \(appName) to target when changing how much basal insulin you get.") + + Text("You don’t need to change the correction range for every preset. But before you decide to change it, ask yourself: *Am I more likely to go high or low during this time?*") + + Callout(.note) { + Text("To help avoid lows, set a range **higher** than your typical correction range.") + .font(.footnote) + .padding(.top, 8) + } + .padding(.horizontal, -16) + } + case .illness(let illness): + switch illness { + case .commonUses: + Text("You can use presets for a variety of situations. Explore the uses below to learn tips for these common scenarios.") + + VStack(spacing: 16) { + CommonUseStep( + title: Text("Presets for Illness"), + readTime: .minutes(3), + onTapGesture: next + ) + + CommonUseStep( + title: Text("Presets for Daily Activity"), + readTime: .minutes(2) + ) + .disabled(true) + + CommonUseStep( + title: Text("Presets for Exercise"), + readTime: .minutes(5) + ) + .disabled(true) + } + + case .presetsForIllness: + Text("Physical stress, like illness, can cause glucose to rise.") + + InsetContent(alignment: .leading) { + Text("**Example:** Paloma Porpoise sees her glucose is running higher than usual. She decides to create a preset to help manage it while she's sick.") + } + + Text("Let's look at the settings that can change how much insulin Paloma get.") + + case .overallInsulin: + Text("Paloma wants \(appName) to know she needs more insulin than usual.") + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Current Therapy Settings", comment: ""), + components: [ + .basalRate(0.5), + .carbRatio(13), + .isf(50) + ] + ) + + Text("She can do this by raising her **Overall Insulin** setting. This tells \(appName) to deliver more than her usual amount, making her insulin settings stronger.") + + if let image = Image.optional("PresetsTrainingIllnessOverallInsulin") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Adjusted Therapy Settings", comment: ""), + components: [ + .basalRate(0.6), + .carbRatio(12), + .isf(45) + ], + style: .adjusted + ) + + case .correctionRange: + Text("While sick, Paloma expects to eat less or not absorb everything she eats.") + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Current Therapy Settings", comment: ""), + component: .correctionRange(105...110) + ) + + Text("To help prevent lows, she will increase her correction range.") + + if let image = Image.optional("PresetsTrainingIllnessCorrectionRange") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + TherapySettingsExampleView( + title: NSLocalizedString("Paloma’s Adjusted Therapy Settings", comment: ""), + component: .correctionRange(130...140), + style: .adjusted + ) + + case .duration: + Text("You can choose how long your preset lasts.") + + Text("Since Paloma doesn't know when she'll feel better, she sets hers to “Until I Turn Off”.") + + if let image = Image.optional("PresetsTrainingIllnessDuration1") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("To be safe, \(appName) will remind her after 24 hours that the preset is still running.") + + if let image = Image.optional("PresetsTrainingIllnessDuration2") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("While turned on, Paloma’s preset will display on the home screen and in her Presets list.") + + case .impactOnBolusing: + Text("Later that day, Paloma eats a meal with about 30g of carbs.") + + Text("How does her preset impact her bolus recommendation?") + + Text("Her preset is set to **110%**, which is more than she usually needs. This means \(appName) will make her basal rates, carb ratio, and insulin sensitivity factor (ISF) stronger. ") + + TherapySettingsExampleView( + title: NSLocalizedString("Her bolus recommendation is higher than usual because her overall insulin is set higher.", comment: ""), + component: .bolusRecommendation( + starting: 3.9, + ending: 4.3, + action: NSLocalizedString("With Preset On", comment: "") + ), + style: .adjusted + ) + } + case .dailyActivities(let dailyActivities): + switch dailyActivities { + case .commonUses: + Text("You can use presets for a variety of situations. Explore the uses below to learn tips for these common scenarios.") + + VStack(spacing: 16) { + CommonUseStep( + title: Text("Presets for Illness"), + readTime: .minutes(3) + ) + + CommonUseStep( + title: Text("Presets for Daily Activity"), + readTime: .minutes(2), + onTapGesture: next + ) + + CommonUseStep( + title: Text("Presets for Exercise"), + readTime: .minutes(5) + ) + .disabled(true) + } + + case .presetsForDailyActivities: + Text("For some people, routine chores and everyday activities can affect glucose levels similar to exercise.") + + InsetContent(alignment: .leading) { + Text("**Example:** Omar Octopus wants to create a preset for some yard work he’ll be doing around the house.") + } + + Text("Let's look at the settings that will impact Omar’s insulin delivery.") + + VStack(alignment: .leading, spacing: 16) { + Text("Learn More") + .font(.headline.weight(.semibold)) + + PlayMediaButton( + image: Image("ADLs"), + title: Text("Managing Activities of Daily Living"), + duration: .minutes(5) + .seconds(36) + ) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(colorPalette.chartColorPalette.presetTint).opacity(0.1)) + ) + + case .overallInsulin: + Text("Omar asks himself, **do I expect I will need more or less insulin than usual?**") + + Text("Since he doesn’t plan to push himself too hard, he expects his insulin needs to stay the same, so he leaves the setting at 100%.") + + if let image = Image.optional("PresetsTrainingDailyActivityOverallInsulin") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Callout(.note) { + Text("Pay attention to your insulin needs before and after exercising, playing sports, or doing unusually hard physical labor.") + } + .padding(.horizontal, -16) + + case .correctionRange: + Text("For activities that raise your risk of going low, you can set a higher temporary correction range.") + + Text("This range is usually higher than your correction range when you are not exercising.") + + Text("Because Omar has gone low while working outdoors in the past, he raises his preset correction range to help prevent another low.") + + TherapySettingsExampleView( + title: NSLocalizedString("Omar’s Current Therapy Settings", comment: ""), + component: .correctionRange(110...120) + ) + + Text("Omar sets his correction range a little higher, to \(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 140), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 160), includeUnit: false)) \(displayGlucosePreference.unit.localizedShortUnitString). This tells \(appName) to step in sooner.") + + if let image = Image.optional("PresetsTrainingDailyActivityCorrectionRange") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + case .savedPresets: + if let image = Image.optional("PresetsTrainingDailyActivitySavedPresets") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Once saved, Omar’s new preset will display in his Presets lists.") + + Callout(.note) { + Text("If your activity has a higher risk of low glucose, start a physical activity preset at least **1 hour before you begin** and keep it on until you finish.") + + Text("If you expect your glucose to rise during the activity, you may not need a preset.") + } + .padding(.horizontal, -16) + + } + case .exercise(let exercise): + switch exercise { + case .commonUses: + Text("Presets can be used for a variety of situations. Explore the uses below to learn tips for these common scenarios.") + + VStack(spacing: 16) { + CommonUseStep( + title: Text("Presets for Illness"), + readTime: .minutes(3) + ) + + CommonUseStep( + title: Text("Presets for Daily Activity"), + readTime: .minutes(2) + ) + + CommonUseStep( + title: Text("Presets for Exercise"), + readTime: .minutes(5), + onTapGesture: next + ) + } + case .presetsForExercise: + Text("Exercise is a common reason to use a preset.") + + Text("Different kinds of exercise and their intensity levels can affect your glucose levels in different ways.") + + Text("Depending on the activity, you may notice a few common patterns when it comes to your insulin needs:") + + BulletedListView { + Text("no change needed") + Text("you need **less** insulin than usual") + Text("you need **more** insulin than usual") + } + + Callout(.note) { + Text("These patterns are based on published exercise consensus guidelines and are meant to be used as a starting point. What works for one person may not work for you.") + } + .padding(.horizontal, -16) + + case .perceivedIntensity: + Text("Recognizing how hard you feel you're working during exercise can help you understand its impact on your glucose levels.") + + Text("Consider an exercise you do regularly and think about how hard you push yourself.") + + Text("Use the slider to rate the effort on a scale of 0–10, with 10 being the hardest you’ve ever worked.") + + IntensityInfo() + + VStack(alignment: .leading, spacing: 16) { + Text("Learn More") + .font(.headline.weight(.semibold)) + + PlayMediaButton( + image: Image("Same Activity Different Intensity"), + title: Text("Same Activity, Different Intensity"), + duration: .minutes(6) + .seconds(34) + ) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(colorPalette.chartColorPalette.presetTint).opacity(0.1)) + ) + + case .lightToModerateExercise: + if let image = Image.optional("PresetsTrainingExerciseLightToModerateHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Light-to-moderate intensity exercise can cause a drop in glucose levels. This is because your body uses glucose (or sugar) for energy during physical activity.") + + InsetContent { + VStack(spacing: 4) { + Text("Aerobic") + .font(.title2.bold()) + + Text("Continuous or exercise without breaks") + .frame(maxWidth: .infinity) + } + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 16) { + Bullet(color: .secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("Walking") + + HStack(alignment: .center, spacing: 2) { + Text("\(Image(systemName: "lightbulb.max"))") + + Text(" **Tip** Use your \(Image(systemName: "figure.walk")) **Walking** preset") + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 16) { + Bullet(color: .secondary) + + Text("Hiking") + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 16) { + Bullet(color: .secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("Jogging") + + HStack(alignment: .center, spacing: 2) { + Text("\(Image(systemName: "lightbulb.max"))") + + Text(" **Tip** Use your \(Image(systemName: "figure.run")) **Jogging** preset") + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(Color(UIColor.secondarySystemBackground).cornerRadius(5)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 16) { + Bullet(color: .secondary) + + Text("Swimming") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + Text("For these activities, consider setting your insulin needs to **less than 100%**.") + + if let image = Image.optional("PresetsTrainingExerciseLightToModerate") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Callout(.note) { + Text("These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you.") + } + .padding(.horizontal, -16) + + case .highIntensityExercise: + if let image = Image.optional("PresetsTrainingExerciseHighHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("High-intensity exercise means pushing yourself to your **maximum effort**. It is so hard that talking is nearly impossible, and you can’t keep it up for very long.") + + Text("During this kind of hard exercise, your body may release hormones that raise glucose. This is more common in the morning before eating.") + + InsetContent { + VStack(spacing: 4) { + Text("Aerobic") + .font(.title2.bold()) + + Text("Explosive sprints or bursts") + .frame(maxWidth: .infinity) + } + + BulletedListView(bulletColor: .secondary) { + Text("Power lifting") + Text("CrossFit") + Text("100m sprint") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Text("For these activities, consider setting your insulin needs to **more than 100%**.") + + Text("That said, insulin needs vary from person to person. Some people find they don’t need to adjust their insulin at all for high-intensity exercise.") + + Text("If you haven’t noticed a rise in glucose with high-intensity exercise, it may be due to:") + + BulletedListView { + Text("Starting your exercise with high active insulin") + Text("Automated insulin adjustments by \(appName) reduce a noticeable rise in glucose") + Text("The exercise may not be vigorous enough to produce these results") + } + + if let image = Image.optional("PresetsTrainingExerciseHigh") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("When using high-insulin presets, **you may not need to start your preset 1 hour before**.") + + Callout(.note) { + Text("These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you.") + } + .padding(.horizontal, -16) + + case .mixedIntensityExercise: + if let image = Image.optional("PresetsTrainingExerciseMixedHero") { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + } + + Text("Mixed-intensity exercise may cause only small changes in glucose levels. Your glucose may go up or down.") + + InsetContent { + VStack(spacing: 4) { + Text("Aerobic") + .font(.title2.bold()) + + Text("Combination of high and low intensity") + .frame(maxWidth: .infinity) + } + + BulletedListView(bulletColor: .secondary) { + Text("Soccer") + Text("Interval Training ") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Text("For mixed-intensity activity:") + + BulletedListView { + Text("If your glucose goes up, you may only need a small increase in insulin — less than you would for high-intensity activity.") + + Text("If your glucose goes down, you may only need a small decrease in insulin — less than you would for low to moderate-intensity activity.") + } + + Callout(.note) { + Text("These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you.") + } + .padding(.horizontal, -16) + + VStack(alignment: .leading, spacing: 16) { + Text("Learn More") + .font(.headline.weight(.semibold)) + + PlayMediaButton( + image: Image("Mixed Exercise"), + title: Text("Navigating the Challenges of Mixed Exercise"), + duration: .minutes(3) + .seconds(27) + ) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(colorPalette.chartColorPalette.presetTint).opacity(0.1)) + ) + + case .exerciseAndGlucoseActiveInsulin: + Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") + + TintedContent( + tint: colorPalette.insulinTintColor, + icon: Image(systemName: "cross.vial"), + title: Text("Active Insulin") + ) { + Text("If you have active insulin in your body when you start exercising, you generally have an increased risk of low glucose.") + + TintedTip(text: Text("**Tip:** Try exercising when your active insulin is close to zero at the start of an activity.")) + } + + case .exerciseAndGlucoseTimeOfDay: + Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") + + TintedContent( + tint: colorPalette.carbTintColor, + icon: Image(systemName: "clock"), + title: Text("Time of Day") + ) { + Text("Morning exercise before eating (like a fasted jog) usually causes a smaller drop in glucose levels and may even promote a rise, compared to afternoon exercise.") + + if dynamicTypeSize < .accessibility1 { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Morning Exercise") + .foregroundStyle(colorPalette.carbTintColor) + .font(.subheadline.weight(.semibold)) + + Text("Smaller glucose drop") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + + VStack(alignment: .leading, spacing: 4) { + Text("Afternoon Exercise") + .foregroundStyle(colorPalette.guidanceColors.critical) + .font(.subheadline.weight(.semibold)) + + Text("Larger glucose drop") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + } + } else { + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Morning Exercise") + .foregroundStyle(colorPalette.carbTintColor) + .font(.subheadline.weight(.semibold)) + + Text("Smaller glucose drop") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + + VStack(alignment: .leading, spacing: 4) { + Text("Afternoon Exercise") + .foregroundStyle(colorPalette.guidanceColors.critical) + .font(.subheadline.weight(.semibold)) + + Text("Larger glucose drop") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + } + } + + TintedTip(text: Text("**Try:** If you often experience low glucose, consider exercising earlier in the day before eating.")) + } + + case .exerciseAndGlucoseMealTiming: + Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") + + TintedContent( + tint: .orange, + icon: Image(systemName: "fork.knife"), + title: Text("Meal Timing") + ) { + Text("If you often experience low glucose, you may need to reduce how much insulin you deliver for meals eaten 1-2 hours before exercising.") + + VStack(spacing: 4) { + Text("Recommended Insulin Reduction") + .font(.headline.weight(.semibold)) + .frame(maxWidth: .infinity) + + Text("25-33%") + .font(.title.weight(.heavy)) + .foregroundStyle(Color.orange) + + Text("if eating less than 2 hours before exercise") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + + TintedTip(text: Text("**Try:** Reducing your meal bolus if you expect your glucose to drop.")) + } + + case .exerciseAndGlucoseCompetitionStress: + Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") + + TintedContent( + tint: colorPalette.glucoseTintColor, + icon: Image(systemName: "trophy"), + title: Text("Competition Stress") + ) { + Text("Stress during a game, match or tournament causes your body to release hormones like adrenaline and cortisol, which may raise your glucose and cause \(appName) to increase insulin delivery.") + + BulletedListView(bulletColor: colorPalette.glucoseTintColor, bulletOpacity: 1) { + Text("Monitor your glucose and active insulin at the start of a competition day") + + Text("Stay hydrated") + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemBackground))) + + TintedTip(text: Text("**Tip: If glucose rises to >270 mg/dl,** check \(appName) to see if a bolus is recommended to bring your glucose back into range.")) + } + + case .preventingLows: + Text("If you usually experience lows while exercising, watch your glucose levels closely during exercise and consider eating around 3 to 20g of fast-acting carbs.") + + Group { + if dynamicTypeSize < .accessibility1 { + HStack(alignment: .bottom, spacing: 12) { + InsetContent(spacing: 8) { + Text("Stable Glucose") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + Image("glucose-stable") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + .foregroundStyle(colorPalette.glucoseTintColor) + + VStack(spacing: 0) { + Text("3-6") + .font(.headline.weight(.semibold)) + + Text("grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + InsetContent(spacing: 8) { + Text("Falling Slowly") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + Image("glucose-stable") + .resizable() + .scaledToFit() + .rotationEffect(.degrees(30)) + .frame(width: 28, height: 28) + .foregroundStyle(colorPalette.glucoseTintColor) + + VStack(spacing: 0) { + Text("6-9") + .font(.headline.weight(.semibold)) + + Text("grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + InsetContent(spacing: 8) { + Text("Falling / Falling Quickly") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + HStack(spacing: 6) { + Image("glucose-stable") + .resizable() + .scaledToFit() + .rotationEffect(.degrees(90)) + .frame(width: 28, height: 28) + + Image("glucose-falling-fast") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + } + .foregroundStyle(colorPalette.glucoseTintColor) + + VStack(spacing: 0) { + Text("9-20") + .font(.headline.weight(.semibold)) + + Text("grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .fixedSize(horizontal: false, vertical: true) + } else { + VStack(spacing: 12) { + InsetContent(spacing: 8) { + Text("Stable Glucose") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + Image("glucose-stable") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + .foregroundStyle(colorPalette.glucoseTintColor) + + Text("3-6") + .font(.headline.weight(.semibold)) + + Text(" grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + + InsetContent(spacing: 8) { + Text("Falling Slowly") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + Image("glucose-stable") + .resizable() + .scaledToFit() + .rotationEffect(.degrees(30)) + .frame(width: 28, height: 28) + .foregroundStyle(colorPalette.glucoseTintColor) + + Text("6-9") + .font(.headline.weight(.semibold)) + + Text(" grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + + InsetContent(spacing: 8) { + Text("Falling / Falling Quickly") + .font(.footnote) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: .infinity) + + HStack(spacing: 6) { + Image("glucose-stable") + .resizable() + .scaledToFit() + .rotationEffect(.degrees(90)) + .frame(width: 28, height: 28) + + Image("glucose-falling-fast") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + } + .foregroundStyle(colorPalette.glucoseTintColor) + + Text("9-20") + .font(.headline.weight(.semibold)) + + Text(" grams") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .fixedSize(horizontal: false, vertical: true) + } + } + .multilineTextAlignment(.center) + + Text("Check your glucose levels around 20 to 30 min after eating. If you're still low, consider eating the same amount.") + + Callout(.note) { + Text("If your glucose isn't dropping, eating too many carbs can raise your blood sugar, trigger more insulin, and increase the risk of low blood sugar during or after the activity.") + } + .padding(.horizontal, -16) + + case .unplannedActivity: + Text("Planning for physical activity can be tough. If you forget to set a preset ahead of time, consider these strategies:") + + InsetContent(padding: 16) { + HStack(spacing: 16) { + Image("presets-selected") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 28, height: 28) + .foregroundStyle(Color(colorPalette.chartColorPalette.presetTint)) + + VStack(alignment: .leading, spacing: 2) { + Text("Start Preset") + .frame(maxWidth: .infinity, alignment: .leading) + .fontWeight(.semibold) + + Text("Turn on the preset as soon as you remember and keep it on until the activity ends") + } + } + } + + InsetContent(padding: 16) { + HStack(spacing: 16) { + Image("candy-icon") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundStyle(colorPalette.carbTintColor) + + VStack(alignment: .leading, spacing: 2) { + Text("If glucose drops below 126 mg/dL") + .frame(maxWidth: .infinity, alignment: .leading) + .fontWeight(.semibold) + + Text("Consider eating around 10 to 20 grams of fast-acting carbs") + } + } + } + } + case .trainingComplete: + Text("Congratulations! You've finished the Presets training.") + + VStack(alignment: .leading, spacing: 8) { + Text("You can now:") + + BulletedListView { + Text("Edit presets") + Text("Create new presets") + } + .padding(.leading, 8) + } + + Text("You may review the training materials again at any time via the Learning Hub, located at the bottom of the Preset screen.") + } + } + + var cta: PresetsTraining.CTA? { + switch self { + case .customizingPresets: .continue + case .illness(let illness): + switch illness { + case .commonUses: nil + case .presetsForIllness, + .overallInsulin, + .correctionRange, + .duration, + .impactOnBolusing: .continue + } + case .dailyActivities(let dailyActivities): + switch dailyActivities { + case .commonUses: nil + case .presetsForDailyActivities, + .overallInsulin, + .correctionRange, + .savedPresets: .continue + } + case .exercise(let exercise): + switch exercise { + case .commonUses: nil + case .presetsForExercise, + .perceivedIntensity, + .lightToModerateExercise, + .highIntensityExercise, + .mixedIntensityExercise, + .exerciseAndGlucoseActiveInsulin, + .exerciseAndGlucoseTimeOfDay, + .exerciseAndGlucoseMealTiming, + .exerciseAndGlucoseCompetitionStress, + .preventingLows, + .unplannedActivity: .continue + } + case .trainingComplete: .close + } + } +} + +extension PresetsTraining.Chapter: PresetsTrainingContent { + @ViewBuilder + func content( + appName: String, + displayGlucosePreference: DisplayGlucosePreference, + colorPalette: LoopUIColorPalette, + dynamicTypeSize: DynamicTypeSize, + next: @escaping () -> Void + ) -> some View { + firstStep.content( + appName: appName, + displayGlucosePreference: displayGlucosePreference, + colorPalette: colorPalette, + dynamicTypeSize: dynamicTypeSize, + next: next + ) + } + + var cta: PresetsTraining.CTA? { + firstStep.cta + } +} + +extension ActivityPreset.ActivityType { + func bulletItem(full: Bool) -> Text { + if full { + return Text(Image(systemName: systemImageName)) + .fontDesign(.monospaced) + + Text(" \(name) · ") + .fontWeight(.semibold) + + Text("\(defaultInsulinNeedsScaleFactor.formatted(.percent)) of insulin") + } else { + return Text(Image(systemName: systemImageName)) + .fontDesign(.monospaced) + + Text(" \(name)") + .fontWeight(.semibold) + } + } +} + +extension ActivityPreset { + @ViewBuilder + static func bulletList(full: Bool) -> some View { + BulletedListView { + ActivityPreset.ActivityType.jogging.bulletItem(full: full) + ActivityPreset.ActivityType.walking.bulletItem(full: full) + ActivityPreset.ActivityType.biking.bulletItem(full: full) + ActivityPreset.ActivityType.strengthTraining.bulletItem(full: full) + } + } +} diff --git a/Loop/Views/Presets/Training Content/PresetsTrainingView.swift b/Loop/Views/Presets/Training/PresetsTrainingView.swift similarity index 94% rename from Loop/Views/Presets/Training Content/PresetsTrainingView.swift rename to Loop/Views/Presets/Training/PresetsTrainingView.swift index 0d96e50a8c..94571fc871 100644 --- a/Loop/Views/Presets/Training Content/PresetsTrainingView.swift +++ b/Loop/Views/Presets/Training/PresetsTrainingView.swift @@ -6,10 +6,11 @@ // Copyright © 2025 LoopKit Authors. All rights reserved. // +import LoopKit import LoopKitUI import SwiftUI -struct PresetsTrainingView: View { +public struct PresetsTrainingView: View { @Environment(\.appName) private var appName @Environment(\.colorPalette) private var colorPalette @@ -25,16 +26,16 @@ struct PresetsTrainingView: View { private let onComplete: (() -> Void)? - init( + public init( navigationPath: [PresetsTraining.Step] = [], startingAt: PresetsTraining.Chapter? = nil, - trainingCompletion: PresetsTrainingCompletion, + trainingCompletionConfiguration: PresetsTraining.TrainingCompletionConfiguration, onComplete: (() -> Void)? = nil ) { self.training = PresetsTraining( navigationPath: navigationPath, startingAt: startingAt, - trainingCompletion: trainingCompletion + trainingCompletionConfiguration: trainingCompletionConfiguration ) self.onComplete = onComplete @@ -51,7 +52,7 @@ struct PresetsTrainingView: View { } } - var body: some View { + public var body: some View { NavigationStack(path: $training.navigationPath) { stepView(training.startingAt.firstStep) .navigationDestination(for: PresetsTraining.Step.self) { step in @@ -78,7 +79,7 @@ struct PresetsTrainingView: View { .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 16) .onLongPressGesture { - guard FeatureFlags.allowDebugFeatures else { + guard training.trainingCompletion.allowDebugFeatures else { return } diff --git a/Loop/Views/WarningPanel.swift b/Loop/Views/WarningPanel.swift deleted file mode 100644 index 69b8e144e8..0000000000 --- a/Loop/Views/WarningPanel.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// WarningPanel.swift -// Loop -// -// Created by Pete Schwamb on 10/28/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import LoopKit -import LoopKitUI - -struct WarningPanel: View { - @Environment(\.guidanceColors) private var guidanceColors - - let severity: WarningSeverity - @ViewBuilder let content: () -> Content - - init(severity: WarningSeverity = .default, @ViewBuilder _ content: @escaping () -> Content) { - self.severity = severity - self.content = content - } - - var body: some View { - let color: Color = severity > .default ? guidanceColors.critical : guidanceColors.warning - - HStack(alignment: .top, spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(color) - - content() - } - .padding(12) - .background(color.opacity(0.1)) - .cornerRadius(12) - } -} diff --git a/LoopCore/SelectablePreset.swift b/LoopCore/SelectablePreset.swift deleted file mode 100644 index b9cbc83fa6..0000000000 --- a/LoopCore/SelectablePreset.swift +++ /dev/null @@ -1,502 +0,0 @@ -// -// SelectablePreset.swift -// Loop -// -// Created by Pete Schwamb on 3/19/25. -// Copyright © 2025 LoopKit Authors. All rights reserved. -// - -import LoopKit -import SwiftUI -import LoopAlgorithm - -public enum PresetDuration: Equatable { - case untilCarbsEntered - case duration(TimeInterval) - case indefinite - - public var presetDuration: TemporaryScheduleOverride.Duration { - switch self { - case .indefinite: return .indefinite - case .duration(let duration): return .finite(duration) - case .untilCarbsEntered: return .indefinite - } - } -} - -public enum PresetExpectedEndTime { - case untilCarbsEntered - case scheduled(Date) - case indefinite -} - -extension TemporaryScheduleOverride.Duration { - public var presetDurationType: PresetDuration { - switch self { - case .finite(let interval): - return .duration(interval) - case .indefinite: - return .indefinite - } - } -} - -extension TemporaryScheduleOverride { - public var expectedEndTime: PresetExpectedEndTime? { - switch context { - case .preMeal: return .untilCarbsEntered - case .activity, .custom, .preset: - switch duration { - case .indefinite: return .indefinite - case .finite: return .scheduled(scheduledEndDate) - } - } - } - - public var presetId: String { - switch context { - case .preMeal: return "preMeal" - case .activity(let activity): return activity.presetId - case .custom: return self.syncIdentifier.uuidString - case .preset(let preset): return preset.id - } - } - - public func createPreset() -> SelectablePreset { - let range = settings.targetRange - - switch context { - case .preMeal: - return .preMeal(range: range!) - case .activity(let activity): - return .activity(activity) - case .custom: - let preset = TemporaryPreset( - id: syncIdentifier.uuidString, - symbol: nil, - name: NSLocalizedString("Single Use Preset", comment: "The title shown for a single use preset"), - settings: settings, - duration: duration - ) - return .custom(preset) - case .preset(let preset): - return .custom(preset) - } - } -} - -extension ActivityPreset { - var presetId: String { - "activity-\(id)" - } -} - -extension PresetDuration: Hashable { - public func hash(into hasher: inout Hasher) { - switch self { - case .indefinite: - hasher.combine("indefinite") - case .untilCarbsEntered: - hasher.combine("untilCarbsEntered") - case .duration(let interval): - hasher.combine("duration") - hasher.combine(interval) - } - } -} - -public enum SelectablePreset: Hashable, Identifiable { - - case custom(TemporaryPreset) - case preMeal(range: ClosedRange) - case activity(ActivityPreset) - - public func hash(into hasher: inout Hasher) { - switch self { - case .custom(let preset): - hasher.combine(preset) - case .activity(let activity): - hasher.combine(activity) - case .preMeal(let range): - hasher.combine("preMeal") - hasher.combine(range) - } - } - - public static func == (lhs: SelectablePreset, rhs: SelectablePreset) -> Bool { - switch (lhs, rhs) { - case (.custom(let lhsPreset), .custom(let rhsPreset)): - return lhsPreset == rhsPreset - case (.activity(let lhsActivity), .activity(let rhsActivity)): - return lhsActivity == rhsActivity - case (.preMeal(let lhsRange), .preMeal(let rhsRange)): - return lhsRange == rhsRange - default: - return false - } - } - - public var id: String { - switch self { - case .custom(let preset): return preset.id - case .activity(let activity): return activity.presetId - case .preMeal: return "preMeal" - } - } - - public var icon: PresetSymbol? { - switch self { - case .custom(let preset): return preset.symbol - case .preMeal: return .image("Pre-Meal-symbol", tint: .preMeal) - case .activity(let activity): return activity.activityType.symbol - } - } - - public var duration: PresetDuration { - get { - switch self { - case .custom(let preset): - switch preset.duration { - case .indefinite: - return .indefinite - case .finite(let duration): - return .duration(duration) - } - case .activity(let activity): - switch activity.preset.duration { - case .indefinite: - return .indefinite - case .finite(let duration): - return .duration(duration) - } - case .preMeal: return .untilCarbsEntered - } - } - set { - switch self { - case .preMeal(let range): - self = .preMeal(range: range) - case .activity(var activity): - activity.preset.settings = TemporaryPresetSettings(targetRange: activity.preset.settings.targetRange, insulinNeedsScaleFactor: activity.preset.settings.insulinNeedsScaleFactor) - switch newValue { - case .indefinite: - activity.preset.duration = .indefinite - case .duration(let duration): - activity.preset.duration = .finite(duration) - default: - break - } - self = .activity(activity) - case .custom(var preset): - preset.settings = TemporaryPresetSettings(targetRange: preset.settings.targetRange, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) - switch newValue { - case .indefinite: - preset.duration = .indefinite - case .duration(let duration): - preset.duration = .finite(duration) - default: - break - } - self = .custom(preset) - } - } - } - - public var isScheduled: Bool { - return nextScheduledStartAfter(Date()) != nil - } - - public func nextScheduledStartAfter(_ date: Date) -> Date? { - switch self { - case .custom(let preset): - return preset.nextScheduledStartAfter(date) - case .activity(let activity): - return activity.preset.nextScheduledStartAfter(date) - case .preMeal: - return nil - } - } - - public var scheduleStartDate: Date? { - get { - switch self { - case .custom(let preset): - return preset.scheduleStartDate - case .activity(let activity): - return activity.preset.scheduleStartDate - case .preMeal: - return nil - } - } - set { - switch self { - case .custom(var preset): - preset.scheduleStartDate = newValue - self = .custom(preset) - case .activity(var activity): - activity.preset.scheduleStartDate = newValue - self = .activity(activity) - case .preMeal: - break - } - } - } - - public var repeatOptions: PresetScheduleRepeatOptions { - get { - switch self { - case .custom(let preset): - return preset.repeatOptions ?? .none - case .activity(let activity): - return activity.preset.repeatOptions ?? .none - case .preMeal: - return .none - } - } - set { - switch self { - case .custom(var preset): - preset.repeatOptions = newValue - self = .custom(preset) - case .activity(var activity): - activity.preset.repeatOptions = newValue - self = .activity(activity) - case .preMeal: - break - } - } - } - - - public var name: String { - get { - switch self { - case .custom(let preset): return preset.name - case .preMeal: return NSLocalizedString("Pre-Meal", comment: "The title of pre-meal preset") - case .activity(let activity): return activity.activityType.name - } - } - set { - switch self { - case .custom(var preset): preset.name = newValue; self = .custom(preset) - default: break - } - } - } - - public var correctionRange: ClosedRange? { - get { - switch self { - case .custom(let preset): return preset.settings.targetRange - case .preMeal(let range): return range - case .activity(let activity): return activity.preset.settings.targetRange - } - } - - set { - switch self { - case .preMeal: - self = .preMeal(range: newValue!) - case .activity(var activity): - activity.preset.settings = TemporaryPresetSettings(targetRange: newValue, insulinNeedsScaleFactor: activity.preset.settings.insulinNeedsScaleFactor) - self = .activity(activity) - case .custom(var preset): - preset.settings = TemporaryPresetSettings(targetRange: newValue, insulinNeedsScaleFactor: preset.settings.insulinNeedsScaleFactor) - self = .custom(preset) - } - } - } - - public var insulinSensitivityMultiplier: Double? { - if case .custom(let preset) = self { - return preset.settings.insulinSensitivityMultiplier - } else if case .activity(let activity) = self { - return activity.preset.settings.insulinSensitivityMultiplier - } else { - return nil - } - } - - public var insulinNeedsScaleFactor: Double { - get { - if case .custom(let preset) = self { - return 1.0 / (preset.settings.insulinSensitivityMultiplier ?? 1) - } else if case .activity(let activity) = self { - return 1.0 / (activity.preset.settings.insulinSensitivityMultiplier ?? 1) - } else { - return 1.0 - } - } - set { - if case .activity(var activity) = self { - activity.preset.settings = TemporaryPresetSettings(targetRange: activity.preset.settings.targetRange, insulinNeedsScaleFactor: newValue) - self = .activity(activity) - } else if case .custom(var preset) = self { - preset.settings = TemporaryPresetSettings(targetRange: preset.settings.targetRange, insulinNeedsScaleFactor: newValue) - self = .custom(preset) - } - } - } - - public var canAdjustSensitivity: Bool { - switch self { - case .custom, .activity: - return true - case .preMeal: - return false - } - } - - public var allowsIndefiniteDuration: Bool { - switch self { - case .custom: - return true - case .preMeal, .activity: - return false - } - } - - public var canAdjustDuration: Bool { - switch self { - case .custom, .activity: - return true - case .preMeal: - return false - } - } - - public var canChangeName: Bool { - switch self { - case .custom: - return true - case .preMeal, .activity: - return false - } - } - - public var allowsScheduling: Bool { - switch self { - case .custom, .activity: - return true - case .preMeal: - return false - } - } - - public var canBeDeleted: Bool { - switch self { - case .custom: - return true - case .preMeal, .activity: - return false - } - } - - public var isPreMeal: Bool { - if case .preMeal = self { - return true - } - return false - } - - public var dateCreated: Date { - switch self { - case .custom: - return .distantPast // TODO - case .preMeal: - return .distantPast.addingTimeInterval(1) - case .activity: - return .distantPast - } - } - - public var veryHighInsulinNeeds: Bool { - return TemporaryScheduleOverride.isInMitigationRange(insulinNeedsScaleFactor: insulinNeedsScaleFactor) - } - -} - -extension SelectablePreset { - public func createOverride(beginningAt: Date = Date()) -> TemporaryScheduleOverride { - switch self { - case .custom(let temporaryScheduleOverridePreset): - return temporaryScheduleOverridePreset.createOverride(enactTrigger: .local, beginningAt: beginningAt) - case .activity(let activity): - return activity.preset.createOverride(enactTrigger: .local, beginningAt: beginningAt) - case .preMeal(let targetRange): - return TemporaryScheduleOverride( - context: .preMeal, - settings: TemporaryPresetSettings(targetRange: targetRange), - startDate: beginningAt, - duration: .finite(.hours(1)), - enactTrigger: .local, - syncIdentifier: UUID() - ) - } - - } -} - -extension PresetExpectedEndTime { - private static let timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - return formatter - }() - - public var localizedTitle: String { - switch self { - case .untilCarbsEntered: - return NSLocalizedString("on until carbs added", comment: "Preset card pre-meal expected end time") - case .indefinite: - return NSLocalizedString("on until turned off", comment: "Preset card indefinite scheduled end time") - case .scheduled(let date): - return NSLocalizedString("on until \(Self.timeFormatter.string(from: date))", comment: "Presets card time duration accessibility label") - } - } - - public var accessibilityLabel: String { - switch self { - case .untilCarbsEntered: - return NSLocalizedString("on until carbs added", comment: "Presets card pre-meal expected end time accessibility label") - case .indefinite: - return NSLocalizedString("on until turned off", comment: "Presets card indefinite duration accessibility label") - case .scheduled(let date): - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute] - formatter.unitsStyle = .spellOut - return NSLocalizedString("on until \(Self.timeFormatter.string(from: date))", comment: "Presets card time duration accessibility label") - } - } -} - -extension PresetDuration { - public var localizedTitle: String { - switch self { - case .untilCarbsEntered: - return NSLocalizedString("until carbs added", comment: "Preset card pre-meal duration") - case .indefinite: - return NSLocalizedString("until turned off", comment: "Preset card indefinite duration") - case .duration(let duration): - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute] - formatter.unitsStyle = .short - return formatter.string(from: duration) ?? "" - - } - } - - public var accessibilityLabel: String { - switch self { - case .untilCarbsEntered: - return NSLocalizedString("Active until carbs are added", comment: "Presets card pre-meal duration accessibility label") - case .indefinite: - return NSLocalizedString("Active until turned off", comment: "Presets card indefinite duration accessibility label") - case .duration(let duration): - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute] - formatter.unitsStyle = .spellOut - return NSLocalizedString("Active for \(formatter.string(from: duration) ?? "")", comment: "Presets card time duration accessibility label") - } - } -} diff --git a/WatchApp/ContentView.swift b/WatchApp/ContentView.swift index fe8d48a328..45c9e9fa7f 100644 --- a/WatchApp/ContentView.swift +++ b/WatchApp/ContentView.swift @@ -6,8 +6,8 @@ // Copyright © 2025 LoopKit Authors. All rights reserved. // +import LoopKit import SwiftUI -import LoopCore struct ContentView: View { @Environment(LoopDataManager.self) var loopManager From b81e0c6dec9b4d4da232f4e64b4263e29172c04c Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 28 Jan 2026 07:51:34 -0400 Subject: [PATCH 352/421] [LOOP-5710] removed asserts from testflight builds (#895) --- Loop.xcodeproj/project.pbxproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 03dfbffcd1..1a0d2a172e 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -5179,6 +5179,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; + ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; From 04913dadfaf316e7e040f39f6c52f78c83c769e3 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 28 Jan 2026 18:43:01 -0400 Subject: [PATCH 353/421] [LOOP-5677] fix for crash (#896) --- Loop/Managers/Alerts/AlertStore.swift | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift index a87cab8ed0..1e5cd8fa51 100644 --- a/Loop/Managers/Alerts/AlertStore.swift +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -84,17 +84,23 @@ public class AlertStore { } public func recordIssued(alert: Alert, at date: Date = Date()) async { - do { - try await self.managedObjectContext.perform { + await self.managedObjectContext.perform { + do { _ = StoredAlert(from: alert, context: self.managedObjectContext, issuedDate: date) - try self.managedObjectContext.save() + if self.managedObjectContext.hasChanges { + try self.managedObjectContext.save() + } self.log.default("Recorded alert: %{public}@", alert.identifier.value) - self.purgeExpired() + } catch { + self.log.error("Could not store alert: %{public}@, %{public}@", alert.identifier.value, String(describing: error)) } - await delegate?.alertStoreHasUpdatedAlertData(self) - } catch { - self.log.error("Could not store alert: %{public}@, %{public}@", alert.identifier.value, String(describing: error)) } + + await self.managedObjectContext.perform { + self.purgeExpired() + } + + await delegate?.alertStoreHasUpdatedAlertData(self) } public func recordRetractedAlert(_ alert: Alert, at date: Date) async throws { From 6ad659a0a943259d7cd322aafee28bc0d98c2d3f Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 29 Jan 2026 12:09:58 -0800 Subject: [PATCH 354/421] [LOOP-5582, LOOP-5613, LOOP-5711 & LOOP-5713] Status Icon and Toolbar Fixes (#894) * [LOOP-5613] Update Loop status Icon when "Last Loop" text is updated * [LOOP-5711] CircleView animation updates * [LOOP-5711] CircleView animation updates * [LOOP-5582] Tab bar fixes * Timer feedback --- Loop/Managers/LoopAppManager.swift | 3 +- .../StatusTableViewController.swift | 10 +- Loop/View Models/SettingsViewModel.swift | 14 +- Loop/Views/SettingsView.swift | 3 +- Loop/Views/StatusTableView.swift | 229 ++++++++---------- LoopUI/Views/LoopCompletionHUDView.swift | 2 - LoopUI/Views/LoopStateView.swift | 20 +- WatchApp Extension/Views/ChartPageView.swift | 6 +- WatchApp Extension/Views/LoopCircleView.swift | 4 +- WatchApp Extension/Views/LoopHeader.swift | 10 +- .../Views/WatchActionsView.swift | 2 +- 11 files changed, 141 insertions(+), 162 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index b305d8089f..2783e05095 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -572,7 +572,8 @@ class LoopAppManager: NSObject { isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceDataManager, presetHistory: temporaryPresetsManager.presetHistory, - deliveryDelegate: deviceDataManager + deliveryDelegate: deviceDataManager, + deviceManager: deviceDataManager ) viewModel.favoriteFoodInsightsDelegate = loopDataManager diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 36429a1635..765e277d58 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -330,7 +330,7 @@ final class StatusTableViewController: LoopChartsTableViewController { private func setupPresetsStatusBar() { let backgroundContainerView = UIView() - backgroundContainerView.backgroundColor = .secondarySystemBackground + backgroundContainerView.backgroundColor = .systemBackground let statusBarBackgroundView = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 0)) self.statusBarBackgroundView = statusBarBackgroundView backgroundContainerView.addSubview(statusBarBackgroundView) @@ -366,7 +366,11 @@ final class StatusTableViewController: LoopChartsTableViewController { } // Toggles the display mode based on the screen aspect ratio. Should not be updated outside of reloadData(). - private var landscapeMode = false + private var landscapeMode = false { + didSet { + setupPresetsStatusBar() + } + } private var lastLoopError: Error? @@ -782,7 +786,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func updateStatusBar() { - statusBarBackgroundView?.backgroundColor = shouldShowPresets ? .presets : .secondarySystemBackground + statusBarBackgroundView?.backgroundColor = landscapeMode ? .systemBackground : (shouldShowPresets ? .presets : .secondarySystemBackground) statusBarBackgroundView?.frame.size.height = abs(tableView.contentOffset.y) + (shouldShowPresets ? tableView(tableView, cellForRowAt: IndexPath(row: 0, section: 0)).contentView.frame.height + 8 : 0) } diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 34d8d554eb..9905127ce1 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -112,6 +112,13 @@ class SettingsViewModel { delegate?.dosingEnabledChanged(closedLoopPreference) } } + + private var deviceManager: DeviceDataManager? + + @MainActor + var deviceInoperable: Bool { + deviceManager?.cgmManager == nil || deviceManager?.cgmManager?.isInoperable == true || deviceManager?.pumpManager == nil || deviceManager?.pumpManager?.isInoperable == true || deviceManager?.hasBluetoothIssue != false + } var preMealGuardrail: Guardrail? @@ -157,7 +164,8 @@ class SettingsViewModel { isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, presetHistory: TemporaryScheduleOverrideHistory, - deliveryDelegate: DeliveryDelegate? + deliveryDelegate: DeliveryDelegate?, + deviceManager: DeviceDataManager?, ) { self.alertPermissionsChecker = alertPermissionsChecker self.alertMuter = alertMuter @@ -177,6 +185,7 @@ class SettingsViewModel { self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate self.presetHistory = presetHistory self.deliveryDelegate = deliveryDelegate + self.deviceManager = deviceManager // This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject) lastLoopCompletion @@ -245,7 +254,8 @@ extension SettingsViewModel { isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, presetHistory: TemporaryScheduleOverrideHistory(), - deliveryDelegate: nil + deliveryDelegate: nil, + deviceManager: nil ) } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 6358b47a46..98800eaee7 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -224,7 +224,8 @@ extension SettingsView { HStack(spacing: 12) { LoopCircleView( closedLoop: viewModel.automaticDosingEnabled, - freshness: viewModel.loopStatusCircleFreshness + freshness: viewModel.loopStatusCircleFreshness, + deviceInoperable: viewModel.deviceInoperable ) .frame(width: 36, height: 36) .padding(12) diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 1f0167d322..8dddb8e1b9 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -88,6 +88,13 @@ private struct WrappedStatusTableViewController: UIViewControllerRepresentable { struct StatusTableView: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + + private var isLandscape: Bool { + UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height + } + private let wrapped: WrappedStatusTableViewController var viewController: StatusTableViewController { @@ -124,25 +131,9 @@ struct StatusTableView: View { ) } - func isActive(action: ToolbarAction) -> Bool { - switch action { - case .addCarbs, .bolus, .settings: // No active states for these actions - return false - case .presets: - return viewModel.temporaryPresetsManager.activeOverride != nil - } - } - - func isDisabled(action: ToolbarAction) -> Bool { - switch action { - case .addCarbs, .bolus, .settings, .presets: - false - } - } - var body: some View { wrappedView - .ignoresSafeArea(.keyboard, edges: .bottom) + .ignoresSafeArea(edges: .bottom) .onChange(of: viewModel.temporaryPresetsManager.activeOverride) { _, _ in Task { await viewController.reloadData(animated: true) @@ -154,140 +145,126 @@ struct StatusTableView: View { .accessibilityIdentifier("bar_Presets") } .toolbar { - ToolbarItem(placement: .bottomBar) { - HStack(alignment: .bottom, spacing: 0) { - ForEach(ToolbarAction.allCases) { action in - action.button( - showTitle: true, - isActive: isActive(action: action), - disabled: isDisabled(action: action) - ) { - switch action { - case .addCarbs: - viewController.userTappedAddCarbs() - case .bolus: - viewController.presentBolusScreen() - case .presets: - viewController.presentPresets() - case .settings: - viewController.presentSettings() - } + if !isLandscape { + if #available(iOS 26, *) { + ToolbarItem(placement: .bottomBar) { + carbTab.padding(.horizontal, 12) + } + ToolbarItem(placement: .bottomBar) { + bolusTab.padding(.horizontal, 12) + } + ToolbarItem(placement: .bottomBar) { + presetsTab.padding(.horizontal, 12) + } + ToolbarItem(placement: .bottomBar) { + settingsTab.padding(.horizontal, 12) + } + } else { + ToolbarItem(placement: .bottomBar) { + HStack { + carbTab + bolusTab + presetsTab + settingsTab } } } } } + .toolbar(isLandscape ? .hidden : .visible, for: .bottomBar) .toolbarBackground(.visible, for: .bottomBar) } -} - -enum ToolbarAction: String, Identifiable, CaseIterable { - case addCarbs - case bolus - case presets - case settings - - var id: String { self.rawValue } - var accessibilityIdentifier: String { - switch self { - case .addCarbs: - "statusTableViewControllerCarbsButton" - case .bolus: - "statusTableViewControllerBolusButton" - case .presets: - "statusTableViewPresetsButton" - case .settings: - "statusTableViewControllerSettingsButton" - } - } - - @ViewBuilder - func icon(isActive: Bool) -> some View { - Group { - switch self { - case .addCarbs: + var carbTab: some View { + Button { + viewController.userTappedAddCarbs() + } label: { + VStack(spacing: 0) { Image("carbs") - .resizable() .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(height: 32) + .frame(maxWidth: .infinity) .foregroundStyle(Color.carbs) - case .bolus: + + Text("Add Carbs") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize() + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("statusTableViewControllerCarbsButton") + } + + var bolusTab: some View { + Button { + viewController.presentBolusScreen() + } label: { + VStack(spacing: 0) { Image("bolus") - .resizable() .renderingMode(.template) - .foregroundStyle(Color.insulin) - case .presets: - Image(isActive ? "presets-selected" : "presets") - .resizable() - .renderingMode(.template) - .foregroundStyle(Color.presets) - .accessibilityIdentifier("image_\(isActive ? "PresetsSelected" : "Presets")") - case .settings: - Image("settings") .resizable() - .renderingMode(.template) - .foregroundStyle(Color(UIColor.secondaryLabel)) + .scaledToFit() + .frame(height: 32) + .frame(maxWidth: .infinity) + .foregroundStyle(Color.insulin) + + Text("Bolus") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize() } } - .aspectRatio(contentMode: .fit) - .frame(width: showCompactToolbar ? 24 : 32, height: showCompactToolbar ? 24 : 32) + .buttonStyle(.plain) + .accessibilityIdentifier("statusTableViewControllerBolusButton") } - @ViewBuilder - var title: some View { - Group { - switch self { - case .addCarbs: - Text("Add Carbs", comment: "The label of the carb entry button") - case .bolus: - Text("Bolus", comment: "The label of the bolus entry button") - case .presets: - Text("Presets", comment: "The label of the presets button") - case .settings: - Text("Settings", comment: "The label of the settings button") + var presetsTab: some View { + Button { + viewController.presentPresets() + } label: { + VStack(spacing: 0) { + Image(viewModel.temporaryPresetsManager.activeOverride != nil ? "presets-selected" : "presets") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(height: 32) + .frame(maxWidth: .infinity) + .foregroundStyle(Color.presets) + .animation(.default, value: viewModel.temporaryPresetsManager.activeOverride) + + Text("Presets") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize() } } - .frame(maxWidth: .infinity) - .foregroundStyle(.secondary) - .font(.footnote) + .buttonStyle(.plain) + .accessibilityIdentifier("statusTableViewPresetsButton") } - @ViewBuilder - func button( - showTitle: Bool, - isActive: Bool, - disabled: Bool, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - VStack(spacing: showCompactToolbar ? 2 : 4) { - icon(isActive: isActive) + var settingsTab: some View { + Button { + viewController.presentSettings() + } label: { + VStack(spacing: 0) { + Image("settings") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(height: 32) + .frame(maxWidth: .infinity) + .foregroundStyle(Color.secondary) - if showTitle { - title - } + Text("Settings") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize() } - .padding(.bottom, showCompactToolbar ? 0 : -12) - .contentShape(Rectangle()) } .buttonStyle(.plain) - .animation(.default, value: isActive) - .disabled(disabled) - .accessibilityIdentifier(accessibilityIdentifier) - } -} - -private var showCompactToolbar: Bool { - let window = UIApplication - .shared - .connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap { $0.windows } - .first { $0.isKeyWindow } - - guard let safeAreaBottom = window?.safeAreaInsets.bottom else { - return true + .accessibilityIdentifier("statusTableViewControllerSettingsButton") } - - return safeAreaBottom <= 0 } diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index 5f39825cfe..a996d224f1 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -74,8 +74,6 @@ public final class LoopCompletionHUDView: BaseHUDView { public var loopInProgress = false { didSet { - loopStateView.animated = loopInProgress - if !loopInProgress { updateTimer = nil assertTimer() diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index 46d3bebe62..467cde64dc 100644 --- a/LoopUI/Views/LoopStateView.swift +++ b/LoopUI/Views/LoopStateView.swift @@ -14,28 +14,18 @@ import UIKit class WrappedLoopStateViewModel: ObservableObject { @Published var loopStatusColors: StateColorPalette @Published var closedLoop: Bool - @Published var freshness: LoopCompletionFreshness { - didSet { - switch freshness { - case .aging, .stale: animating = true - default: animating = false - } - } - } - @Published var animating: Bool + @Published var freshness: LoopCompletionFreshness @Published var deviceInoperable: Bool init( loopStatusColors: StateColorPalette = StateColorPalette(unknown: .black, normal: .black, warning: .black, error: .black), closedLoop: Bool = true, freshness: LoopCompletionFreshness = .stale, - animating: Bool = false, deviceInoperable: Bool = false ) { self.loopStatusColors = loopStatusColors self.closedLoop = closedLoop self.freshness = freshness - self.animating = animating self.deviceInoperable = deviceInoperable } } @@ -45,7 +35,7 @@ struct WrappedLoopCircleView: View { @StateObject var viewModel: WrappedLoopStateViewModel var body: some View { - LoopCircleView(closedLoop: viewModel.closedLoop, freshness: viewModel.freshness, animating: viewModel.animating, deviceInoperable: viewModel.deviceInoperable) + LoopCircleView(animationAllowed: true, closedLoop: viewModel.closedLoop, freshness: viewModel.freshness, deviceInoperable: viewModel.deviceInoperable) .environment(\.loopStatusColorPalette, viewModel.loopStatusColors) } } @@ -96,12 +86,6 @@ final class LoopStateView: UIView { viewModel.closedLoop = !open } } - - var animated: Bool = false { - didSet { - viewModel.animating = animated - } - } var deviceInoperable: Bool = false { didSet { diff --git a/WatchApp Extension/Views/ChartPageView.swift b/WatchApp Extension/Views/ChartPageView.swift index 4b9bdd4d6b..04ca559d5d 100644 --- a/WatchApp Extension/Views/ChartPageView.swift +++ b/WatchApp Extension/Views/ChartPageView.swift @@ -23,6 +23,8 @@ struct ChartPageView: View { @ScaledMetric private var iconSize: Double = 26 + private let lastSyncUpdateTimer = Timer.publish(every: 10, on: .main, in: .common).autoconnect() + var presetActive: Bool { return loopManager.watchInfo.scheduleOverride?.isActive() == true } @@ -60,8 +62,6 @@ struct ChartPageView: View { ) } - let lastSyncUpdateTimer = Timer.publish(every: 10, on: .main, in: .common).autoconnect() - var activeInsulin: String? { guard let activeContext = loopManager.activeContext, let activeInsulin = activeContext.activeInsulin @@ -157,7 +157,7 @@ struct ChartPageView: View { var body: some View { ScrollView(.vertical) { - LoopHeader() + LoopHeader(freshness: LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.loopLastRunDate, at: Date())) chartView VStack(spacing: 8) { diff --git a/WatchApp Extension/Views/LoopCircleView.swift b/WatchApp Extension/Views/LoopCircleView.swift index a5b808caca..d064b77517 100644 --- a/WatchApp Extension/Views/LoopCircleView.swift +++ b/WatchApp Extension/Views/LoopCircleView.swift @@ -36,9 +36,9 @@ public struct LoopCircleView: View { public var body: some View { GeometryReader { geometry in Circle() - .trim(from: closedLoop ? 0 : 0.2, to: 1) + .trim(from: closedLoop ? 0 : 0.25, to: 1) .stroke(loopColor, lineWidth: geometry.size.height / 5) - .rotationEffect(Angle(degrees: closedLoop ? -90 : -126)) + .rotationEffect(Angle(degrees: closedLoop ? -90 : -135)) .animation(.none, value: freshness) .animation(.default, value: closedLoop) .scaleEffect(animating && closedLoop ? 0.75 : 1) diff --git a/WatchApp Extension/Views/LoopHeader.swift b/WatchApp Extension/Views/LoopHeader.swift index be6a25219f..169aa0de18 100644 --- a/WatchApp Extension/Views/LoopHeader.swift +++ b/WatchApp Extension/Views/LoopHeader.swift @@ -13,9 +13,9 @@ import LoopCore struct LoopHeader: View { @Environment(LoopDataManager.self) var loopManager - var freshness: LoopCompletionFreshness { - return LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.loopLastRunDate, at: Date()) - } + @State var freshness: LoopCompletionFreshness + + private let lastSyncUpdateTimer = Timer.publish(every: 10, on: .main, in: .common).autoconnect() var body: some View { HStack { @@ -26,6 +26,10 @@ struct LoopHeader: View { .frame(width: 22, height: 22) .padding(.horizontal) + .onReceive(lastSyncUpdateTimer) { _ in + self.freshness = LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.loopLastRunDate, at: Date()) + } + Text(loopManager.glucoseValue) Spacer() diff --git a/WatchApp Extension/Views/WatchActionsView.swift b/WatchApp Extension/Views/WatchActionsView.swift index 369389af6a..4941f7e632 100644 --- a/WatchApp Extension/Views/WatchActionsView.swift +++ b/WatchApp Extension/Views/WatchActionsView.swift @@ -23,7 +23,7 @@ struct WatchActionsView: View { var body: some View { ScrollView(.vertical) { - LoopHeader() + LoopHeader(freshness: LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.loopLastRunDate, at: Date())) HStack(spacing: 0) { CircleTintedButton( From 81622c5f970587b32c4e0c1c1e73ac332fedd5ab Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 2 Feb 2026 11:51:05 -0800 Subject: [PATCH 355/421] [LOOP-5582] Add Toolbar Spacers to Ensure Proper iOS 26 spacing (#897) --- Loop/Views/StatusTableView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index 8dddb8e1b9..b579bc0b77 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -147,18 +147,23 @@ struct StatusTableView: View { .toolbar { if !isLandscape { if #available(iOS 26, *) { + ToolbarSpacer() ToolbarItem(placement: .bottomBar) { carbTab.padding(.horizontal, 12) } + ToolbarSpacer() ToolbarItem(placement: .bottomBar) { bolusTab.padding(.horizontal, 12) } + ToolbarSpacer() ToolbarItem(placement: .bottomBar) { presetsTab.padding(.horizontal, 12) } + ToolbarSpacer() ToolbarItem(placement: .bottomBar) { settingsTab.padding(.horizontal, 12) } + ToolbarSpacer() } else { ToolbarItem(placement: .bottomBar) { HStack { From eadcf89634263d1653659183899eed9da33631b9 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 5 Feb 2026 01:28:19 -0800 Subject: [PATCH 356/421] Temporarily Disable Liquid Glass (#898) --- Loop/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Loop/Info.plist b/Loop/Info.plist index ddad5426ac..41d479164f 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -84,6 +84,8 @@ processing remote-notification
+ UIDesignRequiresCompatibility + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile From 4a0349e88e1cc381be9a69016730d1a6b260eb3d Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 5 Feb 2026 12:02:43 -0400 Subject: [PATCH 357/421] [LOOP-5732] signal loss display device issue state (#899) --- Loop/Managers/WatchDataManager.swift | 2 +- .../View Controllers/StatusTableViewController.swift | 7 ++++++- Loop/View Models/SettingsViewModel.swift | 4 ++-- Loop/Views/LoopStatusModalView.swift | 9 ++++++--- Loop/Views/SettingsView.swift | 2 +- LoopCore/Models/WatchContext.swift | 10 +++++----- LoopUI/Views/LoopCompletionHUDView.swift | 4 ++-- LoopUI/Views/LoopStateView.swift | 12 ++++++------ WatchApp Extension/Views/LoopCircleView.swift | 8 ++++---- WatchApp Extension/Views/LoopHeader.swift | 2 +- 10 files changed, 34 insertions(+), 26 deletions(-) diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index fd8c4aea14..868f8bedfb 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -300,7 +300,7 @@ final class WatchDataManager: NSObject { let settings = self.settingsManager.loopSettings context.isClosedLoop = settings.dosingEnabled - context.deviceInoperable = deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true || deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true || deviceManager.hasBluetoothIssue + context.deviceIssue = deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true || deviceManager.cgmManager?.inSignalLoss == true || deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true || deviceManager.pumpManager?.inSignalLoss == true || deviceManager.hasBluetoothIssue context.potentialCarbEntry = potentialCarbEntry diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 765e277d58..0e8e5ca58d 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -412,6 +412,11 @@ final class StatusTableViewController: LoopChartsTableViewController { override func createChartsManager() -> ChartsManager { return statusCharts } + + private var deviceIssue: Bool { + // includes when devices are in signal loss, even though that is recoverable + deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true || deviceManager.cgmManager?.inSignalLoss == true || deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true || deviceManager.pumpManager?.inSignalLoss == true || deviceManager.hasBluetoothIssue + } private func updateChartDateRange() { // How far back should we show data? Use the screen size as a guide. @@ -440,7 +445,7 @@ final class StatusTableViewController: LoopChartsTableViewController { // This should be kept up to date immediately hudView?.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted - hudView?.loopCompletionHUD.deviceInoperable = deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true || deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true || deviceManager.hasBluetoothIssue + hudView?.loopCompletionHUD.deviceIssue = deviceIssue hudView?.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate hudView?.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate updateLoopCompletionModal() diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 9905127ce1..0b46636cd2 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -116,8 +116,8 @@ class SettingsViewModel { private var deviceManager: DeviceDataManager? @MainActor - var deviceInoperable: Bool { - deviceManager?.cgmManager == nil || deviceManager?.cgmManager?.isInoperable == true || deviceManager?.pumpManager == nil || deviceManager?.pumpManager?.isInoperable == true || deviceManager?.hasBluetoothIssue != false + var deviceIssue: Bool { + deviceManager?.cgmManager == nil || deviceManager?.cgmManager?.isInoperable == true || deviceManager?.cgmManager?.inSignalLoss == true || deviceManager?.pumpManager == nil || deviceManager?.pumpManager?.isInoperable == true || deviceManager?.pumpManager?.inSignalLoss == true || deviceManager?.hasBluetoothIssue != false } var preMealGuardrail: Guardrail? diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index b1486e539c..6334d0ce63 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -28,7 +28,7 @@ struct LoopStatusModalView: View { } } - private var deviceInoperable: Bool { + private var deviceIssue: Bool { viewModel.isCGMInoperable || viewModel.isPumpInoperable || viewModel.hasBluetoothIssue } @@ -38,7 +38,7 @@ struct LoopStatusModalView: View { .padding(5) .frame(maxWidth: .infinity, alignment: .trailing) - LoopCircleView(closedLoop: viewModel.loopIconClosed, freshness: viewModel.freshness, deviceInoperable: deviceInoperable) + LoopCircleView(closedLoop: viewModel.loopIconClosed, freshness: viewModel.freshness, deviceIssue: deviceIssue) .environment(\.loopStatusColorPalette, loopStatusColors) .padding(.bottom) @@ -158,7 +158,10 @@ class LoopStatusModalViewModel { var lastLoopCompleted: Date? var freshness: LoopCompletionFreshness { - LoopCompletionFreshness(age: ago) + guard !isPumpInSignalLoss, !isCGMInSignalLoss else { + return .stale + } + return LoopCompletionFreshness(age: ago) } var ago: TimeInterval? { guard let lastLoopCompleted else { return nil } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 98800eaee7..c77f990ee8 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -225,7 +225,7 @@ extension SettingsView { LoopCircleView( closedLoop: viewModel.automaticDosingEnabled, freshness: viewModel.loopStatusCircleFreshness, - deviceInoperable: viewModel.deviceInoperable + deviceIssue: viewModel.deviceIssue ) .frame(width: 36, height: 36) .padding(12) diff --git a/LoopCore/Models/WatchContext.swift b/LoopCore/Models/WatchContext.swift index 5277b1de14..dd73f353b6 100644 --- a/LoopCore/Models/WatchContext.swift +++ b/LoopCore/Models/WatchContext.swift @@ -62,7 +62,7 @@ public final class WatchContext: RawRepresentable { public var cgmManagerState: CGMManager.RawStateValue? public var isClosedLoop: Bool? - public var deviceInoperable: Bool? + public var deviceIssue: Bool? public init( creationDate: Date = Date(), @@ -90,7 +90,7 @@ public final class WatchContext: RawRepresentable { insulinDeliveryState: InsulinDeliveryWatchState? = nil, lastManualBolus: LastManualBolus? = nil, isClosedLoop: Bool? = nil, - deviceInoperable: Bool? = nil + deviceIssue: Bool? = nil ) { self.creationDate = creationDate self.displayGlucoseUnit = displayGlucoseUnit @@ -117,7 +117,7 @@ public final class WatchContext: RawRepresentable { self.insulinDeliveryState = insulinDeliveryState self.lastManualBolus = lastManualBolus self.isClosedLoop = isClosedLoop - self.deviceInoperable = deviceInoperable + self.deviceIssue = deviceIssue } public required init?(rawValue: RawValue) { @@ -127,7 +127,7 @@ public final class WatchContext: RawRepresentable { self.creationDate = creationDate isClosedLoop = rawValue["cl"] as? Bool - deviceInoperable = rawValue["di"] as? Bool + deviceIssue = rawValue["di"] as? Bool if let unitString = rawValue["gu"] as? String { displayGlucoseUnit = LoopUnit(from: unitString) @@ -190,7 +190,7 @@ public final class WatchContext: RawRepresentable { raw["bad"] = lastNetTempBasalDate raw["bp"] = batteryPercentage raw["cl"] = isClosedLoop - raw["di"] = deviceInoperable + raw["di"] = deviceIssue raw["cgmManagerState"] = cgmManagerState diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index a996d224f1..de032af1ad 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -63,9 +63,9 @@ public final class LoopCompletionHUDView: BaseHUDView { } } - public var deviceInoperable: Bool = false { + public var deviceIssue: Bool = false { didSet { - loopStateView.deviceInoperable = deviceInoperable + loopStateView.deviceIssue = deviceIssue } } diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index 467cde64dc..c8cf4c7e7c 100644 --- a/LoopUI/Views/LoopStateView.swift +++ b/LoopUI/Views/LoopStateView.swift @@ -15,18 +15,18 @@ class WrappedLoopStateViewModel: ObservableObject { @Published var loopStatusColors: StateColorPalette @Published var closedLoop: Bool @Published var freshness: LoopCompletionFreshness - @Published var deviceInoperable: Bool + @Published var deviceIssue: Bool init( loopStatusColors: StateColorPalette = StateColorPalette(unknown: .black, normal: .black, warning: .black, error: .black), closedLoop: Bool = true, freshness: LoopCompletionFreshness = .stale, - deviceInoperable: Bool = false + deviceIssue: Bool = false ) { self.loopStatusColors = loopStatusColors self.closedLoop = closedLoop self.freshness = freshness - self.deviceInoperable = deviceInoperable + self.deviceIssue = deviceIssue } } @@ -35,7 +35,7 @@ struct WrappedLoopCircleView: View { @StateObject var viewModel: WrappedLoopStateViewModel var body: some View { - LoopCircleView(animationAllowed: true, closedLoop: viewModel.closedLoop, freshness: viewModel.freshness, deviceInoperable: viewModel.deviceInoperable) + LoopCircleView(animationAllowed: true, closedLoop: viewModel.closedLoop, freshness: viewModel.freshness, deviceIssue: viewModel.deviceIssue) .environment(\.loopStatusColorPalette, viewModel.loopStatusColors) } } @@ -87,9 +87,9 @@ final class LoopStateView: UIView { } } - var deviceInoperable: Bool = false { + var deviceIssue: Bool = false { didSet { - viewModel.deviceInoperable = deviceInoperable + viewModel.deviceIssue = deviceIssue } } diff --git a/WatchApp Extension/Views/LoopCircleView.swift b/WatchApp Extension/Views/LoopCircleView.swift index d064b77517..61fbb321ce 100644 --- a/WatchApp Extension/Views/LoopCircleView.swift +++ b/WatchApp Extension/Views/LoopCircleView.swift @@ -16,13 +16,13 @@ public struct LoopCircleView: View { private let animating: Bool private let closedLoop: Bool private let freshness: LoopCompletionFreshness - private let deviceInoperable: Bool + private let deviceIssue: Bool - public init(closedLoop: Bool, freshness: LoopCompletionFreshness, animating: Bool = false, deviceInoperable: Bool = false) { + public init(closedLoop: Bool, freshness: LoopCompletionFreshness, animating: Bool = false, deviceIssue: Bool = false) { self.closedLoop = closedLoop self.freshness = freshness self.animating = animating - self.deviceInoperable = deviceInoperable + self.deviceIssue = deviceIssue } private var reversingAnimation: Animation { @@ -49,7 +49,7 @@ public struct LoopCircleView: View { private var loopColor: Color { if !isEnabled { return .defaultWatchButtonGray - } else if deviceInoperable { + } else if deviceIssue { return .gray } else { switch freshness { diff --git a/WatchApp Extension/Views/LoopHeader.swift b/WatchApp Extension/Views/LoopHeader.swift index 169aa0de18..ae973cb71d 100644 --- a/WatchApp Extension/Views/LoopHeader.swift +++ b/WatchApp Extension/Views/LoopHeader.swift @@ -22,7 +22,7 @@ struct LoopHeader: View { if let activeContext = loopManager.activeContext, let unit = activeContext.displayGlucoseUnit { - LoopCircleView(closedLoop: activeContext.isClosedLoop ?? false, freshness: freshness, deviceInoperable: loopManager.activeContext?.deviceInoperable ?? true) + LoopCircleView(closedLoop: activeContext.isClosedLoop ?? false, freshness: freshness, deviceIssue: loopManager.activeContext?.deviceIssue ?? true) .frame(width: 22, height: 22) .padding(.horizontal) From b9cdb7163158b8ff6b1c81bfe4e824004671cddb Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 5 Feb 2026 14:30:08 -0800 Subject: [PATCH 358/421] [LOOP-5734] Remove Pulsing Animation Logic for Watch Loop Status Icon (#900) * [LOOP-5734] Remove Pulsing Animation Logic for Watch Loop Status Icon * [LOOP-5734] Remove Pulsing Animation Logic for Watch Loop Status Icon * [LOOP-5731, LOOP-5737 & LOOP-5747] Misc WatchOS Sync Fixes + LoopStatusModel Real-Time Updating --- .../StatusTableViewController.swift | 29 +--- Loop/Views/LoopStatusModalView.swift | 152 +++++++++--------- LoopUI/Views/LoopCompletionHUDView.swift | 3 +- WatchApp Extension/Views/ChartPageView.swift | 117 +++++++------- WatchApp Extension/Views/LoopCircleView.swift | 32 +--- WatchApp Extension/Views/LoopHeader.swift | 16 +- .../Views/WatchActionsView.swift | 2 +- WatchApp/ContentView.swift | 6 + 8 files changed, 156 insertions(+), 201 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 0e8e5ca58d..2fd50f3577 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -448,7 +448,6 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView?.loopCompletionHUD.deviceIssue = deviceIssue hudView?.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate hudView?.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate - updateLoopCompletionModal() guard !reloading && !deviceManager.authorizationRequired else { return @@ -1670,32 +1669,12 @@ final class StatusTableViewController: LoopChartsTableViewController { } private lazy var loopCompletionModalViewModel = LoopStatusModalViewModel( - lastLoopCompleted: loopManager.lastLoopCompleted, - loopIconClosed: automaticDosingEnabled, - hasBluetoothIssue: deviceManager.hasBluetoothIssue, - isDeliverySuspended: deviceManager.isSuspended, - isPumpInSignalLoss: deviceManager.pumpManager?.inSignalLoss == true, - isPumpInoperable: deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true, - isCGMInWarmup: deviceManager.cgmManager?.cgmManagerStatus.inSensorWarmup == true, - isCGMInSignalLoss: deviceManager.cgmManager?.inSignalLoss == true, - isCGMInoperable: deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true) - - private func updateLoopCompletionModal() { - loopCompletionModalViewModel.update( - lastLoopCompleted: loopManager.lastLoopCompleted, - loopIconClosed: automaticDosingEnabled, - hasBluetoothIssue: deviceManager.hasBluetoothIssue, - isDeliverySuspended: deviceManager.isSuspended, - isPumpInSignalLoss: deviceManager.pumpManager?.inSignalLoss == true, - isPumpInoperable: deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true, - isCGMInWarmup: deviceManager.cgmManager?.cgmManagerStatus.inSensorWarmup == true, - isCGMInSignalLoss: deviceManager.cgmManager?.inSignalLoss == true, - isCGMInoperable: deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true) - } + deviceManager: deviceManager, + loopManager: loopManager, + settingsManager: settingsManager + ) @objc private func showLoopCompletionMessage(_: Any) { - updateLoopCompletionModal() - let modalVC = UIHostingController( rootView: LoopStatusModalView(viewModel: loopCompletionModalViewModel, onDismiss: { [weak self] in diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 6334d0ce63..1671fdd771 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -34,23 +34,25 @@ struct LoopStatusModalView: View { var body: some View { VStack { - closeButton - .padding(5) - .frame(maxWidth: .infinity, alignment: .trailing) - - LoopCircleView(closedLoop: viewModel.loopIconClosed, freshness: viewModel.freshness, deviceIssue: deviceIssue) - .environment(\.loopStatusColorPalette, loopStatusColors) - .padding(.bottom) - - if viewModel.loopIconClosed, - let lastLoopCompletedFormattedTime = viewModel.lastLoopCompletedFormattedTime - { - lastLoopCompleted(lastLoopCompletedString: lastLoopCompletedFormattedTime) + TimelineView(.animation) { _ in + closeButton + .padding(5) + .frame(maxWidth: .infinity, alignment: .trailing) + + LoopCircleView(closedLoop: viewModel.loopIconClosed, freshness: viewModel.freshness, deviceIssue: deviceIssue) + .environment(\.loopStatusColorPalette, loopStatusColors) + .padding(.bottom) + + if viewModel.loopIconClosed, + let lastLoopCompletedFormattedTime = viewModel.lastLoopCompletedFormattedTime + { + lastLoopCompleted(lastLoopCompletedString: lastLoopCompletedFormattedTime) + } + + automationDetails + .padding([.top, .horizontal]) + .padding(.bottom, 10) } - - automationDetails - .padding([.top, .horizontal]) - .padding(.bottom, 10) } .padding(10) .background(Color(UIColor.systemGroupedBackground)) @@ -81,7 +83,8 @@ struct LoopStatusModalView: View { private func lastLoopCompleted(lastLoopCompletedString: String) -> some View { Group { Text("Last loop completed") - Text("\(Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90")) \(lastLoopCompletedString)") + // ⚠️ arrow.triangle.2.circlepath is deprecated -- replace with "arrow.trianglehead.2.clockwise.rotate.90" once iOS 17 is dropped as a supported platform. + Text("\(Image(systemName: "arrow.triangle.2.circlepath")) \(lastLoopCompletedString)") .foregroundStyle(freshnessColor) if viewModel.includeDateTimeStamp { Text(viewModel.formattedLastLoopCompletedDateTime) @@ -138,8 +141,56 @@ struct LoopStatusModalView: View { } } +@MainActor @Observable class LoopStatusModalViewModel { + + private weak var deviceManager: DeviceDataManager? + private weak var loopManager: LoopDataManager? + private weak var settingsManager: SettingsManager? + + init(deviceManager: DeviceDataManager?, loopManager: LoopDataManager?, settingsManager: SettingsManager?) { + self.deviceManager = deviceManager + self.loopManager = loopManager + self.settingsManager = settingsManager + } + + var lastLoopCompleted: Date? { + loopManager?.lastLoopCompleted + } + + var loopIconClosed: Bool { + settingsManager?.dosingEnabled ?? true + } + + var hasBluetoothIssue: Bool { + deviceManager?.hasBluetoothIssue ?? false + } + + var isPumpInSignalLoss: Bool { + deviceManager?.pumpManager?.inSignalLoss == true + } + + var isPumpInoperable: Bool { + deviceManager?.pumpManager == nil || deviceManager?.pumpManager?.isInoperable == true + } + + var isDeliverySuspended: Bool { + deviceManager?.isSuspended ?? false + } + + var isCGMInWarmup: Bool { + deviceManager?.cgmManager?.cgmManagerStatus.inSensorWarmup == true + } + + var isCGMInSignalLoss: Bool { + deviceManager?.cgmManager?.inSignalLoss == true + } + + var isCGMInoperable: Bool { + deviceManager?.cgmManager == nil || deviceManager?.cgmManager?.isInoperable == true + } + private var dateTimeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium @@ -156,63 +207,33 @@ class LoopStatusModalViewModel { return formatter }() - var lastLoopCompleted: Date? var freshness: LoopCompletionFreshness { guard !isPumpInSignalLoss, !isCGMInSignalLoss else { return .stale } return LoopCompletionFreshness(age: ago) } + var ago: TimeInterval? { guard let lastLoopCompleted else { return nil } return abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) } + var includeDateTimeStamp: Bool { // only include if last loop was before today guard let lastLoopCompleted else { return false } let startOfToday = Calendar.current.startOfDay(for: Date()) return lastLoopCompleted < startOfToday } + var formattedLastLoopCompletedDateTime: String { guard let lastLoopCompleted else { return "Unknown" } return String(format: NSLocalizedString("at %1$@", comment: "when adding the date and time. (1: the formatted date and time)"), dateTimeFormatter.string(from: lastLoopCompleted)) } + var formattedLastLoopCompletedTime: String { guard let lastLoopCompleted else { return "Unknown" } return String(format: NSLocalizedString("at %1$@", comment: "when adding a timestamp. (1: the formatted timestamp)"), timeFormatter.string(from: lastLoopCompleted)) } - - var loopIconClosed: Bool - - var hasBluetoothIssue: Bool - - var isPumpInSignalLoss: Bool - var isPumpInoperable: Bool - var isDeliverySuspended: Bool - - var isCGMInWarmup: Bool - var isCGMInSignalLoss: Bool - var isCGMInoperable: Bool - - func update(lastLoopCompleted: Date?, - loopIconClosed: Bool, - hasBluetoothIssue: Bool, - isDeliverySuspended: Bool, - isPumpInSignalLoss: Bool, - isPumpInoperable: Bool, - isCGMInWarmup: Bool, - isCGMInSignalLoss: Bool, - isCGMInoperable: Bool) - { - self.lastLoopCompleted = lastLoopCompleted - self.loopIconClosed = loopIconClosed - self.hasBluetoothIssue = hasBluetoothIssue - self.isDeliverySuspended = isDeliverySuspended - self.isPumpInSignalLoss = isPumpInSignalLoss - self.isPumpInoperable = isPumpInoperable - self.isCGMInWarmup = isCGMInWarmup - self.isCGMInoperable = isCGMInoperable - self.isCGMInSignalLoss = isCGMInSignalLoss - } var copy: (title: String, message: String) { guard loopIconClosed else { @@ -236,19 +257,19 @@ class LoopStatusModalViewModel { if hasBluetoothIssue || isPumpInoperable { return (titleUnavailable, NSLocalizedString("Tap your CGM or insulin pump status icons right away for more information and steps to resolve the issue.", comment: "message when automation is on and there is a bluetooth or pump issue")) } else if isPumpInSignalLoss { - return (titleUnsucessful, NSLocalizedString("Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM or insulin pump.", comment: "message when automation is on and pump is in signal loss")) + return (titleUnsuccessful, NSLocalizedString("Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM or insulin pump.", comment: "message when automation is on and pump is in signal loss")) } else if isDeliverySuspended { return (titleUnavailable, NSLocalizedString("Automation is unavailable while your insulin is suspended.\n\nResume insulin if you wish for the app to automate insulin delivery.", comment: "message when automation is on and insulin delivery is suspended")) } else if isCGMInoperable { return (titleUnavailable, NSLocalizedString("Tap your CGM status icon right away for more information and steps to resolve the issue.\n\nIn the meantime, your pump is still able to deliver insulin.", comment: "message when automation is on and CGM is inoperable")) } else if isCGMInSignalLoss { - return (titleUnsucessful, NSLocalizedString("Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM.\n\nIn the meantime, your pump is still able to deliver insulin.", comment: "message when automation is on and CGM is in signal loss")) + return (titleUnsuccessful, NSLocalizedString("Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM.\n\nIn the meantime, your pump is still able to deliver insulin.", comment: "message when automation is on and CGM is in signal loss")) } else if isCGMInWarmup { return (titleUnavailable, NSLocalizedString("Automation is unavailable while your CGM sensor is warming up.\n\nIn the meantime, your pump is still able to deliver insulin.\n\nAutomation will resume when CGM readings are received.", comment: "message when automation is on and CGM is in warmup")) } else if freshness == .fresh { return (titleAutomationOn, NSLocalizedString("Tidepool Loop will actively adjust your insulin dosing in response to your glucose as often as every 5 minutes.", comment: "message when automation is on and the glucose value is fresh")) } else { - return (titleUnsucessful, NSLocalizedString("Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM or insulin pump.", comment: "message when automation is on and the glucose value is not fresh")) + return (titleUnsuccessful, NSLocalizedString("Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM or insulin pump.", comment: "message when automation is on and the glucose value is not fresh")) } } @@ -264,7 +285,7 @@ class LoopStatusModalViewModel { return NSLocalizedString("Automation is unavailable", comment: "title for when automation is unavailable") } - var titleUnsucessful: String { + var titleUnsuccessful: String { return NSLocalizedString("Automation was unsuccessful", comment: "title for when automation was unsuccessful") } @@ -283,25 +304,4 @@ class LoopStatusModalViewModel { return NSLocalizedString("\(timeString) ago", comment: "last loop completed string") } - - init(lastLoopCompleted: Date? = nil, - loopIconClosed: Bool, - hasBluetoothIssue: Bool, - isDeliverySuspended: Bool, - isPumpInSignalLoss: Bool, - isPumpInoperable: Bool, - isCGMInWarmup: Bool, - isCGMInSignalLoss: Bool, - isCGMInoperable: Bool) - { - self.lastLoopCompleted = lastLoopCompleted - self.loopIconClosed = loopIconClosed - self.hasBluetoothIssue = hasBluetoothIssue - self.isDeliverySuspended = isDeliverySuspended - self.isPumpInSignalLoss = isPumpInSignalLoss - self.isPumpInoperable = isPumpInoperable - self.isCGMInWarmup = isCGMInWarmup - self.isCGMInSignalLoss = isCGMInSignalLoss - self.isCGMInoperable = isCGMInoperable - } } diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index de032af1ad..8bb371bb9b 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -220,7 +220,8 @@ public final class LoopCompletionHUDView: BaseHUDView { private func formattedTimeAgoString(_ timeString: String, includeGreaterThan: Bool = false) -> NSAttributedString { let config = UIImage.SymbolConfiguration(pointSize: 11, weight: .semibold) - let symbol = UIImage(systemName: "arrow.trianglehead.2.clockwise.rotate.90", withConfiguration: config) + // ⚠️ arrow.triangle.2.circlepath is deprecated -- replace with "arrow.trianglehead.2.clockwise.rotate.90" once iOS 17 is dropped as a supported platform. + let symbol = UIImage(systemName: "arrow.triangle.2.circlepath", withConfiguration: config) let tintedSymbol = symbol?.withTintColor(freshnessColor, renderingMode: .alwaysOriginal) let attachment = NSTextAttachment() attachment.image = tintedSymbol diff --git a/WatchApp Extension/Views/ChartPageView.swift b/WatchApp Extension/Views/ChartPageView.swift index 04ca559d5d..188cedec38 100644 --- a/WatchApp Extension/Views/ChartPageView.swift +++ b/WatchApp Extension/Views/ChartPageView.swift @@ -18,16 +18,30 @@ struct ChartPageView: View { @Environment(LoopDataManager.self) var loopManager @State private var isShowingCarbList: Bool = false - - @State private var lastSyncString: String? @ScaledMetric private var iconSize: Double = 26 - private let lastSyncUpdateTimer = Timer.publish(every: 10, on: .main, in: .common).autoconnect() - var presetActive: Bool { return loopManager.watchInfo.scheduleOverride?.isActive() == true } + + var lastSyncString: String? { + guard loopManager.activeContext?.isClosedLoop == true, let date = loopManager.activeContext?.loopLastRunDate else { + return nil + } + + let ago = min(abs(min(0, date.timeIntervalSinceNow)), TimeInterval.days(7)) + + guard let timeString = ago.truncatedTimeAgoString else { + return nil + } + + if ago > .hours(1) { + return String(format: NSLocalizedString(" >%@ ago", comment: "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components"), timeString) + } else { + return String(format: NSLocalizedString(" %@ ago", comment: "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components"), timeString) + } + } private var chartHeight: CGFloat { switch sizeClass { @@ -157,61 +171,58 @@ struct ChartPageView: View { var body: some View { ScrollView(.vertical) { - LoopHeader(freshness: LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.loopLastRunDate, at: Date())) - chartView - - VStack(spacing: 8) { - if let lastSyncString { - LabelValueRow("Last Loop") { - Text(Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90")) + - Text(" " + lastSyncString) + TimelineView(.animation) { _ in + LoopHeader() + + chartView + + VStack(spacing: 8) { + if let lastSyncString { + LabelValueRow("Last Loop") { + // ⚠️ arrow.triangle.2.circlepath is deprecated -- replace with "arrow.trianglehead.2.clockwise.rotate.90" once watchOS 10 is dropped as a supported platform. + Text(Image(systemName: "arrow.triangle.2.circlepath")) + + Text(" " + lastSyncString) + } + Divider() + } + LabelValueRow("Active Insulin") { + Text(activeInsulin ?? "-") } Divider() - } - LabelValueRow("Active Insulin") { - Text(activeInsulin ?? "-") - } - Divider() - LabelValueRow("Active Carbs") { - Text(activeCarbohydrates ?? "-") - } - .onTapGesture { - isShowingCarbList = true - } - Divider() - LabelValueRow("Last Bolus") { - lastBolus - } - if let currentDelivery = loopManager.activeContext?.insulinDeliveryState { + LabelValueRow("Active Carbs") { + Text(activeCarbohydrates ?? "-") + } + .onTapGesture { + isShowingCarbList = true + } Divider() - LabelValueRow("Current Delivery") { - Text(currentDelivery.iconImage) + - Text(" " + currentDelivery.shortDescription) + LabelValueRow("Last Bolus") { + lastBolus + } + if let currentDelivery = loopManager.activeContext?.insulinDeliveryState { + Divider() + LabelValueRow("Current Delivery") { + Text(currentDelivery.iconImage) + + Text(" " + currentDelivery.shortDescription) + } + } + Divider() + LabelValueRow("Reservoir Volume") { + Text(reservoirVolume ?? "-") } } - Divider() - LabelValueRow("Reservoir Volume") { - Text(reservoirVolume ?? "-") - } + .padding(.horizontal) } - .padding(.horizontal) } .font(.system(size: 14, weight: .light)) .toolbar(.hidden, for: .navigationBar) .environment(\.glucoseDisplayUnit, loopManager.displayGlucoseUnit) .onAppear() { updateGlucoseChart() - updateLastSyncString() } .onChange(of: loopManager.activeContext?.predictedGlucose) { oldValue, newValue in updateGlucoseChart() } - .onReceive(lastSyncUpdateTimer) { _ in - updateLastSyncString() - } - .onChange(of: loopManager.activeContext?.isClosedLoop) { _, _ in - updateLastSyncString() - } .sheet(isPresented: $isShowingCarbList) { CarbList() } @@ -224,26 +235,6 @@ struct ChartPageView: View { loopManager.glucoseChartScene.setNeedsUpdate() } } - - private func updateLastSyncString() { - guard loopManager.activeContext?.isClosedLoop == true, let date = loopManager.activeContext?.loopLastRunDate else { - lastSyncString = nil - return - } - - let ago = min(abs(min(0, date.timeIntervalSinceNow)), TimeInterval.days(7)) - - guard let timeString = ago.truncatedTimeAgoString else { - lastSyncString = nil - return - } - - if ago > .hours(1) { - lastSyncString = String(format: NSLocalizedString(" >%@ ago", comment: "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components"), timeString) - } else { - lastSyncString = String(format: NSLocalizedString(" %@ ago", comment: "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components"), timeString) - } - } } extension TimeInterval { diff --git a/WatchApp Extension/Views/LoopCircleView.swift b/WatchApp Extension/Views/LoopCircleView.swift index 61fbb321ce..4c7b3d2974 100644 --- a/WatchApp Extension/Views/LoopCircleView.swift +++ b/WatchApp Extension/Views/LoopCircleView.swift @@ -10,56 +10,36 @@ import SwiftUI import LoopKit public struct LoopCircleView: View { - @Environment(\.isEnabled) private var isEnabled - - private let animating: Bool + private let closedLoop: Bool private let freshness: LoopCompletionFreshness private let deviceIssue: Bool - public init(closedLoop: Bool, freshness: LoopCompletionFreshness, animating: Bool = false, deviceIssue: Bool = false) { + public init(closedLoop: Bool, freshness: LoopCompletionFreshness, deviceIssue: Bool = false) { self.closedLoop = closedLoop self.freshness = freshness - self.animating = animating self.deviceIssue = deviceIssue } - private var reversingAnimation: Animation { - if animating && closedLoop { - return .easeInOut(duration: 1).repeatForever(autoreverses: true) - } else { - return .easeInOut(duration: 1) - } - } - public var body: some View { GeometryReader { geometry in Circle() .trim(from: closedLoop ? 0 : 0.25, to: 1) .stroke(loopColor, lineWidth: geometry.size.height / 5) .rotationEffect(Angle(degrees: closedLoop ? -90 : -135)) - .animation(.none, value: freshness) .animation(.default, value: closedLoop) - .scaleEffect(animating && closedLoop ? 0.75 : 1) - .animation(reversingAnimation, value: UUID()) + .animation(.default, value: freshness) } } private var loopColor: Color { if !isEnabled { return .defaultWatchButtonGray - } else if deviceIssue { - return .gray + } else if isEnabled && !deviceIssue && freshness == .fresh { + return .fresh } else { - switch freshness { - case .fresh: - return .fresh - case .aging: - return .gray - case .stale: - return .gray - } + return .gray } } } diff --git a/WatchApp Extension/Views/LoopHeader.swift b/WatchApp Extension/Views/LoopHeader.swift index ae973cb71d..4552e7da88 100644 --- a/WatchApp Extension/Views/LoopHeader.swift +++ b/WatchApp Extension/Views/LoopHeader.swift @@ -13,21 +13,19 @@ import LoopCore struct LoopHeader: View { @Environment(LoopDataManager.self) var loopManager - @State var freshness: LoopCompletionFreshness - - private let lastSyncUpdateTimer = Timer.publish(every: 10, on: .main, in: .common).autoconnect() + var freshness: LoopCompletionFreshness { + LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.loopLastRunDate, at: Date()) + } var body: some View { HStack { if let activeContext = loopManager.activeContext, let unit = activeContext.displayGlucoseUnit { - LoopCircleView(closedLoop: activeContext.isClosedLoop ?? false, freshness: freshness, deviceIssue: loopManager.activeContext?.deviceIssue ?? true) - .frame(width: 22, height: 22) - .padding(.horizontal) - - .onReceive(lastSyncUpdateTimer) { _ in - self.freshness = LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.loopLastRunDate, at: Date()) + TimelineView(.animation) { _ in + LoopCircleView(closedLoop: activeContext.isClosedLoop ?? false, freshness: freshness, deviceIssue: loopManager.activeContext?.deviceIssue ?? true) + .frame(width: 22, height: 22) + .padding(.horizontal) } Text(loopManager.glucoseValue) diff --git a/WatchApp Extension/Views/WatchActionsView.swift b/WatchApp Extension/Views/WatchActionsView.swift index 4941f7e632..369389af6a 100644 --- a/WatchApp Extension/Views/WatchActionsView.swift +++ b/WatchApp Extension/Views/WatchActionsView.swift @@ -23,7 +23,7 @@ struct WatchActionsView: View { var body: some View { ScrollView(.vertical) { - LoopHeader(freshness: LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.loopLastRunDate, at: Date())) + LoopHeader() HStack(spacing: 0) { CircleTintedButton( diff --git a/WatchApp/ContentView.swift b/WatchApp/ContentView.swift index 45c9e9fa7f..0600410a77 100644 --- a/WatchApp/ContentView.swift +++ b/WatchApp/ContentView.swift @@ -21,9 +21,15 @@ struct ContentView: View { TabView(selection: $selectedPage) { WatchActionsView() .tag(0) + .task { + loopManager.requestContextUpdate {} + } ChartPageView() .tag(1) + .task { + loopManager.requestContextUpdate {} + } } .tabViewStyle(.page) .indexViewStyle(.page(backgroundDisplayMode: .automatic)) From a1ff6a383464f7af3389f83f6ffb06373aee7104 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 9 Feb 2026 16:21:08 -0400 Subject: [PATCH 359/421] [LOOP-5751] add all the toolbar items as a group (#902) * only update the navigation controll when it is needed * using ToolbarItemGroup instead of multiple ToolbarItems --- Loop/Views/StatusTableView.swift | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index b579bc0b77..56c1425907 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -130,7 +130,7 @@ struct StatusTableView: View { statusTableViewModel: viewModel ) } - + var body: some View { wrappedView .ignoresSafeArea(edges: .bottom) @@ -147,23 +147,15 @@ struct StatusTableView: View { .toolbar { if !isLandscape { if #available(iOS 26, *) { - ToolbarSpacer() - ToolbarItem(placement: .bottomBar) { - carbTab.padding(.horizontal, 12) - } - ToolbarSpacer() - ToolbarItem(placement: .bottomBar) { - bolusTab.padding(.horizontal, 12) - } - ToolbarSpacer() - ToolbarItem(placement: .bottomBar) { - presetsTab.padding(.horizontal, 12) - } - ToolbarSpacer() - ToolbarItem(placement: .bottomBar) { - settingsTab.padding(.horizontal, 12) + ToolbarItemGroup(placement: .bottomBar) { + carbTab + Spacer() + bolusTab + Spacer() + presetsTab + Spacer() + settingsTab } - ToolbarSpacer() } else { ToolbarItem(placement: .bottomBar) { HStack { From 96a1f29f3432ed92831f1ba3965585b4ae17055e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 9 Feb 2026 15:02:29 -0600 Subject: [PATCH 360/421] Fix race condition when acknowledging preset alert from watch (#901) --- Loop/Managers/Alerts/AlertManager.swift | 4 ++++ Loop/Managers/Alerts/InAppModalAlertScheduler.swift | 13 +++++++++++-- Loop/Managers/TemporaryPresetsManager.swift | 4 +++- Loop/Managers/WatchDataManager.swift | 3 +++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index ba0c81bca3..b51b94e0ea 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -334,6 +334,7 @@ extension AlertManager: AlertManagerResponder { } func acknowledgeAlert(identifier: Alert.Identifier) async throws { + self.log.default("acknowledgeAlert called for identifier %{public}@", String(describing: identifier)) if let responder = responders[identifier.managerIdentifier]?.value { do { try await responder.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier) @@ -381,6 +382,7 @@ extension AlertManager: AlertIssuer { } public func retractAlert(identifier: Alert.Identifier) async { + log.default("retractAlert: %{public}@", String(describing: identifier)) guard playbackFinished else { deferredRetractions.append(identifier) return @@ -394,9 +396,11 @@ extension AlertManager: AlertIssuer { } private func replayAlert(_ alert: Alert) { + if let unsafeNotificationPermissionsAlert = AlertPermissionsChecker.UnsafeNotificationPermissionAlert.allCases.first(where: { $0.alertIdentifier == alert.identifier }) { presentUnsafeNotificationPermissionsInAppAlert(unsafeNotificationPermissionsAlert) } else if alert.foregroundContent != nil { + log.default("Scheduling modal alert during replay: %{public}@", String(describing: alert)) modalAlertScheduler.scheduleAlert(alert) } } diff --git a/Loop/Managers/Alerts/InAppModalAlertScheduler.swift b/Loop/Managers/Alerts/InAppModalAlertScheduler.swift index c6d48a3dc6..a762f3c60e 100644 --- a/Loop/Managers/Alerts/InAppModalAlertScheduler.swift +++ b/Loop/Managers/Alerts/InAppModalAlertScheduler.swift @@ -20,7 +20,9 @@ public class InAppModalAlertScheduler { typealias ActionFactoryFunction = (String?, UIAlertAction.Style, ((UIAlertAction) -> Void)?) -> UIAlertAction private let newActionFunc: ActionFactoryFunction - + + private let log = DiagnosticLog(category: "InAppModalAlertScheduler") + typealias TimerFactoryFunction = (TimeInterval, Bool, (() -> Void)?) -> Timer private let newTimerFunc: TimerFactoryFunction @@ -49,15 +51,19 @@ public class InAppModalAlertScheduler { } public func unscheduleAlert(identifier: Alert.Identifier) async { + log.default("unscheduleAlert modal alert: %{public}@", String(describing: identifier)) + removePendingAlert(identifier: identifier) await removePresentedAlert(identifier: identifier) } func removePresentedAlert(identifier: Alert.Identifier) async { guard let alertPresented = alertsPresented[identifier] else { + log.default("No presented modal alert with identifier %{public}@", String(describing: identifier)) return } + log.default("Dismissing modal alert with identifier %{public}@", String(describing: identifier)) await alertPresenter?.dismissAlert(alertPresented.0, animated: true) clearPresentedAlert(identifier: identifier) } @@ -95,6 +101,7 @@ extension InAppModalAlertScheduler { return } Task { @MainActor in + log.default("Presenting modal alert: %{public}@", String(describing: alert.identifier)) if self.isAlertPresented(identifier: alert.identifier) { return } @@ -113,18 +120,20 @@ extension InAppModalAlertScheduler { } } } - await self.alertPresenter?.present(alertController, animated: true) addPresentedAlert(alert: alert, controller: alertController) + await self.alertPresenter?.present(alertController, animated: true) } } private func addPendingAlert(alert: Alert, timer: Timer) { dispatchPrecondition(condition: .onQueue(.main)) + alertsPending[alert.identifier] = (timer, alert) } private func addPresentedAlert(alert: Alert, controller: UIAlertController) { dispatchPrecondition(condition: .onQueue(.main)) + log.default("Adding presented modal alert: %{public}@", String(describing: alert.identifier)) alertsPresented[alert.identifier] = (controller, alert) } diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 64795dfc7c..3a062a3f7f 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -476,7 +476,9 @@ extension TemporaryPresetsManager { } extension TemporaryPresetsManager : AlertResponder { - func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier) async throws { } + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier) async throws { + + } func handleAlertAction(actionIdentifier: String, from alert: Alert) async throws { if actionIdentifier == UNNotificationDismissActionIdentifier { return } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 868f8bedfb..5e5a1fd32b 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -459,6 +459,7 @@ final class WatchDataManager: NSObject { return updatedContext.rawValue case SetPresetUserInfo.name?: + log.default("Set Preset from watch: %{public}@", String(describing: message)) if let userInfo = SetPresetUserInfo(rawValue: message) { if let presetIdentifier = userInfo.presetIdentifier { temporaryPresetsManager.startPreset(withIdentifier: presetIdentifier) @@ -479,6 +480,7 @@ final class WatchDataManager: NSObject { let context = await createWatchContext() return context.rawValue case AcknowledgeAlertUserInfo.name?: + log.default("Acknowledge alert from watch: %{public}@", String(describing: message)) if let userInfo = AcknowledgeAlertUserInfo(rawValue: message) { let id = Alert.Identifier(managerIdentifier: userInfo.managerIdentifier, alertIdentifier: userInfo.alertIdentifier) try? await alertManager.acknowledgeAlert(identifier: id) @@ -530,6 +532,7 @@ final class WatchDataManager: NSObject { extension WatchDataManager: WCSessionDelegate { nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { Task { @MainActor in + self.log.default("Received message: %{public}@", message) do { replyHandler(try await handleWatchMessage(message)) } catch { From 952f54312f766d24363b6b6e91a74aaa0e14ff41 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 20 Feb 2026 08:15:00 -0600 Subject: [PATCH 361/421] Update detection to handle 2 readings with delta > 3mg/dl/min (#903) --- Loop/Models/LoopConstants.swift | 2 +- Loop/View Models/CarbEntryViewModel.swift | 50 ++++++++++++++++++----- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/Loop/Models/LoopConstants.swift b/Loop/Models/LoopConstants.swift index 0d7d881279..4a89c12d3c 100644 --- a/Loop/Models/LoopConstants.swift +++ b/Loop/Models/LoopConstants.swift @@ -62,7 +62,7 @@ enum LoopConstants { /// Missed Meal warning constants static let missedMealWarningGlucoseRiseThreshold = 3.0 // mg/dL/m - static let missedMealWarningGlucoseRecencyWindow = TimeInterval(minutes: 20) + static let missedMealWarningGlucoseRecencyWindow = TimeInterval(minutes: 19) static let missedMealWarningVelocitySampleMinDuration = TimeInterval(minutes: 12) // Bolus calculator warning thresholds diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index d53e501b2d..1cd8c99b75 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -374,25 +374,53 @@ final class CarbEntryViewModel: ObservableObject { } let filteredGlucoseSamples = glucoseSamples.filterDateRange(startDate, now) - guard let startSample = filteredGlucoseSamples.first, let endSample = filteredGlucoseSamples.last else { + guard filteredGlucoseSamples.count >= 2 else { warnings.remove(.glucoseRisingRapidly) return } - let duration = endSample.startDate.timeIntervalSince(startSample.startDate) - guard duration >= LoopConstants.missedMealWarningVelocitySampleMinDuration else { - warnings.remove(.glucoseRisingRapidly) - return + // Condition 1: Rate of change between the most recent two glucose readings within 14 minutes + let twoReadingWindow = now.addingTimeInterval(.minutes(-14)) + let twoReadingSamples = filteredGlucoseSamples.filterDateRange(twoReadingWindow, now) + + if twoReadingSamples.count >= 2 { + let recentTwo = Array(twoReadingSamples.suffix(2)) + let firstOfTwo = recentTwo[0] + let lastOfTwo = recentTwo[1] + + let duration = lastOfTwo.startDate.timeIntervalSince(firstOfTwo.startDate) + let delta = lastOfTwo.quantity.doubleValue(for: .milligramsPerDeciliter) - firstOfTwo.quantity.doubleValue(for: .milligramsPerDeciliter) + let velocity = delta / duration.minutes // Unit = mg/dL/min + + if velocity >= LoopConstants.missedMealWarningGlucoseRiseThreshold { + warnings.insert(.glucoseRisingRapidly) + return + } } - let delta = endSample.quantity.doubleValue(for: .milligramsPerDeciliter) - startSample.quantity.doubleValue(for: .milligramsPerDeciliter) - let velocity = delta / duration.minutes // Unit = mg/dL/m + // Condition 2: Rate of change over the most recent three glucose readings within 19 minutes + let threeReadingWindow = now.addingTimeInterval(LoopConstants.missedMealWarningGlucoseRecencyWindow) + let threeReadingSamples = filteredGlucoseSamples.filterDateRange(threeReadingWindow, now) - if velocity >= LoopConstants.missedMealWarningGlucoseRiseThreshold { - warnings.insert(.glucoseRisingRapidly) - } else { - warnings.remove(.glucoseRisingRapidly) + if threeReadingSamples.count >= 3 { + let recentThree = Array(threeReadingSamples.suffix(3)) + let firstOfThree = recentThree[0] + let lastOfThree = recentThree[2] + + let duration = lastOfThree.startDate.timeIntervalSince(firstOfThree.startDate) + if duration >= LoopConstants.missedMealWarningVelocitySampleMinDuration { + let delta = lastOfThree.quantity.doubleValue(for: .milligramsPerDeciliter) - firstOfThree.quantity.doubleValue(for: .milligramsPerDeciliter) + let velocity = delta / duration.minutes // Unit = mg/dL/min + + if velocity >= LoopConstants.missedMealWarningGlucoseRiseThreshold { + warnings.insert(.glucoseRisingRapidly) + return + } + } } + + // Neither condition met + warnings.remove(.glucoseRisingRapidly) } } From 4d1b0133eeaffaa9e9ddf28a7252bb0afb0518f2 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 24 Feb 2026 10:39:08 -0400 Subject: [PATCH 362/421] [LOOP-5737-5778] use last glucose data date when loop is open (#904) * use last glucose data date when loop is open * fixing for watch app --- Loop/Views/LoopStatusModalView.swift | 11 +++++++++++ WatchApp Extension/Views/LoopHeader.swift | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 1671fdd771..1f6a93a8fc 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -159,6 +159,10 @@ class LoopStatusModalViewModel { loopManager?.lastLoopCompleted } + var mostRecentGlucoseDataDate: Date? { + loopManager?.mostRecentGlucoseDataDate + } + var loopIconClosed: Bool { settingsManager?.dosingEnabled ?? true } @@ -215,6 +219,13 @@ class LoopStatusModalViewModel { } var ago: TimeInterval? { + guard loopIconClosed else { + // when loop is open, the last glucose data date determines ago + guard let mostRecentGlucoseDataDate else { return nil } + return abs(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow)) + } + + // when loop is closed, the last loop completed date determines ago guard let lastLoopCompleted else { return nil } return abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) } diff --git a/WatchApp Extension/Views/LoopHeader.swift b/WatchApp Extension/Views/LoopHeader.swift index 4552e7da88..bc7c0c7669 100644 --- a/WatchApp Extension/Views/LoopHeader.swift +++ b/WatchApp Extension/Views/LoopHeader.swift @@ -14,7 +14,10 @@ struct LoopHeader: View { @Environment(LoopDataManager.self) var loopManager var freshness: LoopCompletionFreshness { - LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.loopLastRunDate, at: Date()) + guard loopManager.activeContext?.isClosedLoop == true else { + return LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.glucoseDate, at: Date()) + } + return LoopCompletionFreshness(lastCompletion: loopManager.activeContext?.loopLastRunDate, at: Date()) } var body: some View { From 6759c38131edfd7f1361264dba90e66f5e84adef Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 26 Feb 2026 14:27:56 -0400 Subject: [PATCH 363/421] [LOOP-5773] style updates for mute all app sounds (#905) --- Loop/View Controllers/StatusTableViewController.swift | 8 +++++--- Loop/Views/AlertManagementView.swift | 2 +- Loop/Views/SettingsView.swift | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 2fd50f3577..688c5ed040 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -969,8 +969,10 @@ final class StatusTableViewController: LoopChartsTableViewController { } } else { HStack { + Text(Image(systemName: "speaker.slash.fill")).font(.title) + Text(" ") + VStack(alignment: .leading) { - Text(Image(systemName: "speaker.slash.fill")) + Text(" ") + Text(NSLocalizedString("All App Sounds Muted", comment: "Warning text for when alerts are muted")) + Text(NSLocalizedString("All App Sounds Muted", comment: "Warning text for when alerts are muted")) .font(.headline.bold()) Text(String(format: NSLocalizedString("Until %1$@", comment: "indication of when alerts will be unmuted (1: time when alerts unmute)"), alertMuter.formattedEndTime)) @@ -985,7 +987,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } .foregroundStyle(Color.white) .padding(8) - .background(Color.warning.cornerRadius(10)) + .background(Color.critical.cornerRadius(10)) .padding([.top, .horizontal], 8) } } @@ -1373,7 +1375,7 @@ final class StatusTableViewController: LoopChartsTableViewController { private func presentUnmuteAlertConfirmation() { 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 body = NSLocalizedString("All app sounds, including sounds for all critical alerts, are currently muted.\n\nTap Unmute to resume app sounds for your alerts.", comment: "The alert body for unmute alert confirmation") let action = UIAlertAction( title: NSLocalizedString("Unmute", comment: "The title of the action used to unmute app sounds"), style: .default) { _ in diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 327e344644..9165c406ce 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -155,7 +155,7 @@ struct AlertManagementView: View { Button(action: alertMuter.unmuteAlerts) { Group { Text(Image(systemName: "speaker.slash.fill")) - .foregroundColor(guidanceColors.warning) + .foregroundColor(guidanceColors.critical) + Text(" ") + Text(NSLocalizedString("Tap to Unmute All App Sounds", comment: "Label for button to unmute all app sounds")) .fontWeight(.semibold) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index c77f990ee8..8fdfa846e0 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -279,7 +279,7 @@ extension SettingsView { Image(systemName: "speaker.slash.fill") .resizable() .aspectRatio(contentMode: .fit) - .foregroundColor(guidanceColors.warning) + .foregroundColor(guidanceColors.critical) .padding(5) } } From 3cf34a686a12f5cc32585fa34c9d06d2d9f1ff04 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 26 Feb 2026 14:28:38 -0400 Subject: [PATCH 364/421] [LOOP-5774] updated preset training UI (#906) --- Loop/Views/Presets/PresetsView.swift | 6 +-- .../Training/PresetsTrainingContent.swift | 42 +++++++++++-------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 3db340edfd..79fd27f1f9 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -290,8 +290,8 @@ struct PresetsView: View { } private var trainingNeededAlert: SwiftUI.Alert { - Alert(title: Text("Extra Training Needed", comment: "Preset training needed alert title"), - message: Text("Complete the training to create a new preset.", comment: "Preset training needed alert message"), + Alert(title: Text("Training Required for New Presets", comment: "Preset training needed alert title"), + message: Text("To create a new preset, you must complete the required training.", comment: "Preset training needed alert message"), primaryButton: startNeededTrainingButton, secondaryButton: closeButton) } @@ -301,7 +301,7 @@ struct PresetsView: View { } private var closeButton: SwiftUI.Alert.Button { - .default(Text("Start Training", comment: "CPreset training needed alert start training button")) { + .default(Text("Start Required Training", comment: "CPreset training needed alert start training button")) { activeSheet = .training() } } diff --git a/Loop/Views/Presets/Training/PresetsTrainingContent.swift b/Loop/Views/Presets/Training/PresetsTrainingContent.swift index ade45bad56..6837eb16cf 100644 --- a/Loop/Views/Presets/Training/PresetsTrainingContent.swift +++ b/Loop/Views/Presets/Training/PresetsTrainingContent.swift @@ -44,20 +44,18 @@ extension PresetsTraining.Step: PresetsTrainingContent { EstimatedReadTime(.minutes(10)) VStack(alignment: .leading) { - Text("Learn to tailor your settings! This training will teach you how to:") + Text("This required training will show you how to change your settings with confidence and create custom presets that fit your need.\n\nThis training covers:") .fixedSize(horizontal: false, vertical: true) BulletedListView { - Text("Configure each setting") - Text("Use Presets for when you are sick") - Text("Use Presets for Daily Activities") - Text("Use Presets for Exercise") + Text("How to configure each setting") + Text("How to use Presets when you are sick") + Text("How to use Presets for everyday activities") + Text("How to use Presets for exercise") } .padding(.leading, 8) } - - Text("Complete this training to learn how to edit the pre-configured presets and adjust them to fit your needs, or create your own custom presets.") - + case .overallInsulin: if let image = Image.optional("PresetsTrainingOverallInsulinHero") { image @@ -118,7 +116,7 @@ extension PresetsTraining.Step: PresetsTrainingContent { case .illness(let illness): switch illness { case .commonUses: - Text("You can use presets for a variety of situations. Explore the uses below to learn tips for these common scenarios.") + Text("Complete each section below to learn how presets can be used for a variety of situations.") VStack(spacing: 16) { CommonUseStep( @@ -143,11 +141,15 @@ extension PresetsTraining.Step: PresetsTrainingContent { case .presetsForIllness: Text("Physical stress, like illness, can cause glucose to rise.") - InsetContent(alignment: .leading) { - Text("**Example:** Paloma Porpoise sees her glucose is running higher than usual. She decides to create a preset to help manage it while she's sick.") + Text("Let’s walk through a simple example to show how a preset might be used in this situation.") + + InsetContent(alignment: .center, spacing: 10) { + Text("Example").fontWeight(.semibold).foregroundColor(.accentColor) + + Text("Paloma Porpoise sees her glucose is running higher than usual. She creates a preset to help manage her glucose while she is sick.").multilineTextAlignment(.center) } - Text("Let's look at the settings that can change how much insulin Paloma get.") + Text("Next, we’ll look at settings you can change and how they affect Paloma’s insulin.") case .overallInsulin: Text("Paloma wants \(appName) to know she needs more insulin than usual.") @@ -270,11 +272,15 @@ extension PresetsTraining.Step: PresetsTrainingContent { case .presetsForDailyActivities: Text("For some people, routine chores and everyday activities can affect glucose levels similar to exercise.") - InsetContent(alignment: .leading) { - Text("**Example:** Omar Octopus wants to create a preset for some yard work he’ll be doing around the house.") + Text("Let’s walk through a simple example to show how a preset might be used in this situation.") + + InsetContent(alignment: .center, spacing: 10) { + Text("Example").fontWeight(.semibold).foregroundColor(.accentColor) + + Text("Omar Octopus wants to create a preset for some yard work he’ll be doing around the house.").multilineTextAlignment(.center) } - Text("Let's look at the settings that will impact Omar’s insulin delivery.") + Text("Next, we’ll look at settings you can change and how they affect Omar’s insulin.") VStack(alignment: .leading, spacing: 16) { Text("Learn More") @@ -371,9 +377,7 @@ extension PresetsTraining.Step: PresetsTrainingContent { ) } case .presetsForExercise: - Text("Exercise is a common reason to use a preset.") - - Text("Different kinds of exercise and their intensity levels can affect your glucose levels in different ways.") + Text("Exercise is a common reason to use a preset. Different kinds of exercise and their intensity levels can affect your glucose levels in different ways.") Text("Depending on the activity, you may notice a few common patterns when it comes to your insulin needs:") @@ -383,6 +387,8 @@ extension PresetsTraining.Step: PresetsTrainingContent { Text("you need **more** insulin than usual") } + Text("Let’s walk through some examples to show how presets might be used in these situations.") + Callout(.note) { Text("These patterns are based on published exercise consensus guidelines and are meant to be used as a starting point. What works for one person may not work for you.") } From f84741ae73712fe2a7d14b036ff098a1b5a0d54d Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 26 Feb 2026 14:44:28 -0400 Subject: [PATCH 365/421] [LOOP-5764] wait for onboarding to be completed before allowing use of the watch app (#907) --- Loop.xcodeproj/project.pbxproj | 4 ++++ Loop/Managers/WatchDataManager.swift | 1 + LoopCore/Models/WatchContext.swift | 7 ++++++- .../Views/CompleteOnboardingView.swift | 20 +++++++++++++++++++ WatchApp/LoopWatchApp.swift | 8 ++++++-- 5 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 WatchApp Extension/Views/CompleteOnboardingView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 1a0d2a172e..ca1e662fb8 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -368,6 +368,7 @@ B455C7332BD14E25002B847E /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B455C7322BD14E25002B847E /* Comparable.swift */; }; B455C7352BD14E30002B847E /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B455C7322BD14E25002B847E /* Comparable.swift */; }; B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B470F5832AB22B5100049695 /* StatefulPluggable.swift */; }; + B47BA4282F506D58006BAAB3 /* CompleteOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B47BA4272F506D50006BAAB3 /* CompleteOnboardingView.swift */; }; B48B0BAC24900093009A48DE /* PumpStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */; }; B490A03F24D0550F00F509FA /* GlucoseRangeCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A03E24D0550F00F509FA /* GlucoseRangeCategory.swift */; }; B490A04124D0559D00F509FA /* DeviceLifecycleProgressState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A04024D0559D00F509FA /* DeviceLifecycleProgressState.swift */; }; @@ -1255,6 +1256,7 @@ B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissibleHostingController.swift; sourceTree = ""; }; B455C7322BD14E25002B847E /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = ""; }; B470F5832AB22B5100049695 /* StatefulPluggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPluggable.swift; sourceTree = ""; }; + B47BA4272F506D50006BAAB3 /* CompleteOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompleteOnboardingView.swift; sourceTree = ""; }; B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatusHUDView.swift; sourceTree = ""; }; B490A03C24D04F9400F509FA /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; B490A03E24D0550F00F509FA /* GlucoseRangeCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseRangeCategory.swift; sourceTree = ""; }; @@ -2535,6 +2537,7 @@ C10C57E42E6F767500A4825C /* CircleTintedButton.swift */, 895788AA242E69A1002CB114 /* CircularAccessoryButtonStyle.swift */, C19E23B72E83566300C20D83 /* CircularProgressWithCheckmark.swift */, + B47BA4272F506D50006BAAB3 /* CompleteOnboardingView.swift */, 89A605EE2432925D009C1096 /* CompletionCheckmark.swift */, 895788B4242E69C8002CB114 /* Extensions */, C17D52032E7F0568001D2AD2 /* LabelValueRow.swift */, @@ -3700,6 +3703,7 @@ 89A605EB243288E4009C1096 /* TopDownTriangle.swift in Sources */, 4F2C15741E0209F500E160D4 /* NSTimeInterval.swift in Sources */, 89F9119224358E2B00ECCAF3 /* CarbEntryInputMode.swift in Sources */, + B47BA4282F506D58006BAAB3 /* CompleteOnboardingView.swift in Sources */, 89FE21AD24AC57E30033F501 /* Collection.swift in Sources */, 898ECA63218ABD21001E9D35 /* ComplicationChartManager.swift in Sources */, 439A7945211FE23A0041B75F /* NSUserActivity.swift in Sources */, diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 5e5a1fd32b..c64c21253a 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -300,6 +300,7 @@ final class WatchDataManager: NSObject { let settings = self.settingsManager.loopSettings context.isClosedLoop = settings.dosingEnabled + context.isOnboardingCompleted = deviceManager.cgmManager?.isOnboarded == true && deviceManager.pumpManager?.isOnboarded == true context.deviceIssue = deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true || deviceManager.cgmManager?.inSignalLoss == true || deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true || deviceManager.pumpManager?.inSignalLoss == true || deviceManager.hasBluetoothIssue context.potentialCarbEntry = potentialCarbEntry diff --git a/LoopCore/Models/WatchContext.swift b/LoopCore/Models/WatchContext.swift index dd73f353b6..adbfb92778 100644 --- a/LoopCore/Models/WatchContext.swift +++ b/LoopCore/Models/WatchContext.swift @@ -63,6 +63,7 @@ public final class WatchContext: RawRepresentable { public var isClosedLoop: Bool? public var deviceIssue: Bool? + public var isOnboardingCompleted: Bool? public init( creationDate: Date = Date(), @@ -90,7 +91,8 @@ public final class WatchContext: RawRepresentable { insulinDeliveryState: InsulinDeliveryWatchState? = nil, lastManualBolus: LastManualBolus? = nil, isClosedLoop: Bool? = nil, - deviceIssue: Bool? = nil + deviceIssue: Bool? = nil, + isOnboardingCompleted: Bool? = nil ) { self.creationDate = creationDate self.displayGlucoseUnit = displayGlucoseUnit @@ -118,6 +120,7 @@ public final class WatchContext: RawRepresentable { self.lastManualBolus = lastManualBolus self.isClosedLoop = isClosedLoop self.deviceIssue = deviceIssue + self.isOnboardingCompleted = isOnboardingCompleted } public required init?(rawValue: RawValue) { @@ -128,6 +131,7 @@ public final class WatchContext: RawRepresentable { self.creationDate = creationDate isClosedLoop = rawValue["cl"] as? Bool deviceIssue = rawValue["di"] as? Bool + isOnboardingCompleted = rawValue["oc"] as? Bool if let unitString = rawValue["gu"] as? String { displayGlucoseUnit = LoopUnit(from: unitString) @@ -191,6 +195,7 @@ public final class WatchContext: RawRepresentable { raw["bp"] = batteryPercentage raw["cl"] = isClosedLoop raw["di"] = deviceIssue + raw["oc"] = isOnboardingCompleted raw["cgmManagerState"] = cgmManagerState diff --git a/WatchApp Extension/Views/CompleteOnboardingView.swift b/WatchApp Extension/Views/CompleteOnboardingView.swift new file mode 100644 index 0000000000..4298e4e3ed --- /dev/null +++ b/WatchApp Extension/Views/CompleteOnboardingView.swift @@ -0,0 +1,20 @@ +// +// CompleteOnboardingView.swift +// Loop +// +// Created by Nathaniel Hamming on 2026-02-26. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct CompleteOnboardingView: View { + + var body: some View { + VStack(alignment: .center) { + Spacer() + Text("Please complete onboarding to use the Tidepool Loop Watch app").multilineTextAlignment(.center) + Spacer() + } + } +} diff --git a/WatchApp/LoopWatchApp.swift b/WatchApp/LoopWatchApp.swift index daf3e10284..cf5ad7d80f 100644 --- a/WatchApp/LoopWatchApp.swift +++ b/WatchApp/LoopWatchApp.swift @@ -15,8 +15,12 @@ struct LoopWatchApp: App { var body: some Scene { WindowGroup { - ContentView() - .environment(loopManager) + if loopManager.activeContext?.isOnboardingCompleted != true { + CompleteOnboardingView() + } else { + ContentView() + .environment(loopManager) + } } } } From 1e7ba0a5a5bf74ebeffc61329dd259aa09e6b14d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 26 Feb 2026 19:20:11 -0600 Subject: [PATCH 366/421] Fix glucose rising notice to display on average of last three samples (#909) --- Loop/Models/LoopConstants.swift | 1 - Loop/View Models/CarbEntryViewModel.swift | 16 +++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/Loop/Models/LoopConstants.swift b/Loop/Models/LoopConstants.swift index 4a89c12d3c..5a78c4e8f7 100644 --- a/Loop/Models/LoopConstants.swift +++ b/Loop/Models/LoopConstants.swift @@ -63,7 +63,6 @@ enum LoopConstants { /// Missed Meal warning constants static let missedMealWarningGlucoseRiseThreshold = 3.0 // mg/dL/m static let missedMealWarningGlucoseRecencyWindow = TimeInterval(minutes: 19) - static let missedMealWarningVelocitySampleMinDuration = TimeInterval(minutes: 12) // Bolus calculator warning thresholds static let simpleBolusCalculatorMinGlucoseBolusRecommendation = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 70) diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift index 1cd8c99b75..ab6e19b2e3 100644 --- a/Loop/View Models/CarbEntryViewModel.swift +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -399,7 +399,7 @@ final class CarbEntryViewModel: ObservableObject { } // Condition 2: Rate of change over the most recent three glucose readings within 19 minutes - let threeReadingWindow = now.addingTimeInterval(LoopConstants.missedMealWarningGlucoseRecencyWindow) + let threeReadingWindow = now.addingTimeInterval(-LoopConstants.missedMealWarningGlucoseRecencyWindow) let threeReadingSamples = filteredGlucoseSamples.filterDateRange(threeReadingWindow, now) if threeReadingSamples.count >= 3 { @@ -408,14 +408,12 @@ final class CarbEntryViewModel: ObservableObject { let lastOfThree = recentThree[2] let duration = lastOfThree.startDate.timeIntervalSince(firstOfThree.startDate) - if duration >= LoopConstants.missedMealWarningVelocitySampleMinDuration { - let delta = lastOfThree.quantity.doubleValue(for: .milligramsPerDeciliter) - firstOfThree.quantity.doubleValue(for: .milligramsPerDeciliter) - let velocity = delta / duration.minutes // Unit = mg/dL/min - - if velocity >= LoopConstants.missedMealWarningGlucoseRiseThreshold { - warnings.insert(.glucoseRisingRapidly) - return - } + let delta = lastOfThree.quantity.doubleValue(for: .milligramsPerDeciliter) - firstOfThree.quantity.doubleValue(for: .milligramsPerDeciliter) + let velocity = delta / duration.minutes // Unit = mg/dL/min + + if velocity >= LoopConstants.missedMealWarningGlucoseRiseThreshold { + warnings.insert(.glucoseRisingRapidly) + return } } From 2b10f8e82678277c0100325c47459b85ddf6824d Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 27 Feb 2026 10:23:03 -0800 Subject: [PATCH 367/421] [LOOP-5752] Fix Delayed Notification Alert (#908) --- Loop/Managers/AlertPermissionsChecker.swift | 33 +++++++++++++--- Loop/Managers/Alerts/AlertManager.swift | 42 ++++++++++----------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index 391116ae9b..c1e7dfafed 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -216,17 +216,38 @@ extension AlertPermissionsChecker { ) } + + /* + All Permutations of NotificationCenterSettingsFlags + + none = 0 + notificationsDisabled = 1 + criticalAlertsDisabled = 2 + notificationsDisabled & criticalAlertsDisabled = 3 + timeSensitiveDisabled = 4 + notificationsDisabled & timeSensitiveDisabled = 5 (Not Possible) + criticalAlertsDisabled & timeSensitiveDisabled == 6 + notificationsDisabled & criticalAlertsDisabled & timeSensitiveDisabled = 7 (Not Possible) + scheduledDeliveryEnabled = 8 + notificationsDisabled & scheduledDeliveryEnabled = 9 (Not Possible) + criticalAlertsDisabled & scheduledDeliveryEnabled = 10 + notificationsDisabled & criticalAlertsDisabled & scheduledDeliveryEnabled = 11 (Not Possible) + timeSensitiveDisabled & scheduledDeliveryEnabled = 12 + notificationsDisabled & timeSensitiveDisabled & scheduledDeliveryEnabled = 13 (Not Possible) + criticalAlertsDisabled & timeSensitiveDisabled & scheduledDeliveryEnabled = 14 + notificationsDisabled & criticalAlertsDisabled & timeSensitiveDisabled & scheduledDeliveryEnabled = 15 (Not Possible) + */ init?(permissions: NotificationCenterSettingsFlags) { switch permissions { - case .notificationsDisabled: + case .notificationsDisabled, NotificationCenterSettingsFlags(rawValue: 9): self = .notificationsDisabled - case .timeSensitiveDisabled, NotificationCenterSettingsFlags(rawValue: 5): - self = .timeSensitiveDisabled - case .criticalAlertsDisabled: + case .criticalAlertsDisabled, NotificationCenterSettingsFlags(rawValue: 10): self = .criticalAlertsDisabled - case NotificationCenterSettingsFlags(rawValue: 3): + case .timeSensitiveDisabled, NotificationCenterSettingsFlags(rawValue: 5), NotificationCenterSettingsFlags(rawValue: 12), NotificationCenterSettingsFlags(rawValue: 13): + self = .timeSensitiveDisabled + case NotificationCenterSettingsFlags(rawValue: 3), NotificationCenterSettingsFlags(rawValue: 11): self = .criticalAlertsAndNotificationDisabled - case NotificationCenterSettingsFlags(rawValue: 6): + case NotificationCenterSettingsFlags(rawValue: 6), NotificationCenterSettingsFlags(rawValue: 7), NotificationCenterSettingsFlags(rawValue: 14), NotificationCenterSettingsFlags(rawValue: 15): self = .criticalAlertsAndTimeSensitiveDisabled default: return nil diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index b51b94e0ea..aa07efde78 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -673,10 +673,29 @@ extension AlertManager: BluetoothObserver { extension AlertManager: AlertPermissionsCheckerDelegate { func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool, permissions: NotificationCenterSettingsFlags) { guard let unsafeNotificationAlert = AlertPermissionsChecker.UnsafeNotificationPermissionAlert(permissions: permissions) else { + _ = issueOrRetract( + alert: AlertPermissionsChecker.scheduledDeliveryEnabledAlert, + condition: scheduledDeliveryEnabled, + alreadyIssued: UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert, + setAlreadyIssued: { + UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0 + }, + issueHandler: { alert in + Task { + await self.issueAlert(alert) + } + }, + retractionHandler: { alert in + Task { + await self.retractAlert(identifier: alert.identifier) + } + } + ) + return } - if !issueOrRetract( + _ = issueOrRetract( alert: unsafeNotificationAlert.alert, condition: requiresRiskMitigation, alreadyIssued: UserDefaults.standard.hasIssuedNotificationPermissionsAlert, @@ -701,26 +720,7 @@ extension AlertManager: AlertPermissionsCheckerDelegate { await self.dismissUnsafeNotificationPermissionsInAppAlert() } } - ) { - _ = issueOrRetract( - alert: AlertPermissionsChecker.scheduledDeliveryEnabledAlert, - condition: scheduledDeliveryEnabled, - alreadyIssued: UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert, - setAlreadyIssued: { - UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0 - }, - issueHandler: { alert in - Task { - await self.issueAlert(alert) - } - }, - retractionHandler: { alert in - Task { - await self.retractAlert(identifier: alert.identifier) - } - } - ) - } + ) } private func issueOrRetract(alert: LoopKit.Alert, From 2fcd7053234b5b9b287ed2487f43996efafe440a Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 4 Mar 2026 06:02:18 -0400 Subject: [PATCH 368/421] [LOOP-5787-5496] updated loop status when closed loop is off and there is a device issue (#910) --- Loop/Views/LoopStatusModalView.swift | 16 ++++++---------- LoopUI/Views/LoopCompletionHUDView.swift | 3 ++- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index 1f6a93a8fc..f271906bf4 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -215,17 +215,15 @@ class LoopStatusModalViewModel { guard !isPumpInSignalLoss, !isCGMInSignalLoss else { return .stale } + + guard loopIconClosed else { + return .fresh + } + return LoopCompletionFreshness(age: ago) } var ago: TimeInterval? { - guard loopIconClosed else { - // when loop is open, the last glucose data date determines ago - guard let mostRecentGlucoseDataDate else { return nil } - return abs(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow)) - } - - // when loop is closed, the last loop completed date determines ago guard let lastLoopCompleted else { return nil } return abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) } @@ -258,10 +256,8 @@ class LoopStatusModalViewModel { return (titleDeviceIssue, NSLocalizedString("Check for potential communication issues with your CGM.\n\nIn the meantime, your pump is still able to deliver insulin.", comment: "message when automation is off and CGM is in signal loss")) } else if isCGMInWarmup { return (titleAutomationOff, NSLocalizedString("Your CGM sensor is warming up.\n\nIn the meantime, your pump is still able to deliver insulin.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on.", comment: "message when automation is off and CGM is in warmup")) - } else if freshness == .fresh { - return (titleAutomationOff, NSLocalizedString("Your pump and CGM will continue to operate, but the app will not adjust insulin dosing automatically.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on.", comment: "message when automation is off, glucose value is fresh and devices are good")) } else { - return (titleAutomationOff, NSLocalizedString("Make sure your devices are connected and within bluetooth range.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on.", comment: "message when automation is off, glucose value is not fresh and devices are good")) + return (titleAutomationOff, NSLocalizedString("Your pump and CGM will continue to operate, but the app will not adjust insulin dosing automatically.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on.", comment: "message when automation is off, glucose value is fresh and devices are good")) } } diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index 8bb371bb9b..2835a4bc00 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -185,7 +185,8 @@ public final class LoopCompletionHUDView: BaseHUDView { } else if !loopIconClosed, let mostRecentPumpDataDate, let mostRecentGlucoseDataDate { let ago = max(abs(min(0, mostRecentPumpDataDate.timeIntervalSinceNow)), abs(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow))) - freshness = LoopCompletionFreshness(age: ago) + // when closed loop is off, always present fresh unless there is a device issue (handled else where) + freshness = .fresh if let timeString = ago.truncatedTimeAgoString { switch traitCollection.preferredContentSizeCategory { From dab455e61a70b80ae5d4bbd1ee4bab9257759350 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 5 Mar 2026 10:52:41 -0800 Subject: [PATCH 369/421] [LOOP-5707] Add missing loopDataManager.dosingStrategySelectionEnabled to OnboardingManager (#911) --- Loop/Managers/LoopDataManager.swift | 2 +- Loop/Managers/OnboardingManager.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index adc13245cd..1b8db7dcc8 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -157,7 +157,7 @@ final class LoopDataManager: ObservableObject { private var lastManualBolusRecommendation: ManualBolusRecommendation? - private var dosingStrategySelectionEnabled: Bool + private(set) var dosingStrategySelectionEnabled: Bool var usePositiveMomentumAndRCForManualBoluses: Bool diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index fd40cfb2af..b384fdbb62 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -148,7 +148,7 @@ class OnboardingManager { } private func displayOnboarding(_ onboarding: OnboardingUI, resuming: Bool) -> Bool { - var onboardingViewController = onboarding.onboardingViewController(onboardingProvider: self, displayGlucosePreference: deviceDataManager.displayGlucosePreference, colorPalette: .default) + var onboardingViewController = onboarding.onboardingViewController(onboardingProvider: self, displayGlucosePreference: deviceDataManager.displayGlucosePreference, colorPalette: .default, dosingStrategySelectionEnabled: loopDataManager.dosingStrategySelectionEnabled) onboardingViewController.cgmManagerOnboardingDelegate = deviceDataManager onboardingViewController.pumpManagerOnboardingDelegate = deviceDataManager onboardingViewController.serviceOnboardingDelegate = servicesManager From 6bc63f11401f21df8970a4be6c6d1c85cc98811e Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 9 Mar 2026 06:32:25 -0300 Subject: [PATCH 370/421] [LOOP-5774] minor corrections to preset training UI (#912) --- Loop/Views/Presets/PresetsView.swift | 1 + Loop/Views/Presets/Training/PresetsTrainingContent.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 79fd27f1f9..02ca48ac68 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -144,6 +144,7 @@ struct PresetsView: View { }) { Image(systemName: "plus") } + .disabled(!trainingCompletion.isComplete) } .padding(.horizontal, 10) diff --git a/Loop/Views/Presets/Training/PresetsTrainingContent.swift b/Loop/Views/Presets/Training/PresetsTrainingContent.swift index 6837eb16cf..9fcf73176a 100644 --- a/Loop/Views/Presets/Training/PresetsTrainingContent.swift +++ b/Loop/Views/Presets/Training/PresetsTrainingContent.swift @@ -44,7 +44,7 @@ extension PresetsTraining.Step: PresetsTrainingContent { EstimatedReadTime(.minutes(10)) VStack(alignment: .leading) { - Text("This required training will show you how to change your settings with confidence and create custom presets that fit your need.\n\nThis training covers:") + Text("This required training will show you how to change your settings with confidence and create custom presets that fit your needs.\n\nThis training covers:") .fixedSize(horizontal: false, vertical: true) BulletedListView { From 7383a6e73dd8c5d7c44e8c9b9cef39b81048cc90 Mon Sep 17 00:00:00 2001 From: Bot Date: Tue, 10 Mar 2026 16:32:57 -0500 Subject: [PATCH 371/421] Resolve LoopDataManager concurrency migration (amend Loop sync) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the Swift Concurrency migration for LoopDataManager, integrating DIY's Live Activity feature into Tidepool's architecture: LiveActivityManagerProxy / LiveActivityManager: - Removed LoopSettings dependency (no longer needed, was importing LoopCore) - Protocol now takes (scheduleOverride:preMealOverride:glucoseTargetRangeSchedule:) directly instead of the monolithic LoopSettings struct - LiveActivityManager stores the three fields independently LoopDataManager: - Removed lockedSettings (Locked) — settings now come from settingsProvider.settings (StoredSettings), managed by SettingsProvider - Removed mutateSettings() — override changes go through temporaryPresetsManager which already handles presetActivated/Deactivated observer notifications - Removed DIY's synchronous loop()/loopInternal()/finishLoop()/update() chain (~400 lines) — superseded by Tidepool's async loop() function - Fixed notification observers: removed orphaned dataAccessQueue.async blocks, keeping only Tidepool's Task { @MainActor in await updateDisplayState() } - Updated overrideIntentObserver (Siri override) to use temporaryPresetsManager directly instead of mutateSettings - Live Activity update injected into updateDisplayState() — fires after every loop cycle and data change, using current temporaryPresetsManager state - LiveActivityManager init no longer takes loopSettings parameter --- Loop.xcodeproj/project.pbxproj | 339 +++++-------- .../xcshareddata/xcschemes/Loop.xcscheme | 4 +- .../Live Activity/LiveActivityManager.swift | 22 +- .../LiveActivityManagerProxy.swift | 10 +- Loop/Managers/LoopDataManager.swift | 457 +----------------- LoopCore/LoopSettings.swift | 1 + LoopCore/NSUserDefaults.swift | 4 +- 7 files changed, 169 insertions(+), 668 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index b7f76fda81..b8cd2a40e0 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -229,6 +229,7 @@ 4FF4D1001E18374700846527 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; 4FF4D1011E18375000846527 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; 7D23667D21250C7E0028B67D /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D23667C21250C7E0028B67D /* LocalizedString.swift */; }; + 7D23667D21250C7E0028B67D /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D23667C21250C7E0028B67D /* LocalizedString.swift */; }; 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */; }; 84213C752D932F0F00642E78 /* InsulinDeliveryLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */; }; 84213C892D94941000642E78 /* InsulinDeliveryLogEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */; }; @@ -430,6 +431,7 @@ C10C57FA2E708B2D00A4825C /* PresetWatchCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57F92E708B1D00A4825C /* PresetWatchCard.swift */; }; C10C57FC2E70B8B900A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57FB2E70B87500A4825C /* EnvironmentValues+GlucoseDisplayUnit.swift */; }; C10C57FE2E71E87D00A4825C /* ActiveOverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10C57FD2E71E87400A4825C /* ActiveOverrideView.swift */; }; + C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C110888C2A3913C600BA4898 /* BuildDetails.swift */; }; C11445B22DA98A3400034864 /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */; }; C11B9D5B286778A800500CF8 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C11B9D5A286778A800500CF8 /* SwiftCharts */; }; C11B9D5E286778D000500CF8 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D5D286778D000500CF8 /* LoopKitUI.framework */; }; @@ -538,6 +540,7 @@ C1ED6C6C2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */; }; C1ED6C6D2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ABA1602E281D330049DF41 /* NotificationActionSelection.swift */; }; C1ED6C6E2E7C6EDE002F91C2 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; + E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; C1ED6C6F2E7C6EDE002F91C2 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; C1ED6C702E7C6F07002F91C2 /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; C1ED6C712E7C6F07002F91C2 /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; @@ -984,6 +987,7 @@ 4FF4D0FF1E18374700846527 /* WatchContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchContext.swift; sourceTree = ""; }; 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartHUDController.swift; sourceTree = ""; }; 7D23667C21250C7E0028B67D /* LocalizedString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LocalizedString.swift; path = LoopUI/Common/LocalizedString.swift; sourceTree = SOURCE_ROOT; }; + 7D23667C21250C7E0028B67D /* LocalizedString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LocalizedString.swift; path = LoopUI/Common/LocalizedString.swift; sourceTree = SOURCE_ROOT; }; 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetLoopManager.swift; sourceTree = ""; }; 840A2F0D2C0F978E003D5E90 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLog.swift; sourceTree = ""; }; @@ -1221,6 +1225,9 @@ C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntry.swift; sourceTree = ""; }; C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntryTests.swift; sourceTree = ""; }; C1550B0B2E6F249A009369DC /* LoopCircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCircleView.swift; sourceTree = ""; }; + C159C8192867857000A86EC0 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C159C8212867859800A86EC0 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C159C82E286787EF00A86EC0 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusViewModelTests.swift; sourceTree = ""; }; C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitor.swift; sourceTree = ""; }; C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitorTests.swift; sourceTree = ""; }; @@ -1233,7 +1240,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 = ""; }; - C1750AEB255B013300B8011C /* Minizip.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Minizip.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C1777A6525A125F100595963 /* ManualEntryDoseViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryDoseViewModelTests.swift; sourceTree = ""; }; C17824991E1999FA00D9D25C /* CaseCountable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaseCountable.swift; sourceTree = ""; }; @@ -1257,7 +1263,6 @@ C19008FD25225D3900721625 /* SimpleBolusCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusCalculator.swift; sourceTree = ""; }; C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusCalculatorTests.swift; sourceTree = ""; }; C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DosingStrategySelectionView.swift; sourceTree = ""; }; - C19C8BB928651DFB0056D5E4 /* TrueTime.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TrueTime.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C19C8BC228651EAE0056D5E4 /* LoopTestingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopTestingKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C19C8BC728651F0A0056D5E4 /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1290,6 +1295,7 @@ C1E2774722433D7A00354103 /* MKRingProgressView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MKRingProgressView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredLoopNotRunningNotification.swift; sourceTree = ""; }; C1E9CB5A295101570022387B /* install-scenarios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "install-scenarios.sh"; sourceTree = ""; }; + C1E9CB5A295101570022387B /* install-scenarios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "install-scenarios.sh"; sourceTree = ""; }; C1ED6C602E79BB9C002F91C2 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; C1ED6C7C2E7C8113002F91C2 /* SetPresetUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPresetUserInfo.swift; sourceTree = ""; }; C1ED6C7F2E7C9C7A002F91C2 /* PendingPresetReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingPresetReminder.swift; sourceTree = ""; }; @@ -1304,7 +1310,12 @@ C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = ""; }; C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = ""; }; + C1F7822527CC056900C0919A /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; + C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressTableViewCell.swift; sourceTree = ""; }; + C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = ""; }; C1FAD5182E7E0C3100F7FAD9 /* ChartPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartPageView.swift; sourceTree = ""; }; + C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; + C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = ""; }; DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegralRetrospectiveCorrectionSelectionView.swift; sourceTree = ""; }; DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationFactorStrategy.swift; sourceTree = ""; }; DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBasedApplicationFactorStrategy.swift; sourceTree = ""; }; @@ -2002,13 +2013,7 @@ 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */, 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */, 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */, - 1DA6499D2441266400F61E75 /* Alerts */, - E95D37FF24EADE68005E2F50 /* Store Protocols */, - E9B355232935906B0076AB04 /* Missed Meal Detection */, 3ED319902EB65A2D00820BCF /* Live Activity */, - C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, - A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, - 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */, C1B80D622AF97D7200AB7705 /* LoopDataManager+CarbAbsorption.swift */, ); @@ -3037,6 +3042,7 @@ B66D1F352E6A5D6600471149 /* Localizable.xcstrings in Resources */, B405E35924D2A75B00DD058D /* DerivedAssets.xcassets in Resources */, C1275E1F2E822CEE0013B99D /* DerivedAssets.xcassets in Resources */, + A966152A23EA5A37005D8B29 /* DefaultAssets.xcassets in Resources */, 849466D02EF1EAD300A90718 /* LocalizablePlural.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3795,6 +3801,7 @@ E9B07FEE253BBC7100BAD8F8 /* OverrideIntentHandler.swift in Sources */, E942DE9F253BE6A900AC532D /* NSTimeInterval.swift in Sources */, E9B08016253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, + E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */, 1D3F0F7726D59DCE004A5960 /* Debug.swift in Sources */, E942DF34253BF87F00AC532D /* Intents.intentdefinition in Sources */, E9B07F7F253BBA6500BAD8F8 /* IntentHandler.swift in Sources */, @@ -3874,6 +3881,32 @@ isa = PBXVariantGroup; children = ( 43785E9A2120E7060057DED1 /* Base */, + 43785E9F2122774A0057DED1 /* es */, + 43785EA12122774B0057DED1 /* ru */, + 43C98058212A799E003B5D17 /* en */, + C12CB9AC23106A3C00F84978 /* it */, + C12CB9AE23106A5C00F84978 /* fr */, + C12CB9B023106A5F00F84978 /* de */, + C12CB9B223106A6000F84978 /* zh-Hans */, + C12CB9B423106A6100F84978 /* nl */, + C12CB9B623106A6200F84978 /* nb */, + C12CB9B823106A6300F84978 /* pl */, + 7D9BEF132335EC4B005DCFD6 /* ja */, + 7D9BEF292335EC58005DCFD6 /* pt-BR */, + 7D9BEF3F2335EC62005DCFD6 /* vi */, + 7D9BEF552335EC6E005DCFD6 /* da */, + 7D9BEF6B2335EC7D005DCFD6 /* sv */, + 7D9BEF812335EC8B005DCFD6 /* fi */, + 7D9BF13A23370E8B005DCFD6 /* ro */, + F5D9C01727DABBE0002E48F6 /* tr */, + F5E0BDD327E1D71C0033557E /* he */, + C1C3127F297E4C0400296DA4 /* ar */, + C1C247882995823200371B88 /* sk */, + C1C5357529C6346A00E32DF9 /* cs */, + 3D03C6DA2AACE6AC00FDE5D2 /* hi */, + B6F22EF52E95A03600CCA05F /* ce */, + B6F22EF72E95A03800CCA05F /* hu */, + B6F22EF92E95A03C00CCA05F /* uk */, ); name = Intents.intentdefinition; sourceTree = ""; @@ -3891,6 +3924,26 @@ isa = PBXVariantGroup; children = ( 43D9FFA921EA9A0C00AF44BF /* Base */, + 7D9BEF002335D67D005DCFD6 /* en */, + 7D9BEF022335D687005DCFD6 /* zh-Hans */, + 7D9BEF042335D68A005DCFD6 /* nl */, + 7D9BEF062335D68C005DCFD6 /* fr */, + 7D9BEF082335D68D005DCFD6 /* de */, + 7D9BEF0A2335D68F005DCFD6 /* it */, + 7D9BEF0C2335D690005DCFD6 /* nb */, + 7D9BEF0E2335D691005DCFD6 /* pl */, + 7D9BEF102335D693005DCFD6 /* ru */, + 7D9BEF122335D694005DCFD6 /* es */, + 7D9BEF182335EC4C005DCFD6 /* ja */, + 7D9BEF2E2335EC59005DCFD6 /* pt-BR */, + 7D9BEF442335EC62005DCFD6 /* vi */, + 7D9BEF5A2335EC6E005DCFD6 /* da */, + 7D9BEF702335EC7D005DCFD6 /* sv */, + 7D9BEF862335EC8B005DCFD6 /* fi */, + 7D9BF13E23370E8C005DCFD6 /* ro */, + F5D9C01C27DABBE1002E48F6 /* tr */, + F5E0BDD827E1D71E0033557E /* he */, + C1C3127A297E4BFE00296DA4 /* ar */, ); name = Main.storyboard; sourceTree = ""; @@ -3902,207 +3955,30 @@ B66D1F282E6A5D6500471149 /* mul */, ); name = MainInterface.storyboard; - 4B60626A287E286000BF8BBB /* Localizable.strings */ = { - 4B60626B287E286000BF8BBB /* de */, - C1004DF92981F5B700B8CF94 /* da */, - C1004E012981F67A00B8CF94 /* sv */, - C1004E092981F6A100B8CF94 /* ro */, - C1004E112981F6E200B8CF94 /* nl */, - C1004E192981F6F500B8CF94 /* nb */, - C1004E212981F72D00B8CF94 /* fr */, - C1004E282981F74300B8CF94 /* fi */, - C1BCB5B3298309C4001C50FF /* it */, - C19A2247298951AC000E4E71 /* en */, - C1EB0D1E299581D900628475 /* es */, - C1F48FFA2995821600C8BD69 /* pl */, - C1B267992995824000BCB7C1 /* tr */, - C122DEFA29BBFAAE00321F8D /* ru */, - C15A582129C7866600D3A5A1 /* ar */, - C1FF3D4B29C786A900BDC1EC /* he */, - C1B0CFD629C786BF0045B04D /* ja */, - C1E693CC29C786E200410918 /* pt-BR */, - C1FDCBFD29C786F90056E652 /* sk */, - C192C60029C78711001EFEA6 /* vi */, - name = Localizable.strings; - sourceTree = ""; - }; - 4B67E2C6289B4EDB002D92AF /* InfoPlist.strings */ = { - 4B67E2C7289B4EDB002D92AF /* de */, - C1004DFB2981F5B700B8CF94 /* da */, - C1004E032981F67A00B8CF94 /* sv */, - C1004E0B2981F6A100B8CF94 /* ro */, - C1004E132981F6E200B8CF94 /* nl */, - C1004E1B2981F6F500B8CF94 /* nb */, - C1004E232981F72D00B8CF94 /* fr */, - C1004E2A2981F74300B8CF94 /* fi */, - C1004E2E2981F75B00B8CF94 /* es */, - C1BCB5B8298309C4001C50FF /* it */, - C1F48FFF2995821600C8BD69 /* pl */, - C1B2679D2995824000BCB7C1 /* tr */, - C122DEFF29BBFAAE00321F8D /* ru */, - C15A582329C7866600D3A5A1 /* ar */, - C1FF3D4D29C786A900BDC1EC /* he */, - C1B0CFD929C786BF0045B04D /* ja */, - C1E693CF29C786E200410918 /* pt-BR */, - C1FDCC0129C786F90056E652 /* sk */, - C192C60329C78711001EFEA6 /* vi */, - name = InfoPlist.strings; - sourceTree = ""; - }; - 63F5E17A297DDF3900A62D4B /* ckcomplication.strings */ = { - 63F5E17B297DDF3900A62D4B /* Base */, - C1C3127E297E4C0100296DA4 /* ar */, - C116134D2983096D00777E7C /* nb */, - C1BCB5B7298309C4001C50FF /* it */, - C11A2BCF29830A3100AC5135 /* fr */, - C18886E829830A5E004C982D /* nl */, - C155A8F52986396E009BD257 /* de */, - C18B7260299581C600F138D3 /* da */, - C1EB0D22299581D900628475 /* es */, - C1F48FFE2995821600C8BD69 /* pl */, - C1B2679C2995824000BCB7C1 /* tr */, - C1AD630029BBFAA80002685D /* ro */, - C122DEFE29BBFAAE00321F8D /* ru */, - C1275E202E8235E90013B99D /* en */, - name = ckcomplication.strings; - sourceTree = ""; - }; - 7D7076471FE06EE0004AC8EA /* InfoPlist.strings */ = { - 7D23667A21250C480028B67D /* Base */, - F5D9C02327DABBE3002E48F6 /* tr */, - F5E0BDDF27E1D7210033557E /* he */, - C1C31281297E4C0400296DA4 /* ar */, - C1004DFA2981F5B700B8CF94 /* da */, - C1004E022981F67A00B8CF94 /* sv */, - C1004E0A2981F6A100B8CF94 /* ro */, - C1004E122981F6E200B8CF94 /* nl */, - C1004E1A2981F6F500B8CF94 /* nb */, - C1004E222981F72D00B8CF94 /* fr */, - C1004E292981F74300B8CF94 /* fi */, - C1004E342981F77B00B8CF94 /* de */, - C1BCB5B4298309C4001C50FF /* it */, - C1EB0D1F299581D900628475 /* es */, - C1F48FFB2995821600C8BD69 /* pl */, - C122DEFB29BBFAAE00321F8D /* ru */, - C1B0CFD729C786BF0045B04D /* ja */, - C1E693CD29C786E200410918 /* pt-BR */, - C192C60129C78711001EFEA6 /* vi */, - name = InfoPlist.strings; - sourceTree = ""; - }; - 7D70764C1FE06EE1004AC8EA /* Localizable.strings */ = { - 7D70764B1FE06EE1004AC8EA /* es */, - 7D68AAB31FE2E8D500522C49 /* ru */, - 7D23667921250C440028B67D /* Base */, - 7D23668C21250D190028B67D /* fr */, - 7D23669C21250D230028B67D /* de */, - 7D2366AC21250D2D0028B67D /* zh-Hans */, - 7D2366BD21250D360028B67D /* it */, - 7D2366CC21250D400028B67D /* nl */, - 7D2366DC21250D4B0028B67D /* nb */, - 7D199D9A212A067600241026 /* pl */, - 7D9BEEDB2335A587005DCFD6 /* en */, - 7D9BEF1F2335EC4D005DCFD6 /* ja */, - 7D9BEF352335EC59005DCFD6 /* pt-BR */, - 7D9BEF4B2335EC63005DCFD6 /* vi */, - 7D9BEF612335EC6F005DCFD6 /* da */, - 7D9BEF772335EC7E005DCFD6 /* sv */, - 7D9BEF8D2335EC8C005DCFD6 /* fi */, - 7D9BF14323370E8C005DCFD6 /* ro */, - F5D9C02227DABBE3002E48F6 /* tr */, - F5E0BDDE27E1D7210033557E /* he */, - C1C31280297E4C0400296DA4 /* ar */, - C121D8D029C7866D00DA0520 /* cs */, - C1FAB5C029C786B000D25073 /* hi */, - C1FDCBFE29C786F90056E652 /* sk */, - name = Localizable.strings; - sourceTree = ""; - }; - 7D7076511FE06EE1004AC8EA /* InfoPlist.strings */ = { - 7D68AAB41FE2E8D600522C49 /* ru */, - 7D23667621250BF70028B67D /* Base */, - 7D23668921250D180028B67D /* fr */, - 7D23669921250D230028B67D /* de */, - 7D2366A921250D2C0028B67D /* zh-Hans */, - 7D2366BA21250D360028B67D /* it */, - 7D2366C921250D400028B67D /* nl */, - 7D2366D921250D4A0028B67D /* nb */, - 7D199D97212A067600241026 /* pl */, - 7D9BEED52335A3CB005DCFD6 /* en */, - 7D9BEF1C2335EC4C005DCFD6 /* ja */, - 7D9BEF322335EC59005DCFD6 /* pt-BR */, - 7D9BEF5E2335EC6F005DCFD6 /* da */, - 7D9BEF8A2335EC8C005DCFD6 /* fi */, - 7D9BEF98233600D6005DCFD6 /* es */, - 7D9BEF99233600D8005DCFD6 /* sv */, - 7D9BEF9A233600D9005DCFD6 /* vi */, - 7D9BF14123370E8C005DCFD6 /* ro */, - F5D9C02027DABBE2002E48F6 /* tr */, - F5E0BDDC27E1D7200033557E /* he */, - C174571429830930009EFCF2 /* ar */, - C1C247902995823200371B88 /* sk */, - name = InfoPlist.strings; - sourceTree = ""; - }; - 7D7076601FE06EE3004AC8EA /* Localizable.strings */ = { - 7D70765F1FE06EE3004AC8EA /* es */, - 7D68AAB71FE2E8D600522C49 /* ru */, - 7D23667F21250CB80028B67D /* Base */, - 7D23668F21250D190028B67D /* fr */, - 7D23669F21250D240028B67D /* de */, - 7D2366AF21250D2D0028B67D /* zh-Hans */, - 7D2366BF21250D370028B67D /* it */, - 7D2366CF21250D400028B67D /* nl */, - 7D2366DF21250D4B0028B67D /* nb */, - 7D199D9D212A067700241026 /* pl */, - 7D9BEEDE2335A5F7005DCFD6 /* en */, - 7D9BEF222335EC4D005DCFD6 /* ja */, - 7D9BEF382335EC5A005DCFD6 /* pt-BR */, - 7D9BEF4E2335EC63005DCFD6 /* vi */, - 7D9BEF642335EC6F005DCFD6 /* da */, - 7D9BEF7A2335EC7E005DCFD6 /* sv */, - 7D9BEF902335EC8C005DCFD6 /* fi */, - 7D9BF14423370E8D005DCFD6 /* ro */, - F5D9C02527DABBE4002E48F6 /* tr */, - F5E0BDE127E1D7230033557E /* he */, - C174571529830930009EFCF2 /* ar */, - C121D8D129C7866D00DA0520 /* cs */, - C1FAB5C129C786B000D25073 /* hi */, - C1FDCC0029C786F90056E652 /* sk */, - name = Localizable.strings; - sourceTree = ""; - }; - 7D7076651FE06EE4004AC8EA /* Localizable.strings */ = { - 7D7076641FE06EE4004AC8EA /* es */, - 7D68AAB81FE2E8D700522C49 /* ru */, - 7D23667521250BE30028B67D /* Base */, - 7D23668821250D180028B67D /* fr */, - 7D23669821250D230028B67D /* de */, - 7D2366A821250D2C0028B67D /* zh-Hans */, - 7D2366B921250D360028B67D /* it */, - 7D2366C821250D400028B67D /* nl */, - 7D2366D821250D4A0028B67D /* nb */, - 7D199D96212A067600241026 /* pl */, - 7D9BEF1B2335EC4C005DCFD6 /* ja */, - 7D9BEF312335EC59005DCFD6 /* pt-BR */, - 7D9BEF472335EC62005DCFD6 /* vi */, - 7D9BEF5D2335EC6F005DCFD6 /* da */, - 7D9BEF732335EC7D005DCFD6 /* sv */, - 7D9BEF892335EC8C005DCFD6 /* fi */, - 7D9BEF972335F667005DCFD6 /* en */, - 7D9BF14023370E8C005DCFD6 /* ro */, - F5D9C01F27DABBE2002E48F6 /* tr */, - F5E0BDDB27E1D7200033557E /* he */, - C1C31282297E4F6E00296DA4 /* ar */, - C1C247912995823200371B88 /* sk */, - C12BCCF929BBFA480066A158 /* cs */, - C1FAB5BE29C786B000D25073 /* hi */, - name = Localizable.strings; sourceTree = ""; }; 7D9BEEE72335A6B3005DCFD6 /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( + 7D9BEEE62335A6B3005DCFD6 /* en */, + 7D9BEEE82335A6B9005DCFD6 /* zh-Hans */, + 7D9BEEE92335A6BB005DCFD6 /* nl */, + 7D9BEEEA2335A6BC005DCFD6 /* fr */, + 7D9BEEEB2335A6BD005DCFD6 /* de */, + 7D9BEEEC2335A6BE005DCFD6 /* it */, + 7D9BEEED2335A6BF005DCFD6 /* nb */, + 7D9BEEEE2335A6BF005DCFD6 /* pl */, + 7D9BEEEF2335A6C0005DCFD6 /* ru */, + 7D9BEEF02335A6C1005DCFD6 /* es */, + 7D9BEF282335EC4E005DCFD6 /* ja */, + 7D9BEF3E2335EC5A005DCFD6 /* pt-BR */, + 7D9BEF542335EC64005DCFD6 /* vi */, + 7D9BEF6A2335EC70005DCFD6 /* da */, + 7D9BEF802335EC7E005DCFD6 /* sv */, + 7D9BEF962335EC8D005DCFD6 /* fi */, + 7D9BF14623370E8D005DCFD6 /* ro */, + F5D9C02727DABBE4002E48F6 /* tr */, + F5E0BDE327E1D7230033557E /* he */, ); name = Localizable.strings; sourceTree = ""; @@ -4110,6 +3986,27 @@ 7D9BEEF52335CF8D005DCFD6 /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( + 7D9BEEF42335CF8D005DCFD6 /* en */, + 7D9BEEF62335CF90005DCFD6 /* zh-Hans */, + 7D9BEEF72335CF91005DCFD6 /* nl */, + 7D9BEEF82335CF93005DCFD6 /* fr */, + 7D9BEEF92335CF93005DCFD6 /* de */, + 7D9BEEFA2335CF94005DCFD6 /* it */, + 7D9BEEFB2335CF95005DCFD6 /* nb */, + 7D9BEEFC2335CF96005DCFD6 /* pl */, + 7D9BEEFD2335CF97005DCFD6 /* ru */, + 7D9BEEFE2335CF97005DCFD6 /* es */, + 7D9BEF1A2335EC4C005DCFD6 /* ja */, + 7D9BEF302335EC59005DCFD6 /* pt-BR */, + 7D9BEF462335EC62005DCFD6 /* vi */, + 7D9BEF5C2335EC6F005DCFD6 /* da */, + 7D9BEF722335EC7D005DCFD6 /* sv */, + 7D9BEF882335EC8C005DCFD6 /* fi */, + 7D9BF13F23370E8C005DCFD6 /* ro */, + F5D9C01E27DABBE2002E48F6 /* tr */, + F5E0BDDA27E1D71F0033557E /* he */, + C1C3127C297E4BFE00296DA4 /* ar */, + C1C247892995823200371B88 /* sk */, ); name = Localizable.strings; sourceTree = ""; @@ -4117,6 +4014,20 @@ 80F864E42433BF5D0026EC26 /* InfoPlist.strings */ = { isa = PBXVariantGroup; children = ( + 80F864E52433BF5D0026EC26 /* fi */, + C1004DEF2981F5B700B8CF94 /* da */, + C1004DFD2981F67A00B8CF94 /* sv */, + C1004E052981F6A100B8CF94 /* ro */, + C1004E0D2981F6E200B8CF94 /* nl */, + C1004E152981F6F500B8CF94 /* nb */, + C1004E1D2981F72D00B8CF94 /* fr */, + C1004E2C2981F75B00B8CF94 /* es */, + C1004E302981F77B00B8CF94 /* de */, + C1BCB5AF298309C4001C50FF /* it */, + C19E387B298638CE00851444 /* tr */, + C1F48FF62995821600C8BD69 /* pl */, + C14952142995822A0095AA84 /* ru */, + C1C2478B2995823200371B88 /* sk */, ); name = InfoPlist.strings; sourceTree = ""; @@ -4819,12 +4730,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; PRODUCT_NAME = "$(TARGET_NAME)"; 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_INSTALL_OBJC_HEADER = NO; - TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -4847,12 +4753,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; PRODUCT_NAME = "$(TARGET_NAME)"; 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_INSTALL_OBJC_HEADER = NO; - TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; @@ -5116,7 +5017,12 @@ PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 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_INSTALL_OBJC_HEADER = NO; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Testflight; }; @@ -5167,7 +5073,12 @@ PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; PRODUCT_NAME = "$(TARGET_NAME)"; 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_INSTALL_OBJC_HEADER = NO; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Testflight; }; diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme index c7ba04cfc6..bfa1c166a1 100644 --- a/Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme +++ b/Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme @@ -3,8 +3,8 @@ LastUpgradeVersion = "2600" version = "1.3"> + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> Void in - self.dataAccessQueue.async { - self.logger.default("Received notification of carb entries changing") - self.liveActivityManager?.update(loopSettings: self.settings) - - self.carbEffect = nil - self.carbsOnBoard = nil - self.recentCarbEntries = nil - self.remoteRecommendationNeedsUpdating = true Task { @MainActor in await self.updateDisplayState() - self.notify(forChange: .carbs) } }, @@ -276,16 +254,9 @@ final class LoopDataManager: ObservableObject { object: self.glucoseStore, queue: nil ) { (note) in - self.dataAccessQueue.async { - self.logger.default("Received notification of glucose samples changing") - self.liveActivityManager?.update(loopSettings: self.settings) - - self.glucoseMomentumEffect = nil - self.remoteRecommendationNeedsUpdating = true Task { @MainActor in self.restartGlucoseValueStalenessTimer() await self.updateDisplayState() - self.notify(forChange: .glucose) } }, @@ -294,15 +265,8 @@ final class LoopDataManager: ObservableObject { object: self.doseStore, queue: OperationQueue.main ) { (note) in - self.dataAccessQueue.async { - self.logger.default("Received notification of dosing changing") - self.liveActivityManager?.update(loopSettings: self.settings) - - self.clearCachedInsulinEffects() - self.remoteRecommendationNeedsUpdating = true Task { @MainActor in await self.updateDisplayState() - self.notify(forChange: .insulin) } }, @@ -335,64 +299,10 @@ final class LoopDataManager: ObservableObject { now.timeIntervalSince(entry.startDate) < .hours(36) }) - private var lockedSettings: Locked - - var settings: LoopSettings { - lockedSettings.value - } - - func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { - var oldValue: LoopSettings! - let newValue = lockedSettings.mutate { settings in - oldValue = settings - changes(&settings) - } - - guard oldValue != newValue else { - return - } - - var invalidateCachedEffects = false - - dosingEnabled = newValue.dosingEnabled - - if newValue.preMealOverride != oldValue.preMealOverride { - // The prediction isn't actually invalid, but a target range change requires recomputing recommended doses - predictedGlucose = nil - - self.liveActivityManager?.update(loopSettings: newValue) - } - - if newValue.scheduleOverride != oldValue.scheduleOverride { - overrideHistory.recordOverride(settings.scheduleOverride) - - if let oldPreset = oldValue.scheduleOverride { - for observer in self.presetActivationObservers { - observer.presetDeactivated(context: oldPreset.context) - } - self.liveActivityManager?.update(loopSettings: newValue) - } - if let newPreset = newValue.scheduleOverride { - for observer in self.presetActivationObservers { - observer.presetActivated(context: newPreset.context, duration: newPreset.duration) - Task { - await self.updateDisplayState() - - } - - self.liveActivityManager?.update(loopSettings: newValue) - } - - if !enabled { - temporaryPresetsManager.endPreMealOverride() - Task { - try? await self?.cancelActiveTempBasal(for: .automaticDosingDisabled) - } - } - } - } - // MARK: - Calculation state + // Note: settings are now accessed via settingsProvider.settings (StoredSettings) + // and overrides via temporaryPresetsManager. DIY's lockedSettings/mutateSettings + // were removed as part of the Swift Concurrency migration. fileprivate let dataAccessQueue: DispatchQueue = DispatchQueue(label: "com.loudnate.Naterade.LoopDataManager.dataAccessQueue", qos: .utility) @@ -651,6 +561,14 @@ final class LoopDataManager: ObservableObject { displayState = newState publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate publishedMostRecentPumpDataDate = mostRecentPumpDataDate + + // DIY: Update Live Activity with current override and target range state + liveActivityManager?.update( + scheduleOverride: temporaryPresetsManager.scheduleOverride, + preMealOverride: temporaryPresetsManager.preMealOverride, + glucoseTargetRangeSchedule: settingsProvider.settings.glucoseTargetRangeSchedule + ) + await updateRemoteRecommendation() } @@ -1036,349 +954,6 @@ extension LoopDataManager { 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: -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) - await dosingDecisionStore.storeDosingDecision(dosingDecision) - - } private func notify(forChange context: LoopUpdateContext) { NotificationCenter.default.post(name: .LoopDataUpdated, diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index 6702a60ab1..ba83a7aa55 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -5,6 +5,7 @@ // Copyright © 2017 LoopKit Authors. All rights reserved. // +import HealthKit import LoopKit import LoopAlgorithm diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index b8f2c21570..8ed715f823 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -175,7 +175,9 @@ extension UserDefaults { } catch { assertionFailure("Unable to encode MissedMealNotification") } - + } + } + public var defaultEnvironment: Data? { get { data(forKey: Key.defaultEnvironment.rawValue) From c0929ff033bf24121459d6eeea90b67420d5f8d4 Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 11 Mar 2026 12:50:47 -0500 Subject: [PATCH 372/421] =?UTF-8?q?Fix=20HKUnit=20=E2=86=92=20LoopUnit=20i?= =?UTF-8?q?n=20GlucoseLiveActivityConfiguration=20(LoopAlgorithm=20migrati?= =?UTF-8?q?on)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GlucoseLiveActivityConfiguration.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index b288c71458..071c16d6ad 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -205,12 +205,12 @@ struct GlucoseLiveActivityConfiguration: Widget { ) -> some View { let glucoseFormatter = NumberFormatter.glucoseFormatter( for: context.state.isMmol - ? HKUnit.millimolesPerLiter - : HKUnit.milligramsPerDeciliter + ? LoopUnit.millimolesPerLiter + : LoopUnit.milligramsPerDeciliter ) let unit = context.state.isMmol - ? HKUnit.millimolesPerLiter.localizedShortUnitString - : HKUnit.milligramsPerDeciliter.localizedShortUnitString + ? LoopUnit.millimolesPerLiter.localizedShortUnitString + : LoopUnit.milligramsPerDeciliter.localizedShortUnitString let glucoseColor = !context.attributes.useLimits ? .primary : getGlucoseColor(context: context) let currentBG = (glucoseFormatter.string(from: context.state.currentGlucose) ?? "??") + getArrowImage(context.state.trendType) @@ -263,7 +263,7 @@ struct GlucoseLiveActivityConfiguration: Widget { ) -> DynamicIsland { let glucoseFormatter = NumberFormatter.glucoseFormatter( for: context.state.isMmol - ? HKUnit.millimolesPerLiter : HKUnit.milligramsPerDeciliter + ? LoopUnit.millimolesPerLiter : LoopUnit.milligramsPerDeciliter ) return DynamicIsland { @@ -287,8 +287,8 @@ struct GlucoseLiveActivityConfiguration: Widget { .font(.headline) Text( context.state.isMmol - ? HKUnit.millimolesPerLiter.localizedShortUnitString - : HKUnit.milligramsPerDeciliter + ? LoopUnit.millimolesPerLiter.localizedShortUnitString + : LoopUnit.milligramsPerDeciliter .localizedShortUnitString ) .foregroundStyle(Color(white: 0.7)) From 54c0b7d8281e0c0f1b0508e6d64eee4fdd56e541 Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 11 Mar 2026 13:04:10 -0500 Subject: [PATCH 373/421] Fix dangling DerivedAssets pbxproj ref in WatchApp Resources phase (caused missing AppIcon error) --- Loop.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index b8cd2a40e0..58bc76e5a9 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -3025,7 +3025,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - A966152B23EA5A37005D8B29 /* DerivedAssets.xcassets in Resources */, + C1275E1F2E822CEE0013B99D /* DerivedAssets.xcassets in Resources */, B66D1F232E6A5D6500471149 /* InfoPlist.xcstrings in Resources */, 43A943761B926B7B0051FA24 /* Interface.storyboard in Resources */, A966152A23EA5A37005D8B29 /* DefaultAssets.xcassets in Resources */, From e1823a1b0128e20b9685df22671929fc0c885f96 Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 11 Mar 2026 13:07:23 -0500 Subject: [PATCH 374/421] Add missing LoopAlgorithm import to GlucoseLiveActivityConfiguration (needed for LoopUnit) --- .../Live Activity/GlucoseLiveActivityConfiguration.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift index 071c16d6ad..314d0de950 100644 --- a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -9,6 +9,7 @@ import ActivityKit import Charts import HealthKit +import LoopAlgorithm import LoopCore import LoopKit import SwiftUI From 77bed03d9dfc0f44e08600f1d7ef76a6efdfe6d4 Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 11 Mar 2026 14:00:31 -0500 Subject: [PATCH 375/421] Fix merge-artifact brace imbalances in LoopDataManager and DeviceDataManager LoopDataManager: missing Task{}/if !enabled{} block + 3 closing braces dropped from withObservationTracking init section during merge. DeviceDataManager: orphaned DIY generateDiagnosticReport callback block landed inside DeliveryDelegate extension without its function wrapper (Tidepool rewrote this as async); removed the orphaned block. --- Loop/Managers/DeviceDataManager.swift | 57 --------------------------- Loop/Managers/LoopDataManager.swift | 14 +++++++ 2 files changed, 14 insertions(+), 57 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 182dd44c05..b0f54ee8e0 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1341,63 +1341,6 @@ extension DeviceDataManager: DeliveryDelegate { return pumpManager != nil } - self.alertManager.getStoredEntries(startDate: Date() - .hours(logDurationHours)) { (alertReport) in - self.deviceLog.getLogEntries(startDate: Date() - .hours(logDurationHours)) { (result) in - let deviceLogReport: String - switch result { - case .failure(let error): - deviceLogReport = "Error fetching entries: \(error)" - case .success(let entries): - deviceLogReport = entries.map { "* \($0.timestamp) \($0.managerIdentifier) \($0.deviceIdentifier ?? "") \($0.type) \($0.message)" }.joined(separator: "\n") - } - - let submodulesInfo = BuildDetails.default.submodules - .sorted(by: { $0.key < $1.key }) - .map { key, value in - "* \(key): \(value.branch), \(value.commitSHA)" - } - .joined(separator: "\n") - - let report = [ - "## Build Details", - "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", - "* profileExpiration: \(BuildDetails.default.profileExpirationString)", - "* sourceRoot: \(BuildDetails.default.sourceRoot ?? "N/A")", - "* buildDateString: \(BuildDetails.default.buildDateString ?? "N/A")", - "* xcodeVersion: \(BuildDetails.default.xcodeVersion ?? "N/A")", - "* Workspace branch: \(BuildDetails.default.workspaceGitBranch ?? "N/A")", - "* Workspace SHA: \(BuildDetails.default.workspaceGitRevision ?? "N/A")", - "* Submodule name: branch, SHA", - "\(submodulesInfo)", - "", - "## FeatureFlags", - "\(FeatureFlags)", - "", - alertReport, - "", - "## DeviceDataManager", - "* launchDate: \(self.launchDate)", - "* lastError: \(String(describing: self.lastError))", - "", - "cacheStore: \(String(reflecting: self.cacheStore))", - "", - self.cgmManager != nil ? String(reflecting: self.cgmManager!) : "cgmManager: nil", - "", - self.pumpManager != nil ? String(reflecting: self.pumpManager!) : "pumpManager: nil", - "", - "## Device Communication Log", - deviceLogReport, - "", - String(reflecting: self.watchManager!), - "", - String(reflecting: self.statusExtensionManager!), - "", - loopReport, - ].joined(separator: "\n") - - completion(report) - } - } func roundBasalRate(unitsPerHour: Double) -> Double { guard let pumpManager = pumpManager else { return unitsPerHour diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index d300c18bca..5568786fcf 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -299,6 +299,20 @@ final class LoopDataManager: ObservableObject { now.timeIntervalSince(entry.startDate) < .hours(36) }) + Task { + await self.updateDisplayState() + } + } + + if !enabled { + temporaryPresetsManager.endPreMealOverride() + Task { + try? await self?.cancelActiveTempBasal(for: .automaticDosingDisabled) + } + } + } + } + // MARK: - Calculation state // Note: settings are now accessed via settingsProvider.settings (StoredSettings) // and overrides via temporaryPresetsManager. DIY's lockedSettings/mutateSettings From 6d9c954c4387e0e7cbde82486cac0069a1774f61 Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 11 Mar 2026 14:15:59 -0500 Subject: [PATCH 376/421] Fix SettingsView.swift brace imbalances from merge - Remove merged duplicate .sheet switch block (kept Tidepool's Group{switch} version) - Remove dead/broken configurationSection fragment (not used in body; Tidepool uses therapySection) - Fix therapySection: remove extra stray } and fix .accessibilityIdentifier placement - Add missing closing } for extension SettingsView before LargeButton struct --- Loop/Views/SettingsView.swift | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 5f46d8f758..fe55ac9ffe 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -138,9 +138,6 @@ struct SettingsView: View { } } .sheet(item: $sheet) { sheet in - switch sheet { - case .favoriteFoods: - FavoriteFoodsView() Group { switch sheet { case .presets: @@ -326,15 +323,6 @@ extension SettingsView { .environment(\.insulinTintColor, self.insulinTintColor) } - private var configurationSection: some View { - Section(header: SectionHeader(label: NSLocalizedString("Configuration", comment: "The title of the Configuration section in settings"))) { - NavigationLink(destination: therapySettingsView) { - LargeButton(action: { }, - delegate: viewModel.therapySettingsViewModelDelegate - ) - ) - } - private var therapySection: some View { Section { NavigationLink(destination: therapySettingsView) { @@ -343,14 +331,13 @@ extension SettingsView { imageView: Image("Therapy Icon"), label: NSLocalizedString("Therapy Settings", comment: "Title text for button to Therapy Settings"), descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) - } .accessibilityIdentifier("button_TherapySettings") } - + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in item.view } - + if FeatureFlags.allowAlgorithmExperiments { algorithmExperimentsSection } @@ -614,7 +601,9 @@ extension SettingsView { private func serviceImage(uiImage: UIImage?) -> some View { deviceImage(uiImage: uiImage) } -} +} // end extension SettingsView + +// MARK: - LargeButton fileprivate struct LargeButton: View { From 2fb43c9db7780d99ee1bdc375c4a680ca275c553 Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 11 Mar 2026 14:46:57 -0500 Subject: [PATCH 377/421] Add missing LoopCore import to LiveActivityManager (needed for LiveActivitySettings) --- Loop/Managers/Live Activity/LiveActivityManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop/Managers/Live Activity/LiveActivityManager.swift b/Loop/Managers/Live Activity/LiveActivityManager.swift index 1181d9bf96..fe02d32e7d 100644 --- a/Loop/Managers/Live Activity/LiveActivityManager.swift +++ b/Loop/Managers/Live Activity/LiveActivityManager.swift @@ -8,6 +8,7 @@ import LoopKitUI import LoopKit +import LoopCore import Foundation import HealthKit import ActivityKit From a71fa405a7786da8c00a10653d4d78fbc4a62b8d Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 11 Mar 2026 15:02:18 -0500 Subject: [PATCH 378/421] =?UTF-8?q?Fix=20LiveActivityManager:=20HKUnit?= =?UTF-8?q?=E2=86=92LoopUnit,=20remove=20callback=20IOB=20API,=20remove=20?= =?UTF-8?q?legacyWorkout,=20fix=20BolusEntryView=20safeAreaInset=20placeme?= =?UTF-8?q?nt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Live Activity/LiveActivityManager.swift | 35 +++++----------- .../LiveActivityManagerProxy.swift | 3 +- Loop/Managers/LoopDataManager.swift | 3 +- Loop/Views/BolusEntryView.swift | 40 +++++++++---------- 4 files changed, 34 insertions(+), 47 deletions(-) diff --git a/Loop/Managers/Live Activity/LiveActivityManager.swift b/Loop/Managers/Live Activity/LiveActivityManager.swift index fe02d32e7d..163bfa0256 100644 --- a/Loop/Managers/Live Activity/LiveActivityManager.swift +++ b/Loop/Managers/Live Activity/LiveActivityManager.swift @@ -28,6 +28,7 @@ class LiveActivityManager : LiveActivityManagerProxy { private var scheduleOverride: TemporaryScheduleOverride? private var preMealOverride: TemporaryScheduleOverride? private var glucoseTargetRangeSchedule: GlucoseRangeSchedule? + private var activeInsulin: InsulinValue? private var startDate: Date = Date.now private var settings: LiveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() @@ -84,11 +85,13 @@ class LiveActivityManager : LiveActivityManagerProxy { public func update( scheduleOverride: TemporaryScheduleOverride?, preMealOverride: TemporaryScheduleOverride?, - glucoseTargetRangeSchedule: GlucoseRangeSchedule? + glucoseTargetRangeSchedule: GlucoseRangeSchedule?, + activeInsulin: InsulinValue? ) { self.scheduleOverride = scheduleOverride self.preMealOverride = preMealOverride self.glucoseTargetRangeSchedule = glucoseTargetRangeSchedule + self.activeInsulin = activeInsulin update() } @@ -104,7 +107,7 @@ class LiveActivityManager : LiveActivityManagerProxy { return } - let isMmol = unit == HKUnit.millimolesPerLiter + let isMmol = unit == LoopUnit.millimolesPerLiter await self.endUnknownActivities() let statusContext = UserDefaults.appGroup?.statusExtensionContext @@ -173,7 +176,7 @@ class LiveActivityManager : LiveActivityManagerProxy { let yAxisPoints = glucoseSamples.map{ item in item.quantity.doubleValue(for: unit) } + predicatedGlucose let chartYAxis = ChartAxisGenerator.getYAxis( points: yAxisPoints, - isMmol: unit == HKUnit.millimolesPerLiter + isMmol: unit == LoopUnit.millimolesPerLiter ) let state = GlucoseActivityAttributes.ContentState( @@ -308,27 +311,11 @@ class LiveActivityManager : LiveActivityManagerProxy { } private func getInsulinOnBoard() -> String { - let updateGroup = DispatchGroup() - var iob = "??" - - updateGroup.enter() - self.doseStore.insulinOnBoard(at: Date.now) { result in - switch (result) { - case .failure: - break - case .success(let iobValue): - iob = self.iobFormatter.string(from: iobValue.value) ?? "??" - break - } - - updateGroup.leave() - } - - _ = updateGroup.wait(timeout: .distantFuture) - return iob + guard let iob = activeInsulin?.value else { return "??" } + return iobFormatter.string(from: iob) ?? "??" } - private func getGlucoseSample(unit: HKUnit) -> [StoredGlucoseSample] { + private func getGlucoseSample(unit: LoopUnit) -> [StoredGlucoseSample] { let updateGroup = DispatchGroup() var samples: [StoredGlucoseSample] = [] @@ -356,7 +343,7 @@ class LiveActivityManager : LiveActivityManagerProxy { return samples } - private func getGlucoseRanges(glucoseRangeSchedule: GlucoseRangeSchedule, presetContext: Preset?, start: Date, end: Date, unit: HKUnit) -> [GlucoseRangeValue] { + private func getGlucoseRanges(glucoseRangeSchedule: GlucoseRangeSchedule, presetContext: Preset?, start: Date, end: Date, unit: LoopUnit) -> [GlucoseRangeValue] { var glucoseRanges: [GlucoseRangeValue] = [] for item in glucoseRangeSchedule.quantityBetween(start: start, end: end) { let minValue = item.value.lowerBound.doubleValue(for: unit) @@ -515,8 +502,6 @@ extension TemporaryScheduleOverride { return NSLocalizedString("Custom preset", comment: "The title of the cell indicating a generic custom preset is enabled") case .preMeal: return NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)") - case .legacyWorkout: - return "" } } } diff --git a/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift b/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift index df387d0460..92d0899b8a 100644 --- a/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift +++ b/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift @@ -14,6 +14,7 @@ protocol LiveActivityManagerProxy { func update( scheduleOverride: TemporaryScheduleOverride?, preMealOverride: TemporaryScheduleOverride?, - glucoseTargetRangeSchedule: GlucoseRangeSchedule? + glucoseTargetRangeSchedule: GlucoseRangeSchedule?, + activeInsulin: InsulinValue? ) } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 5568786fcf..6ae1949c53 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -580,7 +580,8 @@ final class LoopDataManager: ObservableObject { liveActivityManager?.update( scheduleOverride: temporaryPresetsManager.scheduleOverride, preMealOverride: temporaryPresetsManager.preMealOverride, - glucoseTargetRangeSchedule: settingsProvider.settings.glucoseTargetRangeSchedule + glucoseTargetRangeSchedule: settingsProvider.settings.glucoseTargetRangeSchedule, + activeInsulin: displayState.activeInsulin ) await updateRemoteRecommendation() diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index ad0e457ffe..e76b32fc94 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -64,13 +64,13 @@ struct BolusEntryView: View { // If the recommendation changes, and the user has edited the bolus amount, set the bolus amount to 0 enteredBolusStringBinding.wrappedValue = "0" } - .safeAreaInset(edge: .bottom, spacing: 0) { - if bolusFieldFocused { - // Reserve space so the toolbar doesn’t overlap the field - Color.clear.frame(height: accessoryClearance) - } else { - actionArea - } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if bolusFieldFocused { + // Reserve space so the toolbar doesn't overlap the field + Color.clear.frame(height: accessoryClearance) + } else { + actionArea } } .edgesIgnoringSafeArea(self.bolusFieldFocused ? [] : .bottom) @@ -78,14 +78,14 @@ struct BolusEntryView: View { await self.viewModel.generateRecommendationAndStartObserving() } } - + private var title: Text { if viewModel.potentialCarbEntry == nil { return Text("Bolus", comment: "Title for bolus entry screen") } return Text("Meal Bolus", comment: "Title for bolus entry screen when also entering carbs") } - + private var chartSection: some View { Section { VStack(spacing: 8) { @@ -151,7 +151,7 @@ struct BolusEntryView: View { unit: .gram ) } - + @ViewBuilder private var activeInsulinLabel: some View { LabeledQuantity( @@ -177,7 +177,7 @@ struct BolusEntryView: View { } @State private var expandedPresetSummary: Bool = false - + private var summarySection: some View { Section { VStack(spacing: 16) { @@ -189,18 +189,18 @@ struct BolusEntryView: View { HStack(alignment: .top, spacing: 12) { Text(Image(systemName: "info.circle")) .foregroundStyle(Color.accentColor) - + VStack(alignment: .leading, spacing: 8) { Text("Recommended bolus adjusted due to preset") .frame(maxWidth: .infinity, alignment: .leading) - + if expandedPresetSummary, let differenceString = presetEffectedRecommendation.differenceString, let originalAmountString = presetEffectedRecommendation.originalAmountString { Text("This reflects a \(differenceString) \(presetEffectedRecommendation.direction) from the original \(originalAmountString) due to preset adjustments.") .foregroundStyle(.secondary) } } .font(.subheadline) - + Text(Image(systemName: "chevron.up")) .foregroundStyle(.secondary) .rotationEffect(.degrees(expandedPresetSummary ? 180 : 0)) @@ -209,7 +209,7 @@ struct BolusEntryView: View { expandedPresetSummary.toggle() } } - + if viewModel.isManualGlucoseEntryEnabled { ManualGlucoseEntryRow(quantity: $viewModel.manualGlucoseQuantity) } else if viewModel.potentialCarbEntry != nil { @@ -219,7 +219,7 @@ struct BolusEntryView: View { } } .padding(.top, 8) - + if viewModel.isManualGlucoseEntryEnabled && viewModel.potentialCarbEntry != nil { potentialCarbEntryRow } @@ -231,7 +231,7 @@ struct BolusEntryView: View { bolusEntryRow } } - + private var titleText: Text { return Text("Bolus Summary", comment: "Title for card displaying carb entry and bolus recommendation") } @@ -381,7 +381,7 @@ struct BolusEntryView: View { ) } } - + private var enterManualGlucoseButton: some View { Button( action: { @@ -505,9 +505,9 @@ struct LabeledQuantity: View { var valueText: Text { guard let quantity = quantity else { - return Text(verbatim: "– –") + return Text(verbatim: "- -") } - + let formatter = QuantityFormatter(for: unit) if let maxFractionDigits = maxFractionDigits { From af749a7e9694719252aa99c66e6eb39361765d6c Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 11 Mar 2026 15:44:23 -0500 Subject: [PATCH 379/421] Fix LiveActivityManager: add LoopAlgorithm import, async getGlucoseSamples, @unknown default --- .../Live Activity/LiveActivityManager.swift | 23 ++++++++----------- .../LiveActivityManagerProxy.swift | 1 + 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/Loop/Managers/Live Activity/LiveActivityManager.swift b/Loop/Managers/Live Activity/LiveActivityManager.swift index 163bfa0256..5e7a50e069 100644 --- a/Loop/Managers/Live Activity/LiveActivityManager.swift +++ b/Loop/Managers/Live Activity/LiveActivityManager.swift @@ -9,6 +9,7 @@ import LoopKitUI import LoopKit import LoopCore +import LoopAlgorithm import Foundation import HealthKit import ActivityKit @@ -319,23 +320,15 @@ class LiveActivityManager : LiveActivityManagerProxy { let updateGroup = DispatchGroup() var samples: [StoredGlucoseSample] = [] - updateGroup.enter() - // When in spacious mode, we want to show the predictive line // In compact mode, we only want to show the history let timeInterval: TimeInterval = self.settings.addPredictiveLine ? .hours(-2) : .hours(-6) - self.glucoseStore.getGlucoseSamples( - start: Date.now.addingTimeInterval(timeInterval), - end: Date.now - ) { result in - switch (result) { - case .failure: - break - case .success(let data): - samples = data - break - } - + updateGroup.enter() + Task { + samples = (try? await self.glucoseStore.getGlucoseSamples( + start: Date.now.addingTimeInterval(timeInterval), + end: Date.now + )) ?? [] updateGroup.leave() } @@ -502,6 +495,8 @@ extension TemporaryScheduleOverride { return NSLocalizedString("Custom preset", comment: "The title of the cell indicating a generic custom preset is enabled") case .preMeal: return NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)") + @unknown default: + return "" } } } diff --git a/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift b/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift index 92d0899b8a..ffea337d21 100644 --- a/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift +++ b/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift @@ -7,6 +7,7 @@ // import LoopKit +import LoopAlgorithm protocol LiveActivityManagerProxy { /// Update the live activity with current override and glucose target information. From 1e05d3295100753061db352621b7ba9512600911 Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 11 Mar 2026 17:06:46 -0500 Subject: [PATCH 380/421] =?UTF-8?q?Fix=20ChartAxisGenerator:=20HKUnit?= =?UTF-8?q?=E2=86=92LoopUnit,=20add=20LoopAlgorithm=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Managers/Live Activity/ChartAxisGenerator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/Live Activity/ChartAxisGenerator.swift b/Loop/Managers/Live Activity/ChartAxisGenerator.swift index 0fcc3ca80d..015d80dfb0 100644 --- a/Loop/Managers/Live Activity/ChartAxisGenerator.swift +++ b/Loop/Managers/Live Activity/ChartAxisGenerator.swift @@ -8,6 +8,7 @@ import Foundation import HealthKit +import LoopAlgorithm import SwiftCharts import UIKit @@ -22,7 +23,7 @@ struct ChartAxisGenerator { // This logic is copied/ported from generateYAxisValuesUsingLinearSegmentStep static func getYAxis(points: [Double], isMmol: Bool) -> [Double] { - let unit: HKUnit = isMmol ? .millimolesPerLiter : .milligramsPerDeciliter + let unit: LoopUnit = isMmol ? .millimolesPerLiter : .milligramsPerDeciliter let glucoseDisplayRange = [ range.lowerBound.doubleValue(for: unit), From dc972e1db9c412bb23379b6f3bf8fecae2bc8a87 Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 11 Mar 2026 17:47:43 -0500 Subject: [PATCH 381/421] =?UTF-8?q?Fix=20LoopDataManager:=20add=20missing?= =?UTF-8?q?=20overrideIntentObserver=20declaration,=20fix=20storeDosingDec?= =?UTF-8?q?ision=20callback=E2=86=92async?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Managers/LoopDataManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 6ae1949c53..e6f5f7212f 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -137,6 +137,7 @@ final class LoopDataManager: ObservableObject { // References to registered notification center observers private var notificationObservers: [Any] = [] + private var overrideIntentObserver: NSKeyValueObservation? = nil var activeInsulin: InsulinValue? { displayState.activeInsulin @@ -966,7 +967,7 @@ extension LoopDataManager { predictedGlucose: bolusDosingDecision.predictedGlucose, manualBolusRecommendation: bolusDosingDecision.manualBolusRecommendation, manualBolusRequested: bolusDosingDecision.manualBolusRequested) - dosingDecisionStore.storeDosingDecision(dosingDecision) {} + Task { await dosingDecisionStore.storeDosingDecision(dosingDecision) } } From 398dd287244fc645f6e2964f96e5efa2b233500d Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 11 Mar 2026 21:24:30 -0500 Subject: [PATCH 382/421] Fix LoopUnit/HKUnit conversion in LiveActivityManager and ChartAxisGenerator --- Loop/Managers/Live Activity/ChartAxisGenerator.swift | 2 +- Loop/Managers/Live Activity/LiveActivityManager.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/Live Activity/ChartAxisGenerator.swift b/Loop/Managers/Live Activity/ChartAxisGenerator.swift index 015d80dfb0..536d10daa7 100644 --- a/Loop/Managers/Live Activity/ChartAxisGenerator.swift +++ b/Loop/Managers/Live Activity/ChartAxisGenerator.swift @@ -40,7 +40,7 @@ struct ChartAxisGenerator { return [] } - let maxSegmentCount: Double = glucoseValueBelowSoftBoundsMinimum(first, unit) ? 5 : 4 + let maxSegmentCount: Double = glucoseValueBelowSoftBoundsMinimum(first, unit.hkUnit) ? 5 : 4 guard lastPar >=~ first else {fatalError("Invalid range generating axis values")} let multiple: Double = !isMmol ? (yAxisStepSizeMGDLOverride ?? 25) : 1 diff --git a/Loop/Managers/Live Activity/LiveActivityManager.swift b/Loop/Managers/Live Activity/LiveActivityManager.swift index 5e7a50e069..071bfb6937 100644 --- a/Loop/Managers/Live Activity/LiveActivityManager.swift +++ b/Loop/Managers/Live Activity/LiveActivityManager.swift @@ -103,10 +103,11 @@ class LiveActivityManager : LiveActivityManagerProxy { await endActivity() } - guard let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) else { + guard let hkUnit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) else { print("ERROR: No unit found...") return } + let unit = LoopUnit(from: hkUnit) let isMmol = unit == LoopUnit.millimolesPerLiter await self.endUnknownActivities() From 79eee6d5cbb24915b6101fac476df4b4e0d7a40a Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 11 Mar 2026 23:49:35 -0500 Subject: [PATCH 383/421] Remove removed TherapySettingsViewModel params: sensitivityOverridesEnabled, adultChildInsulinModelSelectionEnabled --- Loop/Views/SettingsView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index fe55ac9ffe..59578c4fcb 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -308,8 +308,6 @@ extension SettingsView { mode: .settings, viewModel: TherapySettingsViewModel( therapySettings: viewModel.therapySettings(), - sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, delegate: viewModel.therapySettingsViewModelDelegate ) ) From 71e06d6ab88396dd7e2d0f069d7bbf254130a583 Mon Sep 17 00:00:00 2001 From: LoopKit Developer Date: Thu, 12 Mar 2026 17:20:25 -0500 Subject: [PATCH 384/421] Fix LoopDataManager init: move stored property assignments before overrideIntentObserver closure Swift requires all stored properties to be initialized before self can be captured in a closure. Move analyticsServicesManager, carbAbsorptionModel, usePositiveMomentumAndRCForManualBoluses, automationHistory, publishedMostRecentGlucoseDataDate, dosingStrategySelectionEnabled, and publishedMostRecentPumpDataDate before the overrideIntentObserver observe() call to satisfy Swift's initialization requirements. --- .../xcshareddata/swiftpm/Package.resolved | 3 +-- Loop/Managers/LoopDataManager.swift | 14 +++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Loop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Loop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3ae53addaa..9cb3736d66 100644 --- a/Loop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Loop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/LoopKit/ZIPFoundation.git", "state" : { - "branch" : "stream-entry", - "revision" : "ad465ee2545392153a64c0976d6e59227d0c1c70" + "revision" : "c67b7509ec82ee2b4b0ab3f97742b94ed9692494" } } ], diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index e6f5f7212f..3d4aa2a474 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -199,6 +199,13 @@ final class LoopDataManager: ObservableObject { self.crashRecoveryManager = crashRecoveryManager self.dosingDecisionStore = dosingDecisionStore self.trustedTimeOffset = trustedTimeOffset + self.analyticsServicesManager = analyticsServicesManager + self.carbAbsorptionModel = carbAbsorptionModel + self.usePositiveMomentumAndRCForManualBoluses = usePositiveMomentumAndRCForManualBoluses + self.automationHistory = UserDefaults.standard.automationHistory + self.publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate + self.dosingStrategySelectionEnabled = dosingStrategySelectionEnabled + self.publishedMostRecentPumpDataDate = mostRecentPumpDataDate if #available(iOS 16.2, *) { self.liveActivityManager = LiveActivityManager( @@ -226,13 +233,6 @@ final class LoopDataManager: ObservableObject { // Remove the override from UserDefaults so we don't set it multiple times appGroup.intentExtensionOverrideToSet = nil }) - self.analyticsServicesManager = analyticsServicesManager - self.carbAbsorptionModel = carbAbsorptionModel - self.usePositiveMomentumAndRCForManualBoluses = usePositiveMomentumAndRCForManualBoluses - self.automationHistory = UserDefaults.standard.automationHistory - self.publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate - self.dosingStrategySelectionEnabled = dosingStrategySelectionEnabled - self.publishedMostRecentPumpDataDate = mostRecentPumpDataDate // Required for device settings in stored dosing decisions From d2ed561a22ed4402db5e482af8e503ed3f982dc3 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 13 Mar 2026 14:07:49 -0300 Subject: [PATCH 385/421] [LOOP-5795] determine activeInsulin in computeSimpleBolusRecommendation (#913) * determine activeInsulin in computeSimpleBolusRecommendation * return nil if no input available --- Loop/Managers/LoopDataManager.swift | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 1b8db7dcc8..3ecf758f98 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -986,8 +986,21 @@ extension LoopDataManager { var dosingDecision = BolusDosingDecision(for: .simpleBolus) - guard let iob = displayState.activeInsulin?.value, - let suspendThreshold = settingsProvider.settings.suspendThreshold?.quantity, + // Determine activeInsulin + let activeInsulin: LoopQuantity + if let iob = displayState.activeInsulin?.value { + activeInsulin = LoopQuantity.init(unit: .internationalUnit, doubleValue: iob) + } else if let input = displayState.input { + let basal = input.basal + let dosesRelativeToBasal: [BasalRelativeDose] = input.doses.annotated(with: basal) + let iob = dosesRelativeToBasal.insulinOnBoard(at: date) + activeInsulin = LoopQuantity.init(unit: .internationalUnit, doubleValue: iob) + } else { + return nil + } + + + guard let suspendThreshold = settingsProvider.settings.suspendThreshold?.quantity, let carbRatioSchedule = temporaryPresetsManager.carbRatioScheduleApplyingOverrideHistory, let correctionRangeSchedule = temporaryPresetsManager.effectiveCorrectionRangeSchedule(presumingMealEntry: mealCarbs != nil), let sensitivitySchedule = temporaryPresetsManager.insulinSensitivityScheduleApplyingOverrideHistory @@ -1018,7 +1031,7 @@ extension LoopDataManager { let bolusAmount = SimpleBolusCalculator.recommendedInsulin( mealCarbs: mealCarbs, manualGlucose: manualGlucose, - activeInsulin: LoopQuantity.init(unit: .internationalUnit, doubleValue: iob), + activeInsulin: activeInsulin, carbRatioSchedule: carbRatioSchedule, correctionRangeSchedule: correctionRangeSchedule, sensitivitySchedule: sensitivitySchedule, From 8edd0449eed36caab0dda8a0e63182d5daa4cf14 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Mon, 16 Mar 2026 09:49:08 -0700 Subject: [PATCH 386/421] [LOOP-5403] Presets Performance History (#914) --- Loop.xcodeproj/project.pbxproj | 8 + Loop/Managers/DeviceDataManager.swift | 7 +- Loop/Models/AutomationHistoryEntry.swift | 14 +- .../StatusTableViewController.swift | 32 +- Loop/View Models/SettingsViewModel.swift | 3 +- .../PresetPerformanceHistoryView.swift | 400 ++++++++++++++++++ Loop/Views/Presets/PresetsHistoryView.swift | 155 ++++--- .../PresetsPerformanceHistoryViewModel.swift | 197 +++++++++ Loop/Views/Presets/PresetsView.swift | 41 +- Loop/Views/SettingsView.swift | 10 +- LoopUI/Extensions/Color.swift | 10 + 11 files changed, 789 insertions(+), 88 deletions(-) create mode 100644 Loop/Views/Presets/PresetPerformanceHistoryView.swift create mode 100644 Loop/Views/Presets/PresetsPerformanceHistoryViewModel.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index ca1e662fb8..b2307b62b6 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -235,6 +235,7 @@ 842E40B12F22F7E2000CCCE0 /* CommonUseStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E409B2F22F7E2000CCCE0 /* CommonUseStep.swift */; }; 842E40B22F22F7E2000CCCE0 /* TherapySettingsExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E409D2F22F7E2000CCCE0 /* TherapySettingsExampleView.swift */; }; 843F32EA2E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */; }; + 8446319F2F5A2AB3003825AE /* PresetsPerformanceHistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8446319E2F5A2AA9003825AE /* PresetsPerformanceHistoryViewModel.swift */; }; 847F23432E4543140035C864 /* ActivePresetBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847F23422E4543140035C864 /* ActivePresetBanner.swift */; }; 849466D02EF1EAD300A90718 /* LocalizablePlural.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4C6D2412EA7E38C006F5755 /* LocalizablePlural.xcstrings */; }; 8496F7312B5711C4003E672C /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8496F7302B5711C4003E672C /* ContentMargin.swift */; }; @@ -245,6 +246,7 @@ 84AA81DB2A4A2973000B658B /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DA2A4A2973000B658B /* Date.swift */; }; 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */; }; 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E62A4A4DEF000B658B /* PumpView.swift */; }; + 84B67A8D2F63558A004C783B /* PresetPerformanceHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B67A8C2F63558A004C783B /* PresetPerformanceHistoryView.swift */; }; 84BC5BDF2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BC5BDE2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift */; }; 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A62D09053A00CB271F /* StatusTableView.swift */; }; 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A82D09800700CB271F /* PresetDetentView.swift */; }; @@ -1119,6 +1121,7 @@ 842E40A32F22F7E2000CCCE0 /* PresetsTrainingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingView.swift; sourceTree = ""; }; 842E40A52F22F7E2000CCCE0 /* PresetsTrainingContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsTrainingContent.swift; sourceTree = ""; }; 843F32E92E2815D400B0B271 /* InsulinDeliveryLogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogViewModel.swift; sourceTree = ""; }; + 8446319E2F5A2AA9003825AE /* PresetsPerformanceHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsPerformanceHistoryViewModel.swift; sourceTree = ""; }; 847F23422E4543140035C864 /* ActivePresetBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivePresetBanner.swift; sourceTree = ""; }; 8496F7302B5711C4003E672C /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; 84A7B54F2D2D972C00B6D202 /* Image+Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Optional.swift"; sourceTree = ""; }; @@ -1128,6 +1131,7 @@ 84AA81DA2A4A2973000B658B /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelineProvider.swift; sourceTree = ""; }; 84AA81E62A4A4DEF000B658B /* PumpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpView.swift; sourceTree = ""; }; + 84B67A8C2F63558A004C783B /* PresetPerformanceHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetPerformanceHistoryView.swift; sourceTree = ""; }; 84BC5BDE2E1C4A7300432000 /* InsulinDeliveryEventDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryEventDetailsView.swift; sourceTree = ""; }; 84D1F1A62D09053A00CB271F /* StatusTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableView.swift; sourceTree = ""; }; 84D1F1A82D09800700CB271F /* PresetDetentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDetentView.swift; sourceTree = ""; }; @@ -2500,6 +2504,8 @@ children = ( C11445B12DA98A2D00034864 /* CorrectionRangeInformationView.swift */, 84FA9D322CF7FD0D004162B4 /* PresetsHistoryView.swift */, + 84B67A8C2F63558A004C783B /* PresetPerformanceHistoryView.swift */, + 8446319E2F5A2AA9003825AE /* PresetsPerformanceHistoryViewModel.swift */, 84E8BBC92CCA16290078E6CF /* PresetsView.swift */, 842E40A62F22F7E2000CCCE0 /* Training */, 84E8BBC22CC9B9780078E6CF /* Components */, @@ -3496,6 +3502,7 @@ 14C970802C5C0A1500E8A01B /* FavoriteFoodInsightsViewModel.swift in Sources */, 149A28BB2A853E5100052EDF /* CarbEntryViewModel.swift in Sources */, C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */, + 8446319F2F5A2AB3003825AE /* PresetsPerformanceHistoryViewModel.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, @@ -3622,6 +3629,7 @@ A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */, E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, + 84B67A8D2F63558A004C783B /* PresetPerformanceHistoryView.swift in Sources */, C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, C10509772D8B591200118A37 /* StatusTableViewModel.swift in Sources */, 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */, diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index ec039968ee..01c447e343 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -166,10 +166,11 @@ final class DeviceDataManager { var doseEnactor = DoseEnactor() // MARK: Stores + let carbStore: CarbStore + let doseStore: DoseStore + let glucoseStore: GlucoseStore + private let healthStore: HKHealthStore - private let carbStore: CarbStore - private let doseStore: DoseStore - private let glucoseStore: GlucoseStore private let cacheStore: PersistenceController private let cgmEventStore: CgmEventStore diff --git a/Loop/Models/AutomationHistoryEntry.swift b/Loop/Models/AutomationHistoryEntry.swift index c459d674d6..4a989a67e8 100644 --- a/Loop/Models/AutomationHistoryEntry.swift +++ b/Loop/Models/AutomationHistoryEntry.swift @@ -9,7 +9,7 @@ import Foundation import LoopAlgorithm -struct AutomationHistoryEntry: Codable, Hashable { +public struct AutomationHistoryEntry: Codable, Hashable { var startDate: Date var enabled: Bool } @@ -26,8 +26,9 @@ extension Array where Element == AutomationHistoryEntry { var prev = iter.next()! - func addItem(start: Date, end: Date, enabled: Bool) { - out.append(AbsoluteScheduleValue(startDate: start, endDate: end, value: enabled)) + if prev.startDate > start { + let gapEnd = Swift.min(prev.startDate, end) + out.append(AbsoluteScheduleValue(startDate: start, endDate: gapEnd, value: !prev.enabled)) } while let cur = iter.next() { @@ -35,13 +36,16 @@ extension Array where Element == AutomationHistoryEntry { continue } if cur.startDate > start { - addItem(start: Swift.max(prev.startDate, start), end: Swift.min(cur.startDate, end), enabled: prev.enabled) + let segmentStart = Swift.max(prev.startDate, start) + let segmentEnd = Swift.min(cur.startDate, end) + out.append(AbsoluteScheduleValue(startDate: segmentStart, endDate: segmentEnd, value: prev.enabled)) } prev = cur } if prev.startDate < end { - addItem(start: prev.startDate, end: end, enabled: prev.enabled) + let segmentStart = Swift.max(prev.startDate, start) + out.append(AbsoluteScheduleValue(startDate: segmentStart, endDate: end, value: prev.enabled)) } return out diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 688c5ed040..08384b0663 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1532,17 +1532,23 @@ final class StatusTableViewController: LoopChartsTableViewController { func presentPresets() { let hostingController = DismissibleHostingController( - rootView: PresetsView(roundBasalRate: deviceManager.roundBasalRate) - .onAppear { self.isShowingPresets = true } - .onDisappear { self.isShowingPresets = false } - .environmentObject(deviceManager.displayGlucosePreference) - .environment(\.appName, Bundle.main.bundleDisplayName) - .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) - .environment(\.colorPalette, .default) - .environment(\.loopStatusColorPalette, .loopStatus) - .environment(\.temporaryPresetsManager, temporaryPresetsManager) - .environment(\.settingsManager, settingsManager), - isModalInPresentation: false) + rootView: PresetsView( + roundBasalRate: deviceManager.roundBasalRate, + carbStore: deviceManager.carbStore, + doseStore: deviceManager.doseStore, + glucoseStore: deviceManager.glucoseStore, + automationHistory: { [weak self] in self?.loopManager.automationHistory ?? [] } + ) + .onAppear { self.isShowingPresets = true } + .onDisappear { self.isShowingPresets = false } + .environmentObject(deviceManager.displayGlucosePreference) + .environment(\.appName, Bundle.main.bundleDisplayName) + .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) + .environment(\.colorPalette, .default) + .environment(\.loopStatusColorPalette, .loopStatus) + .environment(\.temporaryPresetsManager, temporaryPresetsManager) + .environment(\.settingsManager, settingsManager), + isModalInPresentation: false) present(hostingController, animated: true) } @@ -2137,6 +2143,10 @@ extension StatusTableViewController: SettingsViewModelDelegate { var closedLoopDescriptiveText: String? { return deviceManager.closedLoopDisallowedLocalizedDescription } + + var automationHistory: [AutomationHistoryEntry] { + loopManager.automationHistory + } func dosingEnabledChanged(_ value: Bool) { settingsManager.mutateLoopSettings { settings in diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 0b46636cd2..24629b16da 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -55,6 +55,7 @@ public protocol SettingsViewModelDelegate: AnyObject { func didTapIssueReport() var closedLoopDescriptiveText: String? { get } var automaticDosingEnabled: Bool { get set } + var automationHistory: [AutomationHistoryEntry] { get } } @Observable @@ -113,7 +114,7 @@ class SettingsViewModel { } } - private var deviceManager: DeviceDataManager? + private(set) var deviceManager: DeviceDataManager? @MainActor var deviceIssue: Bool { diff --git a/Loop/Views/Presets/PresetPerformanceHistoryView.swift b/Loop/Views/Presets/PresetPerformanceHistoryView.swift new file mode 100644 index 0000000000..7c3475349f --- /dev/null +++ b/Loop/Views/Presets/PresetPerformanceHistoryView.swift @@ -0,0 +1,400 @@ +// +// PresetPerformanceHistoryView.swift +// Loop +// +// Created by Cameron Ingham on 3/12/26. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit +import LoopKitUI +import SwiftUI + +struct PresetPerformanceHistoryView: View { + + private enum DateRange: Hashable { + case preset + case presetPlus6Hours + + var localizedTitle: String { + switch self { + case .preset: return NSLocalizedString("During Preset", comment: "") + case .presetPlus6Hours: return NSLocalizedString("Preset +6 Hours", comment: "") + } + } + + static func allCases(allowPlus6Hours: Bool) -> [DateRange] { + if allowPlus6Hours { + return [.preset, .presetPlus6Hours] + } else { + return [.preset] + } + } + } + + private enum DataState { + case loading + case loaded(PresetsPerformanceHistoryViewModel.PerformanceData, plus6Hours: PresetsPerformanceHistoryViewModel.PerformanceData) + } + + + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + + @Environment(\.colorPalette) private var colorPalette + @Environment(\.settingsManager) private var settingsManager + + @State private var state: DataState = .loading + @State private var selectedDateRange: DateRange = .preset + + private let insulinFormatter = QuantityFormatter(for: .internationalUnit) + private let carbFormatter = QuantityFormatter(for: .gram) + + let preset: SelectablePreset + let override: TemporaryScheduleOverride + let presetsPerformanceHistoryViewModel: PresetsPerformanceHistoryViewModel + + private var show6hrData: Bool { + guard !override.isActive() else { + return false + } + + return override.actualEndDate.addingTimeInterval(.hours(6)) <= Date() + } + + private var title: some View { + HStack(spacing: 4) { + if let icon = preset.icon, !icon.isEmpty { + PresetSymbolView(icon, iconSize: UIFontMetrics.default.scaledValue(for: 28)) + } + + Text(preset.name) + .fontWeight(.bold) + } + .font(.largeTitle) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + title + + switch state { + case .loading: + ActivityIndicator(isAnimating: .constant(true), style: .medium) + .frame(maxWidth: .infinity) + case .loaded(let data, let dataPlus6Hours): + VStack(spacing: 16) { + if data.minimalData { + minimalDataSection + } + + dateAndSettingsSection(performanceData: data) + + Picker("", selection: $selectedDateRange) { + ForEach(DateRange.allCases(allowPlus6Hours: true), id: \.self) { option in + Text(option.localizedTitle) + } + } + .pickerStyle(SegmentedPickerStyle()) + + detailsSection( + performanceData: selectedDateRange == .preset ? data : dataPlus6Hours, + showNoData: selectedDateRange == .presetPlus6Hours && !show6hrData + ) + } + } + } + .padding() + } + .animation(.default, value: selectedDateRange) + .background(Color(UIColor.secondarySystemBackground)) + .task { + await fetch() + } + } + + private var minimalDataSection: some View { + GroupBox { + HStack(spacing: 8) { + Image(systemName: "info.circle") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + + Text("This summary is based on a preset with less than 30 minutes of CGM readings.") + .font(.subheadline) + } + .foregroundStyle(Color.accentColor) + } + .backgroundStyle(Color(UIColor.systemBackground)) + } + + private func dateAndSettingsSection(performanceData: PresetsPerformanceHistoryViewModel.PerformanceData) -> some View { + GroupBox { + VStack(alignment: .leading, spacing: 12) { + if override.isActive(), let expectedEndTime = override.expectedEndTime { + HStack(spacing: 8) { + Text(Image(systemName: "timer")) + + + Text(" \(expectedEndTime.localizedTitle)") + .accessibilityLabel(Text(expectedEndTime.accessibilityLabel)) + } + .font(.footnote) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 5) + .background(Color(colorPalette.chartColorPalette.presetTint)) + .cornerRadius(8) + } else { + Text(performanceData.dateRange()) + .fontWeight(.semibold) + } + + Divider() + + PresetStatsView( + insulinMultiplier: performanceData.overallInsulin, + correctionRange: performanceData.correctionRange, + guardrail: settingsManager.correctionRangeGuardrailForPreset(preset), + therapySettingsImpactDisplayState: .hide, + isScheduled: false, // Not needed for hidden impact + isActive: false, // Not needed for hidden impact + effectiveCorrectionRange: { nil } // Not needed for hidden impact + ) + } + } + .backgroundStyle(Color(UIColor.systemBackground)) + } + + private func detailsSection(performanceData: PresetsPerformanceHistoryViewModel.PerformanceData, showNoData: Bool) -> some View { + GroupBox { + if showNoData { + Image("performance-history-empty") + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .padding(20) + .background(Color(UIColor.systemBackground).clipShape(Circle())) + + VStack(spacing: 4) { + Text("No performance history available yet") + .multilineTextAlignment(.center) + + Text("You can see this summary 6 hours after the preset ends.") + .multilineTextAlignment(.center) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } else { + VStack(alignment: .leading, spacing: 24) { + Text(performanceData.dateRange(overrideEndDate: override.isActive() ? Date() : nil)) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + + VStack(alignment: .leading, spacing: 8) { + Text("Glucose Summary") + .fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 4) { + if let startingGlucose = performanceData.startingGlucose { + LabeledContent("Starting Glucose") { + Group { Text(displayGlucosePreference.format(startingGlucose, includeUnit: false)).fontWeight(.semibold).foregroundStyle(.primary) + Text(" ") + Text(displayGlucosePreference.unit.localizedShortUnitString).foregroundStyle(.secondary) }.contentTransition(.numericText()) + } + } + + LabeledContent("Average Glucose") { + Group { Text(displayGlucosePreference.format(performanceData.averageGlucose, includeUnit: false)).fontWeight(.semibold).foregroundStyle(.primary) + Text(" ") + Text(displayGlucosePreference.unit.localizedShortUnitString).foregroundStyle(.secondary) }.contentTransition(.numericText()) + } + } + } + + HStack(spacing: 24) { + StackedBarView( + segments: [ + .init(color: .glucoseVeryHigh, fraction: performanceData.timeInRange[.veryHigh] ?? 0), + .init(color: .glucoseHigh, fraction: performanceData.timeInRange[.high] ?? 0), + .init(color: .glucoseNormal, fraction: performanceData.timeInRange[.normal] ?? 0), + .init(color: .glucoseLow, fraction: performanceData.timeInRange[.low] ?? 0), + .init(color: .glucoseVeryLow, fraction: performanceData.timeInRange[.veryLow] ?? 0), + ] + ) + .frame(maxHeight: .infinity) + .accessibilityHidden(true) + + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 24) { + GridRow { + Group { Text(String(format: "%.0f", (performanceData.timeInRange[.veryHigh] ?? 0) * 100)).font(.title2).bold().fontDesign(.monospaced) + Text(" %").font(.footnote) } + .foregroundStyle(Color.glucoseVeryHigh) + .contentTransition(.numericText()) + + Text("Very High").font(.subheadline) + Text(" ") + Text(">\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 250), includeUnit: true))").font(.caption).foregroundStyle(.secondary) + } + + GridRow { + Group { Text(String(format: "%.0f", (performanceData.timeInRange[.high] ?? 0) * 100)).font(.title2).bold().fontDesign(.monospaced) + Text(" %").font(.footnote) } + .foregroundStyle(Color.glucoseHigh) + .contentTransition(.numericText()) + + Text("High").font(.subheadline) + Text(" ") + Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 181), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 250), includeUnit: true))").font(.caption).foregroundStyle(.secondary) + } + + GridRow { + Group { Text(String(format: "%.0f", (performanceData.timeInRange[.normal] ?? 0) * 100)).font(.title2).bold().fontDesign(.monospaced) + Text(" %").font(.footnote) } + .foregroundStyle(Color.glucoseNormal) + .contentTransition(.numericText()) + + Text("Target").font(.subheadline).fontWeight(.semibold) + Text(" ") + Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 70), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), includeUnit: true))").font(.caption).foregroundStyle(.secondary) + } + + GridRow { + Group { Text(String(format: "%.0f", (performanceData.timeInRange[.low] ?? 0) * 100)).font(.title2).bold().fontDesign(.monospaced) + Text(" %").font(.footnote) } + .foregroundStyle(Color.glucoseLow) + .contentTransition(.numericText()) + + Text("Low").font(.subheadline) + Text(" ") + Text("\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 54), includeUnit: false))-\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 69), includeUnit: true))").font(.caption).foregroundStyle(.secondary) + } + + GridRow { + Group { Text(String(format: "%.0f", (performanceData.timeInRange[.veryLow] ?? 0) * 100)).font(.title2).bold().fontDesign(.monospaced) + Text(" %").font(.footnote) } + .foregroundStyle(Color.glucoseVeryLow) + .contentTransition(.numericText()) + + Text("Very Low").font(.subheadline) + Text(" ") + Text("<\(displayGlucosePreference.format(LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 54), includeUnit: true))").font(.caption).foregroundStyle(.secondary) + } + } + } + .frame(minHeight: 240) + .frame(maxWidth: .infinity) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Overview") + .fontWeight(.semibold) + + Grid(horizontalSpacing: 16) { + GridRow(alignment: .top) { + if let carbString = carbFormatter.string(from: performanceData.totalCarbs, includeUnit: false) { + VStack(spacing: 8) { + Image("carbs") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundStyle(Color.carbs) + + VStack { + Group { + Text(carbString).fontWeight(.semibold) + Text(" \(LoopUnit.gram.localizedShortUnitString)").font(.footnote) + } + .foregroundStyle(Color.carbs) + .contentTransition(.numericText()) + + Text("Total\nCarbs") + .multilineTextAlignment(.center) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + } + + if let bolusString = insulinFormatter.string(from: performanceData.totalBolus, includeUnit: false) { + VStack(spacing: 8) { + Image("bolus") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundStyle(Color.insulin) + + VStack { + Group { + Text(bolusString).fontWeight(.semibold) + Text(" \(LoopUnit.internationalUnit.localizedShortUnitString)").font(.footnote) + } + .foregroundStyle(Color.insulin) + .contentTransition(.numericText()) + + Text("Total\nBolus") + .multilineTextAlignment(.center) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + } + + VStack(spacing: 8) { + Image("automation-on-delivery-log") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + + VStack { + Group { + Text(String(format: "%.0f", performanceData.timeInAutomation * 100)).fontWeight(.semibold) + Text(" %").font(.footnote) + } + .contentTransition(.numericText()) + + Text("Time in\nAutomation") + .multilineTextAlignment(.center) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + } + } + } + } + } + } + .backgroundStyle(Color(UIColor.systemBackground)) + } + + private func fetch() async { + async let data = try? await presetsPerformanceHistoryViewModel.fetchData(from: override, add6Hours: false) + async let dataPlus6Hours = try? await presetsPerformanceHistoryViewModel.fetchData(from: override, add6Hours: true) + + let combined = await (data, dataPlus6Hours) + + if let data = combined.0, let dataPlus6Hours = combined.1 { + self.state = .loaded(data, plus6Hours: dataPlus6Hours) + } + } +} + +struct StackedBarView: View { + struct Segment { + let color: Color + let fraction: Double + } + + let segments: [Segment] + let cornerRadius: CGFloat = 8 + let width: CGFloat = 40 + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .top) { + ForEach(Array(segments.enumerated()), id: \.offset) { index, segment in + segment.color + .frame(height: heightFrom(index: index, totalHeight: geo.size.height)) + .frame(maxWidth: .infinity) + .offset(y: offsetFor(index: index, totalHeight: geo.size.height)) + } + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } + .frame(width: width) + } + + private func offsetFor(index: Int, totalHeight: CGFloat) -> CGFloat { + segments[0.. CGFloat { + segments[index...].reduce(0.0) { $0 + $1.fraction } * totalHeight + } +} diff --git a/Loop/Views/Presets/PresetsHistoryView.swift b/Loop/Views/Presets/PresetsHistoryView.swift index 6c0531dc68..4d55c29b41 100644 --- a/Loop/Views/Presets/PresetsHistoryView.swift +++ b/Loop/Views/Presets/PresetsHistoryView.swift @@ -6,6 +6,7 @@ // Copyright © 2024 LoopKit Authors. All rights reserved. // +import LoopAlgorithm import LoopKit import LoopKitUI import SwiftUI @@ -14,6 +15,24 @@ struct PresetsHistoryView: View { @Environment(\.colorPalette) private var colorPalette @Environment(\.settingsManager) private var settingsManager @Environment(\.temporaryPresetsManager) private var temporaryPresetsManager + + let presetsPerformanceHistoryViewModel: PresetsPerformanceHistoryViewModel + + init( + temporaryPresetsManager: TemporaryPresetsManager, + glucoseStore: GlucoseStoreProtocol, + carbStore: CarbStoreProtocol, + doseStore: DoseStoreProtocol, + automationHistory: @escaping () -> [AutomationHistoryEntry] + ) { + presetsPerformanceHistoryViewModel = PresetsPerformanceHistoryViewModel( + temporaryPresetsManager: temporaryPresetsManager, + glucoseStore: glucoseStore, + carbStore: carbStore, + doseStore: doseStore, + automationHistory: automationHistory + ) + } let formatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -22,44 +41,93 @@ struct PresetsHistoryView: View { return formatter }() - var overridesByDate: Dictionary { + let now = Date() + + var overrides: Dictionary { Dictionary( grouping: temporaryPresetsManager.presetHistory.recentEvents .map(\.override) - .filter({ !$0.isActive() }) - .sorted(by: { $0.actualEndDate > $1.actualEndDate }) + .filter({ $0.actualEndDate > now.addingTimeInterval(.days(-7)) }) + .sorted(by: { $0.startDate > $1.startDate }) ) { override in - Calendar.current.startOfDay(for: override.startDate) + override.isActive() || override.actualEndDate > now.addingTimeInterval(.days(-1)) } } var body: some View { - List { - ForEach(Array(overridesByDate.keys.sorted(by: >)), id: \.self) { date in - Section(date.formatted(date: .abbreviated, time: .omitted)) { - ForEach(overridesByDate[date] ?? [], id: \.self) { override in - LabeledContent { - VStack(alignment: .trailing, spacing: 8) { - Text("Duration") - .font(.footnote) - .foregroundStyle(.secondary) - - durationText(for: override) - } - } label: { - VStack(alignment: .leading, spacing: 8) { - Text(override.startDate.formatted(date: .omitted, time: .shortened)) - .font(.footnote) - .foregroundStyle(.secondary) - + Group { + if overrides.values.flatMap({ $0 }).isEmpty { + ZStack { + Color(UIColor.secondarySystemBackground) + .ignoresSafeArea(edges: .all) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + VStack(spacing: 16) { + Spacer() + + Image("performance-history-empty") + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .padding(20) + .background(Color(UIColor.systemBackground).clipShape(Circle())) + + VStack(spacing: 4) { + Text("No performance history available yet") + .multilineTextAlignment(.center) + + Text("To see how presets can support you, review the training.") + .multilineTextAlignment(.center) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(.horizontal, 32) + } + } else { + List { + ForEach(Array(overrides.keys).sorted { $0 && !$1 }, id: \.self) { isLast24Hrs in + Section(isLast24Hrs ? NSLocalizedString("LAST 24 HOURS", comment: "Preset Performance History, Last 24 hrs, Section title") : NSLocalizedString("LAST 7 DAYS", comment: "Preset Performance History, Last 7 days, Section title")) { + ForEach(overrides[isLast24Hrs] ?? [], id: \.self) { override in if let preset = temporaryPresetsManager.selectablePresets.first(where: { $0.id == override.presetId }) { - HStack(spacing: 4) { - if let icon = preset.icon, !icon.isEmpty { - PresetSymbolView(icon) + NavigationLink { + PresetPerformanceHistoryView( + preset: preset, + override: override, + presetsPerformanceHistoryViewModel: presetsPerformanceHistoryViewModel + ) + } label: { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + if let icon = preset.icon, !icon.isEmpty { + PresetSymbolView(icon) + } + + Text(preset.name) + .fontWeight(.semibold) + } + + if override.isActive(), let expectedEndTime = override.expectedEndTime { + HStack(spacing: 8) { + Text(Image(systemName: "timer")) + + + Text(" \(expectedEndTime.localizedTitle)") + .accessibilityLabel(Text(expectedEndTime.accessibilityLabel)) + } + .font(.footnote) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 5) + .background(Color(colorPalette.chartColorPalette.presetTint)) + .cornerRadius(8) + } else { + Text(PresetsPerformanceHistoryViewModel.dateRange(from: override.startDate, to: override.actualEndDate)) + .font(.subheadline) + .foregroundStyle(.secondary) + } } - - Text(preset.name) - .fontWeight(.semibold) } } } @@ -68,35 +136,6 @@ struct PresetsHistoryView: View { } } } - .navigationTitle("Recent Events") - } - - @ViewBuilder - func durationText(for override: TemporaryScheduleOverride) -> some View { - switch override.duration { - case let .finite(scheduledDuration): - let actualDuration = override.actualDuration.timeInterval - if let scheduledDurationString = formatter.string(from: scheduledDuration), let actualDurationString = formatter.string(from: actualDuration) { - if scheduledDuration <= actualDuration { - Text(actualDurationString) - .foregroundStyle(.primary) - } else { - Text(actualDurationString) - .foregroundStyle(.primary) - .fontWeight(.semibold) - + Text(" / ") - + Text(scheduledDurationString) - } - } - case .indefinite: - let actualDuration = override.actualDuration.timeInterval - if actualDuration != .infinity, - let durationString = formatter.string(from: actualDuration) - { - Text(durationString) - .foregroundStyle(.primary) - .fontWeight(.semibold) - } - } + .navigationTitle("Performance History") } } diff --git a/Loop/Views/Presets/PresetsPerformanceHistoryViewModel.swift b/Loop/Views/Presets/PresetsPerformanceHistoryViewModel.swift new file mode 100644 index 0000000000..e7bdb8174c --- /dev/null +++ b/Loop/Views/Presets/PresetsPerformanceHistoryViewModel.swift @@ -0,0 +1,197 @@ +// +// PresetsPerformanceHistoryViewModel.swift +// Loop +// +// Created by Cameron Ingham on 3/5/26. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import LoopAlgorithm +import LoopKit + +@MainActor +@Observable +class PresetsPerformanceHistoryViewModel { + private let temporaryPresetsManager: TemporaryPresetsManager + private let glucoseStore: GlucoseStoreProtocol + private let carbStore: CarbStoreProtocol + private let doseStore: DoseStoreProtocol + private let automationHistory: () -> [AutomationHistoryEntry] + + private static let calendar = Calendar.current + + private static var timeFormatter: DateFormatter = { + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "h:mm a" + return timeFormatter + }() + + private static var dayFormatter: DateFormatter = { + let dayFormatter = DateFormatter() + dayFormatter.dateFormat = "EEE M/d" + return dayFormatter + }() + + init(temporaryPresetsManager: TemporaryPresetsManager, glucoseStore: GlucoseStoreProtocol, carbStore: CarbStoreProtocol, doseStore: DoseStoreProtocol, automationHistory: @escaping () -> [AutomationHistoryEntry]) { + self.temporaryPresetsManager = temporaryPresetsManager + self.glucoseStore = glucoseStore + self.carbStore = carbStore + self.doseStore = doseStore + self.automationHistory = automationHistory + } + + func fetchData(from override: TemporaryScheduleOverride, add6Hours: Bool) async throws -> PerformanceData { + let overallInsulin = override.settings.effectiveInsulinNeedsScaleFactor + let correctionRange = override.settings.targetRange + let startDate = override.startDate + let endDate = override.actualEndDate + + let calculatedEndDate = add6Hours ? endDate.addingTimeInterval(.hours(6)) : endDate + + let allGlucoseValues = try await glucoseStore.getGlucoseSamples(start: startDate.addingTimeInterval(.minutes(-5)), end: calculatedEndDate) + + let totalCarbs = LoopQuantity( + unit: .gram, + doubleValue: try await carbStore.getCarbEntries(start: startDate, end: calculatedEndDate) + .map({ $0.quantity.doubleValue(for: .gram) }) + .reduce(0, +) + ) + + let totalBolus = LoopQuantity( + unit: .internationalUnit, + doubleValue: try await ((doseStore as? DoseStore)?.insulinDeliveryStore.getBoluses(start: startDate, end: calculatedEndDate) ?? []) + .map({ $0.deliveredUnits ?? 0 }) + .reduce(0, +) + ) + + let timeInAutomation = automationHistory() + .toTimeline(from: startDate, to: calculatedEndDate) + .percentageTrue(from: startDate, to: calculatedEndDate) + + return PerformanceData( + overallInsulin: overallInsulin, + correctionRange: correctionRange, + startDate: startDate, + endDate: calculatedEndDate, + allGlucoseValues: allGlucoseValues, + totalCarbs: totalCarbs, + totalBolus: totalBolus, + timeInAutomation: timeInAutomation + ) + } + + struct PerformanceData { + enum GlucoseRange { + case veryLow, low, normal, high, veryHigh + } + + func classifyValue(_ value: Double) -> GlucoseRange { + switch value { + case ..<54: return .veryLow + case ..<70: return .low + case ..<181: return .normal + case ..<251: return .high + default: return .veryHigh + } + } + + let overallInsulin: Double + let correctionRange: ClosedRange? + let startDate: Date + let endDate: Date + let allGlucoseValues: [StoredGlucoseSample] + let totalCarbs: LoopQuantity + let totalBolus: LoopQuantity + let timeInAutomation: Double + + var startingGlucose: LoopQuantity? { + allGlucoseValues.map(\.quantity).first + } + + var averageGlucose: LoopQuantity { + LoopQuantity( + unit: .milligramsPerDeciliter, + doubleValue: ( + allGlucoseValues + .dropFirst() + .map({ $0.quantity.doubleValue(for: .milligramsPerDeciliter) }) + .reduce(0, +) / Double(allGlucoseValues.count) + ) + ) + } + + var timeInRange: [GlucoseRange: Double] { + guard allGlucoseValues.dropFirst().count > 0 else { return [:] } + + let sorted = allGlucoseValues.dropFirst().sorted { $0.startDate < $1.startDate } + var durations: [GlucoseRange: TimeInterval] = [:] + + for (index, sample) in sorted.enumerated() { + let nextDate = index + 1 < sorted.count ? sorted[index + 1].startDate : endDate + let duration = nextDate.timeIntervalSince(sample.startDate) + let range = classifyValue(sample.quantity.doubleValue(for: .milligramsPerDeciliter)) + durations[range, default: 0] += duration + } + + let total = durations.values.reduce(0, +) + return durations.mapValues { $0 / total } + } + + @MainActor + func dateRange(overrideEndDate: Date? = nil) -> String { + PresetsPerformanceHistoryViewModel.dateRange(from: startDate, to: overrideEndDate ?? endDate) + } + + var minimalData: Bool { + let sorted = allGlucoseValues.sorted(by: { $0.startDate < $1.startDate }) + guard let first = sorted.first, let last = sorted.last, first != last else { + return true + } + + return abs(first.startDate.timeIntervalSince(last.startDate)) < .minutes(30) + } + } + + static func dateRange(from startDate: Date, to endDate: Date) -> String { + let startIsToday = calendar.isDateInToday(startDate) + let endIsToday = calendar.isDateInToday(endDate) + let sameDay = calendar.isDate(startDate, inSameDayAs: endDate) + + let startTime = timeFormatter.string(from: startDate) + let endTime = timeFormatter.string(from: endDate) + + if startIsToday && endIsToday { + // Today: "Today, 1:32 PM - 2:32 PM" + return String(format: NSLocalizedString("Today, %1$@ - %2$@", comment: "The format string for the same day date range (1: start date)(2: end date)"), startTime, endTime) + + } else if sameDay { + // Single Day: "Sun 5/21, 1:32 PM - 2:32 PM" + return "\(dayFormatter.string(from: startDate)), \(startTime) - \(endTime)" + + } else { + // Multi Day: "Sun 5/21 2:05 PM – Mon 5/22 4:45 PM" + let startDay = dayFormatter.string(from: startDate) + let endDay = dayFormatter.string(from: endDate) + return "\(startDay) \(startTime) – \(endDay) \(endTime)" + } + } +} + +private extension [AbsoluteScheduleValue] { + func percentageTrue(from windowStart: Date, to windowEnd: Date) -> Double { + let windowDuration = windowEnd.timeIntervalSince(windowStart) + guard windowDuration > 0 else { return 0 } + + var totalTrueTime: TimeInterval = 0 + for entry in self { + let overlapStart = Swift.max(entry.startDate, windowStart) + let overlapEnd = Swift.min(entry.endDate, windowEnd) + let overlap = Swift.max(0, overlapEnd.timeIntervalSince(overlapStart)) + if entry.value { + totalTrueTime += overlap + } + } + + return totalTrueTime / windowDuration + } +} diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 02ca48ac68..683f2de52f 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -68,15 +68,28 @@ struct PresetsView: View { @State private var showPresetsTrainingSheet: Bool = false @State private var activeSheet: ActiveSheet? @State private var navigationPath = NavigationPath() + + private let carbStore: CarbStore + private let doseStore: DoseStore + private let glucoseStore: GlucoseStore + private let automationHistory: () -> [AutomationHistoryEntry] @AppStorage("presetsSortAscending") private var presetsSortAscending: Bool = true @AppStorage("presetsSortOrder") private var selectedSortOption: PresetSortOption = .name init( - roundBasalRate: ((Double) -> Double)? + roundBasalRate: ((Double) -> Double)?, + carbStore: CarbStore, + doseStore: DoseStore, + glucoseStore: GlucoseStore, + automationHistory: @escaping () -> [AutomationHistoryEntry] ) { self.trainingCompletion = PresetsTrainingCompletion(allowDebugFeatures: FeatureFlags.allowDebugFeatures) self.roundBasalRate = roundBasalRate + self.carbStore = carbStore + self.doseStore = doseStore + self.glucoseStore = glucoseStore + self.automationHistory = automationHistory } var isDescending: Bool { !presetsSortAscending } @@ -179,13 +192,12 @@ struct PresetsView: View { NavigationLink(value: NavigationDestination.presetsHistory) { HStack { - Image(systemName: "list.bullet") - .foregroundColor(.white) - .padding(8) - .background(Color.presets) - .cornerRadius(8) + Image("performance-history-empty") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) - Text("Presets Performance History") + Text("Performance History") Spacer() Image(systemName: "chevron.right") .foregroundColor(.gray) @@ -203,7 +215,12 @@ struct PresetsView: View { activeSheet = .training() } label: { HStack { - Text("Review Presets Training") + Image("book") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + + Text("Learning Hub") Spacer() Image(systemName: "chevron.right") .foregroundColor(.gray) @@ -227,7 +244,13 @@ struct PresetsView: View { .navigationDestination(for: NavigationDestination.self) { route in switch route { case .presetsHistory: - PresetsHistoryView() + PresetsHistoryView( + temporaryPresetsManager: temporaryPresetsManager, + glucoseStore: glucoseStore, + carbStore: carbStore, + doseStore: doseStore, + automationHistory: automationHistory + ) } } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 8fdfa846e0..446724de7f 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -141,7 +141,15 @@ struct SettingsView: View { Group { switch sheet { case .presets: - PresetsView(roundBasalRate: viewModel.deliveryDelegate?.roundBasalRate) + if let carbStore = viewModel.deviceManager?.carbStore, let doseStore = viewModel.deviceManager?.doseStore, let glucoseStore = viewModel.deviceManager?.glucoseStore { + PresetsView( + roundBasalRate: viewModel.deliveryDelegate?.roundBasalRate, + carbStore: carbStore, + doseStore: doseStore, + glucoseStore: glucoseStore, + automationHistory: { viewModel.delegate?.automationHistory ?? [] } + ) + } case .favoriteFoods: FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) } diff --git a/LoopUI/Extensions/Color.swift b/LoopUI/Extensions/Color.swift index 189e4baa06..dbc6bd1fdd 100644 --- a/LoopUI/Extensions/Color.swift +++ b/LoopUI/Extensions/Color.swift @@ -24,6 +24,16 @@ extension Color { public static let loopAccent = Color("accent") public static let warning = Color("warning") + + public static let glucoseVeryHigh = Color("glucose-very-high") + + public static let glucoseHigh = Color("glucose-high") + + public static let glucoseNormal = Color("glucose-normal") + + public static let glucoseLow = Color("glucose-low") + + public static let glucoseVeryLow = Color("glucose-very-low") } From 26dabaadfbb2cdf6b574027c2633b488602e2497 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 17 Mar 2026 05:35:41 -0300 Subject: [PATCH 387/421] [LOOP-5639] when training is not completed, color is gray and tapping displays warning modal (#915) --- Loop/Views/Presets/PresetsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index 683f2de52f..a66836d11c 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -157,7 +157,7 @@ struct PresetsView: View { }) { Image(systemName: "plus") } - .disabled(!trainingCompletion.isComplete) + .foregroundStyle(trainingCompletion.isComplete ? Color.accentColor : Color.secondary) } .padding(.horizontal, 10) From ca464a59c7b41f71834acb4b9b201f2f2183a527 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 20 Mar 2026 14:17:45 -0700 Subject: [PATCH 388/421] [LOOP-5813] Media Player (#917) --- Loop.xcodeproj/project.pbxproj | 42 ++- .../Contents.json | 12 + .../performance-history-empty.pdf | Bin 0 -> 8107 bytes Loop/Extensions/Double+Closest.swift | 25 ++ Loop/Extensions/Image+Crop.swift | 30 ++ .../StatusTableViewController.swift | 1 + .../Presets/Media Player/AudioPlayer.swift | 90 +++++ .../Presets/Media Player/CaptionsView.swift | 31 ++ .../Media Player/MediaPlayerView.swift | 203 ++++++++++ .../PlayMediaButton.swift | 20 +- .../Presets/Media Player/PlayerControls.swift | 353 ++++++++++++++++++ .../Presets/Media Player/TranscriptView.swift | 119 ++++++ .../Presets/Media Player/VideoView.swift | 93 +++++ Loop/Views/Presets/PresetsView.swift | 15 +- .../Training/PresetsTrainingContent.swift | 84 ++--- .../Training/PresetsTrainingView.swift | 15 +- Loop/Views/SettingsView.swift | 1 + LoopTests/Managers/SupportManagerTests.swift | 2 + 18 files changed, 1083 insertions(+), 53 deletions(-) create mode 100644 Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/Contents.json create mode 100644 Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/performance-history-empty.pdf create mode 100644 Loop/Extensions/Double+Closest.swift create mode 100644 Loop/Extensions/Image+Crop.swift create mode 100644 Loop/Views/Presets/Media Player/AudioPlayer.swift create mode 100644 Loop/Views/Presets/Media Player/CaptionsView.swift create mode 100644 Loop/Views/Presets/Media Player/MediaPlayerView.swift rename Loop/Views/Presets/{Training/Components => Media Player}/PlayMediaButton.swift (78%) create mode 100644 Loop/Views/Presets/Media Player/PlayerControls.swift create mode 100644 Loop/Views/Presets/Media Player/TranscriptView.swift create mode 100644 Loop/Views/Presets/Media Player/VideoView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index b2307b62b6..89625f11b1 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -251,6 +251,14 @@ 84D1F1A72D09053A00CB271F /* StatusTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A62D09053A00CB271F /* StatusTableView.swift */; }; 84D1F1A92D09800700CB271F /* PresetDetentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D1F1A82D09800700CB271F /* PresetDetentView.swift */; }; 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */; }; + 84DF48B52F6A0AD400BEDB40 /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48B42F6A0AD400BEDB40 /* AudioPlayer.swift */; }; + 84DF48B72F6A0AD700BEDB40 /* CaptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48B62F6A0AD700BEDB40 /* CaptionsView.swift */; }; + 84DF48B92F6A0ADB00BEDB40 /* MediaPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48B82F6A0ADB00BEDB40 /* MediaPlayerView.swift */; }; + 84DF48BB2F6A0ADD00BEDB40 /* PlayerControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48BA2F6A0ADD00BEDB40 /* PlayerControls.swift */; }; + 84DF48BD2F6A0AE200BEDB40 /* TranscriptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48BC2F6A0AE200BEDB40 /* TranscriptView.swift */; }; + 84DF48BF2F6A0AE500BEDB40 /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48BE2F6A0AE500BEDB40 /* VideoView.swift */; }; + 84DF48C12F6A0AED00BEDB40 /* Double+Closest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48C02F6A0AED00BEDB40 /* Double+Closest.swift */; }; + 84DF48C32F6A0AF600BEDB40 /* Image+Crop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DF48C22F6A0AF600BEDB40 /* Image+Crop.swift */; }; 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8BBC92CCA16290078E6CF /* PresetsView.swift */; }; 84EC162E2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */; }; 84F20DFD2D0B9C3A0089DF02 /* EditPresetDurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */; }; @@ -1136,6 +1144,14 @@ 84D1F1A62D09053A00CB271F /* StatusTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableView.swift; sourceTree = ""; }; 84D1F1A82D09800700CB271F /* PresetDetentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetDetentView.swift; sourceTree = ""; }; 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSFocusModesView.swift; sourceTree = ""; }; + 84DF48B42F6A0AD400BEDB40 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; + 84DF48B62F6A0AD700BEDB40 /* CaptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptionsView.swift; sourceTree = ""; }; + 84DF48B82F6A0ADB00BEDB40 /* MediaPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerView.swift; sourceTree = ""; }; + 84DF48BA2F6A0ADD00BEDB40 /* PlayerControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControls.swift; sourceTree = ""; }; + 84DF48BC2F6A0AE200BEDB40 /* TranscriptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptView.swift; sourceTree = ""; }; + 84DF48BE2F6A0AE500BEDB40 /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = ""; }; + 84DF48C02F6A0AED00BEDB40 /* Double+Closest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Closest.swift"; sourceTree = ""; }; + 84DF48C22F6A0AF600BEDB40 /* Image+Crop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Crop.swift"; sourceTree = ""; }; 84E8BBC92CCA16290078E6CF /* PresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsView.swift; sourceTree = ""; }; 84EC162D2C9115CA00D220C5 /* DIYLoopUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DIYLoopUnitTestPlan.xctestplan; sourceTree = ""; }; 84F20DFC2D0B9C3A0089DF02 /* EditPresetDurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPresetDurationView.swift; sourceTree = ""; }; @@ -2099,6 +2115,8 @@ 43E344A01B9E144300C85C07 /* Extensions */ = { isa = PBXGroup; children = ( + 84DF48C22F6A0AF600BEDB40 /* Image+Crop.swift */, + 84DF48C02F6A0AED00BEDB40 /* Double+Closest.swift */, C120CECB2D8CD6970050944B /* Publisher.swift */, C10509782D8B635F00118A37 /* Environment+TemporaryPresetsManager.swift */, C10509742D8B308E00118A37 /* Environment+SettingsManager.swift */, @@ -2431,7 +2449,6 @@ 842E409A2F22F7E2000CCCE0 /* PresetsTrainingCard.swift */, 842E409B2F22F7E2000CCCE0 /* CommonUseStep.swift */, 842E409D2F22F7E2000CCCE0 /* TherapySettingsExampleView.swift */, - 842E409E2F22F7E2000CCCE0 /* PlayMediaButton.swift */, 842E409F2F22F7E2000CCCE0 /* IntensitySlider.swift */, 842E40A02F22F7E2000CCCE0 /* TintedContent.swift */, ); @@ -2499,6 +2516,20 @@ path = Widgets; sourceTree = ""; }; + 84DF48A42F6A0A6E00BEDB40 /* Media Player */ = { + isa = PBXGroup; + children = ( + 84DF48BE2F6A0AE500BEDB40 /* VideoView.swift */, + 84DF48BC2F6A0AE200BEDB40 /* TranscriptView.swift */, + 84DF48BA2F6A0ADD00BEDB40 /* PlayerControls.swift */, + 84DF48B82F6A0ADB00BEDB40 /* MediaPlayerView.swift */, + 84DF48B62F6A0AD700BEDB40 /* CaptionsView.swift */, + 84DF48B42F6A0AD400BEDB40 /* AudioPlayer.swift */, + 842E409E2F22F7E2000CCCE0 /* PlayMediaButton.swift */, + ); + path = "Media Player"; + sourceTree = ""; + }; 84E8BBAF2CC979300078E6CF /* Presets */ = { isa = PBXGroup; children = ( @@ -2507,6 +2538,7 @@ 84B67A8C2F63558A004C783B /* PresetPerformanceHistoryView.swift */, 8446319E2F5A2AA9003825AE /* PresetsPerformanceHistoryViewModel.swift */, 84E8BBC92CCA16290078E6CF /* PresetsView.swift */, + 84DF48A42F6A0A6E00BEDB40 /* Media Player */, 842E40A62F22F7E2000CCCE0 /* Training */, 84E8BBC22CC9B9780078E6CF /* Components */, ); @@ -3488,6 +3520,7 @@ 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, 4372E48B213CB5F00068E043 /* Double.swift in Sources */, + 84DF48C12F6A0AED00BEDB40 /* Double+Closest.swift in Sources */, 430B29932041F5B300BA9F93 /* UserDefaults+Loop.swift in Sources */, B429CAB42E97C97000FA988E /* LoopStatusModalView.swift in Sources */, 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */, @@ -3504,6 +3537,7 @@ C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */, 8446319F2F5A2AB3003825AE /* PresetsPerformanceHistoryViewModel.swift in Sources */, C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, + 84DF48B92F6A0ADB00BEDB40 /* MediaPlayerView.swift in Sources */, 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, C18859AC2AF29BE50010F21F /* TemporaryPresetsManager.swift in Sources */, @@ -3564,6 +3598,7 @@ 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */, A9A056B524B94123007CF06D /* CriticalEventLogExportViewModel.swift in Sources */, 434FF1EE1CF27EEF000DB779 /* UITableViewCell.swift in Sources */, + 84DF48C32F6A0AF600BEDB40 /* Image+Crop.swift in Sources */, 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */, C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */, E98A55EF24EDD6E60008715D /* DosingDecisionStoreProtocol.swift in Sources */, @@ -3598,7 +3633,9 @@ 4328E0351CFC0AE100E199AA /* WatchDataManager.swift in Sources */, 4345E3FC21F04911009E00E5 /* UIColor+HIG.swift in Sources */, 1D4A3E2E2478628500FD601B /* StoredAlert+CoreDataProperties.swift in Sources */, + 84DF48B72F6A0AD700BEDB40 /* CaptionsView.swift in Sources */, B4C6D2442EAA2AC2006F5755 /* TimeInterval.swift in Sources */, + 84DF48BD2F6A0AE200BEDB40 /* TranscriptView.swift in Sources */, B43B5C562EAFBF230096A6AE /* RecentGlucoseTableViewCell.swift in Sources */, C10509752D8B309400118A37 /* Environment+SettingsManager.swift in Sources */, E95D380124EADE7C005E2F50 /* DoseStoreProtocol.swift in Sources */, @@ -3633,6 +3670,7 @@ C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, C10509772D8B591200118A37 /* StatusTableViewModel.swift in Sources */, 84DEB10D2C18FABA00170734 /* IOSFocusModesView.swift in Sources */, + 84DF48BF2F6A0AE500BEDB40 /* VideoView.swift in Sources */, 84213C752D932F0F00642E78 /* InsulinDeliveryLog.swift in Sources */, DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */, 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, @@ -3675,11 +3713,13 @@ 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, 14C9706A2C5A833100E8A01B /* CarbEffectChartView.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, + 84DF48B52F6A0AD400BEDB40 /* AudioPlayer.swift in Sources */, 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */, DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */, A9C62D842331700E00535612 /* DiagnosticLog+Subsystem.swift in Sources */, C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */, A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */, + 84DF48BB2F6A0ADD00BEDB40 /* PlayerControls.swift in Sources */, 84E8BBCA2CCA16290078E6CF /* PresetsView.swift in Sources */, 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, diff --git a/Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/Contents.json new file mode 100644 index 0000000000..346576c5b9 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "performance-history-empty.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/performance-history-empty.pdf b/Loop/DerivedAssetsBase.xcassets/performance-history-empty.imageset/performance-history-empty.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e02a67f6df2ad30e1134f50ad4d44bca8680fad5 GIT binary patch literal 8107 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 z

E(odW7g1NFYVSEOC-^thJITwM4xBI7DYYHV&QDVW?Y*<|zFW9Ap$QD9kM2MOBT z^16OG!(INR6Hu#5k?=dzN0ml{AGY`szvqYR6#Z;1|NdDbe!i$WcY7(ftM#1um#VD) zQ8$u-}mE=2;AO5 z|926xyJ}AcnM(zY#^6b8zghNf)B(E^e>b;G5I7703q#-lfK&&e@#KyYfL!YA*DePp z{nK0^n`8t4k^2h2^)w|Rq)x{^yOHC*-+s&9?b7YZ)F5@ONbnzhgk7S4oTW$h`fl6r z!0RMoy9L~DVTd5&^$=bM`}xloz@NtLYP1U+Xtd|v zJ&VXpCKwD^mp`rgr#>b7%K(aX(Lp<4b_-1MfK-%{_5eX%vKLyvo0x#zh}#FvkqA!& z)(DGmK#-a+b?pG;X57BkMx+iN?(gv4bC?>bNsGcblBWIPDI&=_I&lC< znr<(^$$tUTV6Y?@;J|o*fk^(`{enKgWTi-{{BKN#G4p;!$r+X<3G(~zN$Xh_#7aX3H S$-A2(LsFNHpI_sW=KleIdsuw{ literal 0 HcmV?d00001 diff --git a/Loop/Extensions/Double+Closest.swift b/Loop/Extensions/Double+Closest.swift new file mode 100644 index 0000000000..c7b4601d8c --- /dev/null +++ b/Loop/Extensions/Double+Closest.swift @@ -0,0 +1,25 @@ +// +// Double+Closest.swift +// Loop +// +// Created by Cameron Ingham on 3/20/25. +// + +extension Double { + func findClosest(in numberSet: [Double]) -> Double { + guard !numberSet.isEmpty else { + return self + } + + guard numberSet.count > 1 else { + return numberSet[0] + } + + return numberSet.reduce(numberSet[0]) { closest, current in + let currentDifference = abs(current - self) + let closestDifference = abs(closest - self) + + return currentDifference < closestDifference ? current : closest + } + } +} diff --git a/Loop/Extensions/Image+Crop.swift b/Loop/Extensions/Image+Crop.swift new file mode 100644 index 0000000000..ea20c7d607 --- /dev/null +++ b/Loop/Extensions/Image+Crop.swift @@ -0,0 +1,30 @@ +// +// Image+Crop.swift +// Loop +// +// Created by Cameron Ingham on 3/20/25. +// + +import AVKit +import SwiftUI + +extension Image { + func centerCropped() -> some View { + GeometryReader { geo in + self + .resizable() + .scaledToFill() + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + } + } +} + +extension View { + func centerCropped() -> some View { + GeometryReader { geo in + self + .frame(width: geo.size.width, height: geo.size.height) + } + } +} diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 08384b0663..ae398ae2e1 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1537,6 +1537,7 @@ final class StatusTableViewController: LoopChartsTableViewController { carbStore: deviceManager.carbStore, doseStore: deviceManager.doseStore, glucoseStore: deviceManager.glucoseStore, + trainingContent: supportManager.availableSupports.flatMap({ $0.trainingMedia(for: .presets) }), automationHistory: { [weak self] in self?.loopManager.automationHistory ?? [] } ) .onAppear { self.isShowingPresets = true } diff --git a/Loop/Views/Presets/Media Player/AudioPlayer.swift b/Loop/Views/Presets/Media Player/AudioPlayer.swift new file mode 100644 index 0000000000..272e130360 --- /dev/null +++ b/Loop/Views/Presets/Media Player/AudioPlayer.swift @@ -0,0 +1,90 @@ +// +// AudioPlayer.swift +// Podcast Demo +// +// Created by Cameron Ingham on 3/20/25. +// + +import AVKit +import LoopKit +import SwiftUI + +struct AudioPlayerView: View { + var fileName: String? + var url: URL? + + @State private var player: AVAudioPlayer? + @State private var isPlaying = false + @State private var totalTime: TimeInterval = 0.0 + @State private var currentTime: TimeInterval = 0.0 + + var body: some View { + VStack { + if let player = player { + Text(fileName ?? "File") + + HStack { + Button(action: { + isPlaying.toggle() + if isPlaying { + player.play() + } else { + player.pause() + } + }) { + Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.largeTitle) + } + .buttonStyle(PlainButtonStyle()) + + Slider(value: Binding(get: { + currentTime + }, set: { newValue in + player.currentTime = newValue + currentTime = newValue + }), in: 0...totalTime) + .accentColor(.blue) + } + + HStack { + Text("\(formatTime(currentTime))") + Spacer() + Text("\(formatTime(totalTime))") + } + .padding(.horizontal) + } + } + .onAppear { + if let url = url { + setupAudio(withURL: url) + } + } + .onReceive(Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()) { _ in + updateProgress() + } + .onDisappear { + player?.stop() + } + } + + private func formatTime(_ time: TimeInterval) -> String { + let seconds = Int(time) % 60 + let minutes = Int(time) / 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + private func setupAudio(withURL url: URL) { + do { + player = try AVAudioPlayer(contentsOf: url) + player?.prepareToPlay() + totalTime = player?.duration ?? 0.0 + } catch { + print("Error loading audio: \(error)") + } + } + + private func updateProgress() { + guard let player = player, player.isPlaying else { return } + currentTime = player.currentTime + } +} diff --git a/Loop/Views/Presets/Media Player/CaptionsView.swift b/Loop/Views/Presets/Media Player/CaptionsView.swift new file mode 100644 index 0000000000..fe5bee4bee --- /dev/null +++ b/Loop/Views/Presets/Media Player/CaptionsView.swift @@ -0,0 +1,31 @@ +// +// CaptionsView.swift +// Podcast Demo +// +// Created by Cameron Ingham on 3/21/25. +// + +import LoopKit +import SwiftUI + +struct CaptionsView: View { + + @Binding var currentTime: TimeInterval + + let captions: ClosedCaptions + + private var currentCaptionFragment: ClosedCaptionFragment? { + captions.currentFragment(at: currentTime) + } + + var body: some View { + if let currentCaptionFragment { + Text(currentCaptionFragment.text) + .multilineTextAlignment(.leading) + .foregroundStyle(.white) + .font(.subheadline) + .padding(8) + .background(Color.black.opacity(0.66).clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))) + } + } +} diff --git a/Loop/Views/Presets/Media Player/MediaPlayerView.swift b/Loop/Views/Presets/Media Player/MediaPlayerView.swift new file mode 100644 index 0000000000..fa430a2b82 --- /dev/null +++ b/Loop/Views/Presets/Media Player/MediaPlayerView.swift @@ -0,0 +1,203 @@ +// +// MediaPlayerView.swift +// Podcast Demo +// +// Created by Cameron Ingham on 3/11/25. +// + +import AVKit +import LoopKit +import SwiftUI + +struct MediaPlayerView: View { + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dismiss) private var dismiss + + private let media: MediaContent + + @State private var player: AVAudioPlayer + @State private var minHeight: Double + @State private var sheetHeight: Double + @State private var miniPlayer: Bool + @State private var captionsEnabled: Bool + @State private var isPaused: Bool + @State private var currentTime: TimeInterval + + init( + media: MediaContent, + minHeight: Double = 0, + sheetHeight: Double = 0, + miniPlayer: Bool = false, + captionsEnabled: Bool = false + ) { + self.player = try! AVAudioPlayer(contentsOf: media.audio) + self.media = media + self.minHeight = minHeight + self.sheetHeight = sheetHeight + self.miniPlayer = miniPlayer + self.captionsEnabled = captionsEnabled + self.isPaused = true + self.currentTime = 0 + } + + private var dragGesture: some Gesture { + DragGesture(coordinateSpace: .global) + .onChanged { value in + withAnimation(.default.speed(10)) { + sheetHeight = max(minHeight, UIScreen.main.bounds.height - value.location.y) + } + } + .onEnded { value in + withAnimation(reduceMotion ? nil : .default) { + sheetHeight = (UIScreen.main.bounds.height - value.location.y).findClosest(in: [minHeight, UIScreen.main.bounds.height / 2, UIScreen.main.bounds.height]) + } + } + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .bottom) { + ZStack(alignment: .topTrailing) { + Group { + VideoView(isPaused: $isPaused, media: media) + .centerCropped() + } + .edgesIgnoringSafeArea(.all) + .padding(.bottom, sheetHeight) + + Button { + dismiss() + } label: { + ZStack { + Circle() + .fill(Color.secondary) + .frame(width: 24, height: 24) + + Image(systemName: "xmark") + .font(.caption.weight(.semibold)) + .fontDesign(.rounded) + .foregroundColor(Color(UIColor.secondarySystemBackground)) + } + .padding(20) + .contentShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityLabel(Text("Close")) + .opacity(sheetHeight <= UIScreen.main.bounds.height - 130 ? 1 : 0) + .animation(.default, value: sheetHeight) + .padding(.top, -16) + } + + VStack(spacing: 16) { + if captionsEnabled && sheetHeight <= UIScreen.main.bounds.height - 190 { + CaptionsView(currentTime: $currentTime, captions: media.closedCaptions) + .padding(.horizontal) + } + + SheetView( + minHeight: $minHeight, + sheetHeight: $sheetHeight, + miniPlayer: $miniPlayer, + isPaused: $isPaused, + currentTime: $currentTime, + captionsEnabled: $captionsEnabled, + media: media, + player: player, + topSafeAreaInset: miniPlayer ? geometry.safeAreaInsets.top : 0 + ) + .frame(height: sheetHeight) + .gesture(media.transcript != nil ? dragGesture : nil) + } + } + .onChange(of: minHeight) { _, newValue in + if sheetHeight == 0 { + sheetHeight = newValue + } + } + .onChange(of: sheetHeight) { _, newValue in + withAnimation(reduceMotion ? nil : .default) { + miniPlayer = newValue >= (UIScreen.main.bounds.height - geometry.safeAreaInsets.top) + } + } + } + .ignoresSafeArea(edges: .bottom) + } +} + +struct SheetView: View { + + @Environment(\.accessibilityReduceMotion) var reduceMotion + + @Binding private var minHeight: Double + @Binding private var sheetHeight: Double + @Binding private var miniPlayer: Bool + @Binding private var isPaused: Bool + @Binding private var currentTime: TimeInterval + @Binding private var captionsEnabled: Bool + + @State private var scrollPosition: TranscriptExcerpt? + + private let media: MediaContent + private let player: AVAudioPlayer + private let topSafeAreaInset: Double + + init( + minHeight: Binding, + sheetHeight: Binding, + miniPlayer: Binding, + isPaused: Binding, + currentTime: Binding, + captionsEnabled: Binding, + media: MediaContent, + player: AVAudioPlayer, + topSafeAreaInset: Double + ) { + self._minHeight = minHeight + self._sheetHeight = sheetHeight + self._miniPlayer = miniPlayer + self._isPaused = isPaused + self._currentTime = currentTime + self._captionsEnabled = captionsEnabled + self.media = media + self.player = player + self.topSafeAreaInset = topSafeAreaInset + } + + var body: some View { + VStack(spacing: 0) { + PlayerControls(player: player, height: $minHeight, mini: $miniPlayer, isPaused: $isPaused, currentTime: $currentTime, captionsEnabled: $captionsEnabled, media: media) + .onTapGesture { + if miniPlayer { + withAnimation(reduceMotion ? nil : .default) { + sheetHeight = UIScreen.main.bounds.height / 2 + } + } + } + + if let transcript = media.transcript { + ScrollViewReader { proxy in + ScrollView { + TranscriptView( + currentTime: $currentTime, + transcript: transcript, + onExcerptTap: { + player.currentTime = $0.startTime + }, + onExcerptChanged: { + proxy.scrollTo($0.text, anchor: .top) + } + ) + .padding(.horizontal, 20) + .padding(.top, 32) + .padding(.bottom, topSafeAreaInset + 32) + } + } + } + } + .onDisappear { + isPaused = true + } + .persistentSystemOverlays(.hidden) + } +} diff --git a/Loop/Views/Presets/Training/Components/PlayMediaButton.swift b/Loop/Views/Presets/Media Player/PlayMediaButton.swift similarity index 78% rename from Loop/Views/Presets/Training/Components/PlayMediaButton.swift rename to Loop/Views/Presets/Media Player/PlayMediaButton.swift index 75345c100c..4b58783f3a 100644 --- a/Loop/Views/Presets/Training/Components/PlayMediaButton.swift +++ b/Loop/Views/Presets/Media Player/PlayMediaButton.swift @@ -6,13 +6,15 @@ // Copyright © 2025 LoopKit Authors. All rights reserved. // +import LoopKit import SwiftUI struct PlayMediaButton: View { - let image: Image - let title: Text - let duration: TimeInterval + let mediaContent: MediaContent + var onPlay: (MediaContent) -> Void = { _ in } + + @State private var duration: TimeInterval = 0 private let formatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -21,6 +23,10 @@ struct PlayMediaButton: View { return formatter }() + private var image: Image { + Image(mediaContent.staticImage.name, bundle: mediaContent.staticImage.bundle) + } + var body: some View { VStack(alignment: .leading, spacing: 6) { image @@ -37,7 +43,7 @@ struct PlayMediaButton: View { .frame(width: 64, height: 64) } - title + Text(mediaContent.metadata.title) .font(.headline.weight(.semibold)) .frame(maxWidth: .infinity, alignment: .leading) @@ -59,5 +65,11 @@ struct PlayMediaButton: View { .background(Color(UIColor.systemBackground)) .cornerRadius(10) .shadow(color: .primary.opacity(0.2), radius: 3, x: 0, y: 0) + .task { + self.duration = (try? await mediaContent.duration) ?? 0 + } + .onTapGesture { + onPlay(mediaContent) + } } } diff --git a/Loop/Views/Presets/Media Player/PlayerControls.swift b/Loop/Views/Presets/Media Player/PlayerControls.swift new file mode 100644 index 0000000000..b7eb3f269c --- /dev/null +++ b/Loop/Views/Presets/Media Player/PlayerControls.swift @@ -0,0 +1,353 @@ +// +// PlayerControls.swift +// Podcast Demo +// +// Created by Cameron Ingham on 2/27/25. +// + +import AVKit +import LoopKit +import SwiftUI + +struct PlayerControls: View { + + @Namespace private var animation + + @State private var totalTime: TimeInterval + @State private var playbackSpeed: Double + + @Binding private var height: Double + @Binding private var mini: Bool + @Binding private var isPaused: Bool + @Binding private var currentTime: TimeInterval + @Binding private var captionsEnabled: Bool + + private let media: MediaContent + private let player: AVAudioPlayer + + init( + player: AVAudioPlayer, + totalTime: TimeInterval = 0, + playbackSpeed: Double = 1, + height: Binding, + mini: Binding, + isPaused: Binding, + currentTime: Binding, + captionsEnabled: Binding, + media: MediaContent + ) { + self.player = player + self.totalTime = totalTime + self.playbackSpeed = playbackSpeed + self._height = height + self._mini = mini + self._isPaused = isPaused + self._currentTime = currentTime + self._captionsEnabled = captionsEnabled + self.media = media + } + + @ViewBuilder + private func playbackSpeedLabel(_ speed: Double) -> some View { + ZStack(alignment: .leading) { + // Added so the menu button takes the width of the largest option so the parent HStack doesn't shift the other elements. + Group { Text("0.5") + Text(Image(systemName: "xmark")).font(.caption2) }.opacity(0) + + switch speed { + case 0.5: Text("0.5") + Text(Image(systemName: "xmark")).font(.caption2) + case 1: Text("1") + Text(Image(systemName: "xmark")).font(.caption2) + case 2: Text("2") + Text(Image(systemName: "xmark")).font(.caption2) + default: Text("\(Int(speed))") + Text(Image(systemName: "xmark")).font(.caption2) + } + } + } + + @ViewBuilder + private func playbackSpeedText(_ speed: Double) -> some View { + switch speed { + case 0.5: Text("0.5x") + case 1: Text("1x") + case 2: Text("2x") + default: Text("\(Int(speed))x") + } + } + + private func playbackSpeedMenuOptions() -> [Double] { + if playbackSpeed == 1 { + return [2, 0.5] + } else if playbackSpeed == 0.5 { + return [2, 1] + } else { + return [1, 0.5] + } + } + + @ViewBuilder + private var fullMetadata: some View { + VStack(alignment: .leading, spacing: 0) { + Text(media.metadata.title) + .multilineTextAlignment(.leading) + .font(.title3.bold()) + .frame(maxWidth: .infinity, alignment: .leading) + .matchedGeometryEffect(id: "title", in: animation) + .fixedSize(horizontal: false, vertical: true) + + Text("by \(media.metadata.author)") + .multilineTextAlignment(.leading) + .matchedGeometryEffect(id: "subtitle", in: animation) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundStyle(.primary) + } + + @ViewBuilder + private var miniMetadata: some View { + VStack(alignment: .leading, spacing: 0) { + Text(media.metadata.title) + .multilineTextAlignment(.leading) + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .matchedGeometryEffect(id: "title", in: animation) + .fixedSize(horizontal: false, vertical: true) + + Text("by \(media.metadata.author)") + .multilineTextAlignment(.leading) + .matchedGeometryEffect(id: "subtitle", in: animation) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundStyle(.white) + } + + @ScaledMetric private var skipIconSize: Double = 24 + @ScaledMetric private var largePlayPauseIconSize: Double = 48 + @ScaledMetric private var miniPlayPauseIconSize: Double = 27 + + @ViewBuilder + private var fullControls: some View { + HStack { + Menu { + ForEach(playbackSpeedMenuOptions(), id: \.self) { speed in + Button { + playbackSpeed = speed + } label: { + playbackSpeedText(speed) + } + } + } label: { + playbackSpeedLabel(playbackSpeed) + } + .tint(Color(UIColor.systemGray)) + + Spacer() + + HStack(spacing: 32) { + Button { + player.currentTime -= 15 + } label: { + Text(Image(systemName: "15.arrow.trianglehead.counterclockwise")) + .font(.system(size: skipIconSize)) + } + .tint(Color(UIColor.systemGray)) + + Button { + isPaused.toggle() + } label: { + Text(Image(systemName: isPaused ? "play.circle.fill" : "pause.circle.fill")) + .font(.system(size: largePlayPauseIconSize)) + .transition(.symbolEffect) + } + .matchedGeometryEffect(id: "playButton", in: animation) + + Button { + player.currentTime += 15 + } label: { + Text(Image(systemName: "15.arrow.trianglehead.clockwise")) + .font(.system(size: skipIconSize)) + } + .tint(Color(UIColor.systemGray)) + } + + Spacer() + + Button { + withAnimation { + captionsEnabled.toggle() + } + } label: { + Image(systemName: "captions.bubble") + } + .tint(captionsEnabled ? .accentColor : Color(UIColor.systemGray)) + } + } + + @ViewBuilder + private var miniControls: some View { + Button { + isPaused.toggle() + } label: { + Text(Image(systemName: isPaused ? "play.circle.fill" : "pause.circle.fill")) + .font(.system(size: miniPlayPauseIconSize)) + .transition(.symbolEffect) + } + .tint(.white) + .matchedGeometryEffect(id: "playButton", in: animation) + } + + var body: some View { + Group { + if mini { + VStack(alignment: .leading, spacing: 24) { + HStack(spacing: 0) { + miniMetadata + + Spacer() + + miniControls + } + .padding(.horizontal, 20) + + TimelineView( + mini: true, + totalTime: $totalTime, + currentTime: $currentTime, + player: player + ) + } + .padding(.top, 24) + } else { + VStack(alignment: .leading, spacing: 0) { + fullMetadata + .padding(.bottom, 16) + + TimelineView( + totalTime: $totalTime, + currentTime: $currentTime, + player: player + ) + .padding(.bottom, 4) + + fullControls + } + .padding(20) + .padding(.bottom, 12) + .background { + GeometryReader { proxy in + Color.clear + .onAppear { + height = proxy.size.height + } + } + } + } + } + .background { + Group { + mini ? Color.accentColor : Color(UIColor.systemBackground) + } + .ignoresSafeArea(edges: .top) + .shadow(color: .secondary.opacity(0.2) , radius: 3, y: 2) + } + .onAppear { + player.prepareToPlay() + player.enableRate = true + try? AVAudioSession.sharedInstance().setCategory(.playback) + try? AVAudioSession.sharedInstance().setActive(true) + totalTime = player.duration + } + .onChange(of: isPaused) { _, newValue in + if isPaused { + player.pause() + } else { + player.play() + } + } + .onChange(of: playbackSpeed) { _, newValue in + player.rate = Float(newValue) + } + .onReceive(Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()) { _ in + if player.isPlaying == false { + isPaused = true + } + } + } +} + +private struct TimelineView: View { + + @Namespace private var animation + + @State var mini: Bool = false + + @Binding var totalTime: TimeInterval + @Binding var currentTime: TimeInterval + + let player: AVAudioPlayer + + private var progress: Double { + guard totalTime > 0 else { + return 1.0 + } + + let percentage = currentTime / totalTime + + return min(max(percentage, 0.0), 1.0) + } + + private var timeRemaining: TimeInterval { + totalTime - currentTime + } + + var body: some View { + Group { + if mini { + Color.black.opacity(0.3) + .frame(height: 4) + .containerRelativeFrame(.horizontal) { size, axis in + size * progress + } + .matchedGeometryEffect(id: "timeline", in: animation) + } else { + VStack(spacing: 2) { + Slider( + value: Binding( + get: { + progress + }, + set: { newValue, _ in + player.currentTime = min(max(newValue, 0.0), 1.0) * totalTime + } + ), + in: 0...1 + ) + .onAppear { + let size = CGSize(width: 12, height: 12) + let image = UIGraphicsImageRenderer(size: size).image { _ in + UIImage(systemName: "circle.fill")?.draw(in: CGRect(origin: .zero, size: size)) + }.withRenderingMode(.alwaysTemplate) + + UISlider.appearance().setThumbImage(image, for: .normal) + } + .matchedGeometryEffect(id: "timeline", in: animation) + HStack { + Text(formatTime(currentTime)) + + Spacer() + + Text("-") + Text(formatTime(timeRemaining)) + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .onReceive(Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()) { _ in + currentTime = player.currentTime + } + } + + private func formatTime(_ time: TimeInterval) -> String { + let seconds = Int(time) % 60 + let minutes = Int(time) / 60 + return String(format: "%02d:%02d", minutes, seconds) + } +} diff --git a/Loop/Views/Presets/Media Player/TranscriptView.swift b/Loop/Views/Presets/Media Player/TranscriptView.swift new file mode 100644 index 0000000000..d63100a822 --- /dev/null +++ b/Loop/Views/Presets/Media Player/TranscriptView.swift @@ -0,0 +1,119 @@ +// +// TranscriptView.swift +// Podcast Demo +// +// Created by Cameron Ingham on 7/16/25. +// + +import LoopKit +import SwiftUI + +struct TranscriptView: View { + @Binding var currentTime: TimeInterval + + let transcript: Transcript + let onExcerptTap: (TranscriptExcerpt) -> Void + let onExcerptChanged: (TranscriptExcerpt) -> Void + + private var currentTranscriptExcerpt: TranscriptExcerpt { + transcript.currentExcerpt(at: currentTime) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(transcript.paragraphs, id: \.self) { paragraph in + paragraph.excerpts.reduce(AttributedText("")) { partialResult, excerpt in + if partialResult != AttributedText("") { + return partialResult + AttributedText(" ") + excerptText(excerpt: excerpt) + } else { + return partialResult + excerptText(excerpt: excerpt) + } + } + } + } + .font(.title3.weight(.medium)) + .fontDesign(.serif) + .onChange(of: currentTranscriptExcerpt) { _, newValue in + onExcerptChanged(newValue) + } + } + + private func excerptText(excerpt: TranscriptExcerpt) -> AttributedText { + AttributedText(excerpt.text) { attributedText in + attributedText.foregroundColor = (currentTranscriptExcerpt == excerpt && currentTime != 0) ? .accentColor : .primary + } onTap: { + onExcerptTap(excerpt) + } + } +} + +public struct AttributedText: View, Equatable { + private var id: String + private var attributedString: AttributedString + private var onTap: (() -> Void)? = nil + private var tapHandlers: [String: () -> Void] = [:] + private var currentId: Int = 0 + + private mutating func registerTapHandler(_ handler: @escaping () -> Void) -> String { + let _id = "tappable-\(currentId)" + currentId += 1 + tapHandlers[_id] = handler + return _id + } + + private func getTapHandler(for _id: String) -> (() -> Void)? { + return tapHandlers[_id] + } + + public init( + _ string: String = "", + modifier: ((_ text: inout AttributedString) -> Void)? = nil, + onTap: (() -> Void)? = nil + ) { + var attributedString = AttributedString(string) + + modifier?(&attributedString) + + self.id = string + self.attributedString = attributedString + self.onTap = onTap + } + + public static func + (lhs: Self, rhs: Self) -> Self { + var result = lhs + var rhsString = rhs.attributedString + + if let onTap = rhs.onTap { + let id = result.registerTapHandler(onTap) + rhsString.link = URL(string: "tappable://\(id)") + } + + result.attributedString.append(rhsString) + return result + } + + public var body: some View { + Text(attributedString) + .id(id) + .environment(\.openURL, OpenURLAction { url in + if let _id = url.host { + getTapHandler(for: _id)?() + } + return .discarded + }) + } + + public func onTap(_ action: @escaping () -> Void) -> Self { + var copy = self + copy.onTap = action + return copy + } + + public static func += (lhs: inout Self, rhs: Self) { + lhs = lhs + rhs + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.attributedString == rhs.attributedString + } +} diff --git a/Loop/Views/Presets/Media Player/VideoView.swift b/Loop/Views/Presets/Media Player/VideoView.swift new file mode 100644 index 0000000000..66f4ff4f52 --- /dev/null +++ b/Loop/Views/Presets/Media Player/VideoView.swift @@ -0,0 +1,93 @@ +// +// VideoView.swift +// Podcast Demo +// +// Created by Cameron Ingham on 3/20/25. +// + +import AVKit +import LoopKit +import SwiftUI + +struct VideoView: View { + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @Binding var isPaused: Bool + + let media: MediaContent + + var body: some View { + _VideoPlayer(media: media, isPaused: $isPaused) + } +} + +struct _VideoPlayer : UIViewControllerRepresentable { + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private let player: AVPlayer + + @Binding private var isPaused: Bool + + init(media: MediaContent, isPaused: Binding) { + self.player = AVPlayer(url: media.animation) + self._isPaused = .init(projectedValue: isPaused) + } + + func makeCoordinator() -> Coordinator { + Coordinator(player: player, reduceMotion: reduceMotion) + } + + func makeUIViewController(context: UIViewControllerRepresentableContext<_VideoPlayer>) -> AVPlayerViewController { + let controller = AVPlayerViewController() + controller.player = player + controller.showsPlaybackControls = false + controller.videoGravity = .resizeAspectFill + controller.allowsPictureInPicturePlayback = false + return controller + } + + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: UIViewControllerRepresentableContext<_VideoPlayer>) { + if !reduceMotion { + if isPaused { + uiViewController.player?.pause() + } else { + uiViewController.player?.play() + } + } else { + uiViewController.player?.pause() + } + } + + class Coordinator: NSObject { + + private let player: AVPlayer + private let reduceMotion: Bool + + init(player: AVPlayer, reduceMotion: Bool) { + self.player = player + self.reduceMotion = reduceMotion + + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(playerItemDidReachEnd(notification:)), + name: AVPlayerItem.didPlayToEndTimeNotification, + object: player.currentItem + ) + } + + @objc + private func playerItemDidReachEnd(notification: Notification) { + if let playerItem: AVPlayerItem = notification.object as? AVPlayerItem { + playerItem.seek(to: .zero) { _ in } + + if !reduceMotion { + player.play() + } + } + } + } +} diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index a66836d11c..f3c28115a8 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -72,6 +72,7 @@ struct PresetsView: View { private let carbStore: CarbStore private let doseStore: DoseStore private let glucoseStore: GlucoseStore + private let trainingContent: [MediaContent] private let automationHistory: () -> [AutomationHistoryEntry] @AppStorage("presetsSortAscending") private var presetsSortAscending: Bool = true @@ -82,6 +83,7 @@ struct PresetsView: View { carbStore: CarbStore, doseStore: DoseStore, glucoseStore: GlucoseStore, + trainingContent: [MediaContent], automationHistory: @escaping () -> [AutomationHistoryEntry] ) { self.trainingCompletion = PresetsTrainingCompletion(allowDebugFeatures: FeatureFlags.allowDebugFeatures) @@ -89,6 +91,7 @@ struct PresetsView: View { self.carbStore = carbStore self.doseStore = doseStore self.glucoseStore = glucoseStore + self.trainingContent = trainingContent self.automationHistory = automationHistory } @@ -286,12 +289,20 @@ struct PresetsView: View { suspendThreshold: { settingsManager.settings.suspendThreshold } ) .sheet(isPresented: $showPresetsTrainingSheet) { - PresetsTrainingView(trainingCompletionConfiguration: .trainingCompletion(trainingCompletion)) + PresetsTrainingView( + trainingCompletionConfiguration: .trainingCompletion(trainingCompletion), + trainingContent: trainingContent + ) } } } case .training(let navigationPath, let startingAt, let editPresetWhenComplete): - PresetsTrainingView(navigationPath: navigationPath, startingAt: startingAt, trainingCompletionConfiguration: .trainingCompletion(trainingCompletion)) { + PresetsTrainingView( + navigationPath: navigationPath, + startingAt: startingAt, + trainingCompletionConfiguration: .trainingCompletion(trainingCompletion), + trainingContent: trainingContent + ) { if let editPresetWhenComplete { activeSheet = .editPreset(editPresetWhenComplete) } diff --git a/Loop/Views/Presets/Training/PresetsTrainingContent.swift b/Loop/Views/Presets/Training/PresetsTrainingContent.swift index 9fcf73176a..a50cda3fcc 100644 --- a/Loop/Views/Presets/Training/PresetsTrainingContent.swift +++ b/Loop/Views/Presets/Training/PresetsTrainingContent.swift @@ -22,14 +22,14 @@ extension PresetsTraining { protocol PresetsTrainingContent { associatedtype B: View - func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, next: @escaping () -> Void) -> B + func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, trainingContent: [MediaContent], next: @escaping () -> Void, onPlayMedia: @escaping (MediaContent) -> Void) -> B var cta: PresetsTraining.CTA? { get } } extension PresetsTraining.Step: PresetsTrainingContent { @ViewBuilder - func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, next: @escaping () -> Void) -> some View { + func content(appName: String, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, trainingContent: [MediaContent], next: @escaping () -> Void, onPlayMedia: @escaping (MediaContent) -> Void) -> some View { switch self { case .customizingPresets(let customizingPresets): switch customizingPresets { @@ -282,21 +282,19 @@ extension PresetsTraining.Step: PresetsTrainingContent { Text("Next, we’ll look at settings you can change and how they affect Omar’s insulin.") - VStack(alignment: .leading, spacing: 16) { - Text("Learn More") - .font(.headline.weight(.semibold)) - - PlayMediaButton( - image: Image("ADLs"), - title: Text("Managing Activities of Daily Living"), - duration: .minutes(5) + .seconds(36) + if let adls = trainingContent.first(where: { $0.fileName == "ADLs" }) { + VStack(alignment: .leading, spacing: 16) { + Text("Learn More") + .font(.headline.weight(.semibold)) + + PlayMediaButton(mediaContent: adls, onPlay: onPlayMedia) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(colorPalette.chartColorPalette.presetTint).opacity(0.1)) ) } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color(colorPalette.chartColorPalette.presetTint).opacity(0.1)) - ) case .overallInsulin: Text("Omar asks himself, **do I expect I will need more or less insulin than usual?**") @@ -403,21 +401,19 @@ extension PresetsTraining.Step: PresetsTrainingContent { IntensityInfo() - VStack(alignment: .leading, spacing: 16) { - Text("Learn More") - .font(.headline.weight(.semibold)) - - PlayMediaButton( - image: Image("Same Activity Different Intensity"), - title: Text("Same Activity, Different Intensity"), - duration: .minutes(6) + .seconds(34) + if let sameActivityDifferentIntensity = trainingContent.first(where: { $0.fileName == "Same Activity Different Intensity" }) { + VStack(alignment: .leading, spacing: 16) { + Text("Learn More") + .font(.headline.weight(.semibold)) + + PlayMediaButton(mediaContent: sameActivityDifferentIntensity, onPlay: onPlayMedia) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(colorPalette.chartColorPalette.presetTint).opacity(0.1)) ) } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color(colorPalette.chartColorPalette.presetTint).opacity(0.1)) - ) case .lightToModerateExercise: if let image = Image.optional("PresetsTrainingExerciseLightToModerateHero") { @@ -599,21 +595,19 @@ extension PresetsTraining.Step: PresetsTrainingContent { } .padding(.horizontal, -16) - VStack(alignment: .leading, spacing: 16) { - Text("Learn More") - .font(.headline.weight(.semibold)) - - PlayMediaButton( - image: Image("Mixed Exercise"), - title: Text("Navigating the Challenges of Mixed Exercise"), - duration: .minutes(3) + .seconds(27) + if let mixedExercise = trainingContent.first(where: { $0.fileName == "Mixed Exercise" }) { + VStack(alignment: .leading, spacing: 16) { + Text("Learn More") + .font(.headline.weight(.semibold)) + + PlayMediaButton(mediaContent: mixedExercise, onPlay: onPlayMedia) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(colorPalette.chartColorPalette.presetTint).opacity(0.1)) ) } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color(colorPalette.chartColorPalette.presetTint).opacity(0.1)) - ) case .exerciseAndGlucoseActiveInsulin: Text("When using a preset for activity, keep in mind four key factors that may impact your glucose.") @@ -1015,14 +1009,18 @@ extension PresetsTraining.Chapter: PresetsTrainingContent { displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, dynamicTypeSize: DynamicTypeSize, - next: @escaping () -> Void + trainingContent: [MediaContent], + next: @escaping () -> Void, + onPlayMedia: @escaping (MediaContent) -> Void ) -> some View { firstStep.content( appName: appName, displayGlucosePreference: displayGlucosePreference, colorPalette: colorPalette, dynamicTypeSize: dynamicTypeSize, - next: next + trainingContent: trainingContent, + next: next, + onPlayMedia: onPlayMedia ) } diff --git a/Loop/Views/Presets/Training/PresetsTrainingView.swift b/Loop/Views/Presets/Training/PresetsTrainingView.swift index 94571fc871..f2762d4d22 100644 --- a/Loop/Views/Presets/Training/PresetsTrainingView.swift +++ b/Loop/Views/Presets/Training/PresetsTrainingView.swift @@ -19,17 +19,20 @@ public struct PresetsTrainingView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference - @Bindable private var training: PresetsTraining + @State private var training: PresetsTraining @State private var confirmDismiss: Bool = false @State private var showSkipToChapterSelector: Bool = false + @State private var selectedMedia: MediaContent? + private let trainingContent: [MediaContent] private let onComplete: (() -> Void)? public init( navigationPath: [PresetsTraining.Step] = [], startingAt: PresetsTraining.Chapter? = nil, trainingCompletionConfiguration: PresetsTraining.TrainingCompletionConfiguration, + trainingContent: [MediaContent], onComplete: (() -> Void)? = nil ) { self.training = PresetsTraining( @@ -38,6 +41,7 @@ public struct PresetsTrainingView: View { trainingCompletionConfiguration: trainingCompletionConfiguration ) + self.trainingContent = trainingContent self.onComplete = onComplete } @@ -61,6 +65,9 @@ public struct PresetsTrainingView: View { } .environment(training) .interactiveDismissDisabled(!training.trainingCompletion.isComplete) + .fullScreenCover(item: $selectedMedia) { media in + MediaPlayerView(media: media) + } } private func close() { @@ -95,7 +102,9 @@ public struct PresetsTrainingView: View { displayGlucosePreference: displayGlucosePreference, colorPalette: colorPalette, dynamicTypeSize: dynamicTypeSize, - next: training.next + trainingContent: trainingContent, + next: training.next, + onPlayMedia: { selectedMedia = $0 } ) .padding(.bottom, 24) .padding(.horizontal, 16) @@ -160,7 +169,7 @@ public struct PresetsTrainingView: View { .alert(isPresented: $confirmDismiss) { Alert( title: Text("End Training?"), - message: Text("You’ll have to restart this section and some features will be disabled until you complete the training."), + message: Text("You'll have to restart this section and some features will be disabled until you complete the training."), primaryButton: .cancel(), secondaryButton: .destructive(Text("End"), action: { close() }) ) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 446724de7f..767a56e9b7 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -147,6 +147,7 @@ struct SettingsView: View { carbStore: carbStore, doseStore: doseStore, glucoseStore: glucoseStore, + trainingContent: viewModel.availableSupports.flatMap({ $0.trainingMedia(for: .presets) }), automationHistory: { viewModel.delegate?.automationHistory ?? [] } ) } diff --git a/LoopTests/Managers/SupportManagerTests.swift b/LoopTests/Managers/SupportManagerTests.swift index 54471521ae..77b5a6c27c 100644 --- a/LoopTests/Managers/SupportManagerTests.swift +++ b/LoopTests/Managers/SupportManagerTests.swift @@ -44,6 +44,7 @@ class SupportManagerTests: XCTestCase { func loopWillReset() {} func loopDidReset() {} func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { return [] } + func trainingMedia(for domain: TrainingMediaDomain) -> [MediaContent] { [] } } class AnotherMockSupport: Mixin, SupportUI { @@ -56,6 +57,7 @@ class SupportManagerTests: XCTestCase { func loopWillReset() {} func loopDidReset() {} func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { return [] } + func trainingMedia(for domain: TrainingMediaDomain) -> [MediaContent] { [] } } class MockAlertIssuer: AlertIssuer { From cf40c9e6a7b58053a5fab0c9ed1ae1d8aa9736ee Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Tue, 24 Mar 2026 10:05:28 -0700 Subject: [PATCH 389/421] [LOOP-5403] Fix Average Glucose Calculation in Preset Performance History (#918) --- .../PresetPerformanceHistoryView.swift | 20 +++++++++++-------- .../PresetsPerformanceHistoryViewModel.swift | 18 ++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/Loop/Views/Presets/PresetPerformanceHistoryView.swift b/Loop/Views/Presets/PresetPerformanceHistoryView.swift index 7c3475349f..c0eec2209d 100644 --- a/Loop/Views/Presets/PresetPerformanceHistoryView.swift +++ b/Loop/Views/Presets/PresetPerformanceHistoryView.swift @@ -169,7 +169,7 @@ struct PresetPerformanceHistoryView: View { private func detailsSection(performanceData: PresetsPerformanceHistoryViewModel.PerformanceData, showNoData: Bool) -> some View { GroupBox { - if showNoData { + if showNoData || performanceData.allGlucoseValues.isEmpty { Image("performance-history-empty") .resizable() .scaledToFit() @@ -180,12 +180,14 @@ struct PresetPerformanceHistoryView: View { VStack(spacing: 4) { Text("No performance history available yet") .multilineTextAlignment(.center) - - Text("You can see this summary 6 hours after the preset ends.") - .multilineTextAlignment(.center) - .font(.subheadline) - .foregroundStyle(.secondary) .frame(maxWidth: .infinity) + + if showNoData { + Text("You can see this summary 6 hours after the preset ends.") + .multilineTextAlignment(.center) + .font(.subheadline) + .foregroundStyle(.secondary) + } } } else { VStack(alignment: .leading, spacing: 24) { @@ -205,8 +207,10 @@ struct PresetPerformanceHistoryView: View { } } - LabeledContent("Average Glucose") { - Group { Text(displayGlucosePreference.format(performanceData.averageGlucose, includeUnit: false)).fontWeight(.semibold).foregroundStyle(.primary) + Text(" ") + Text(displayGlucosePreference.unit.localizedShortUnitString).foregroundStyle(.secondary) }.contentTransition(.numericText()) + if let averageGlucose = performanceData.averageGlucose { + LabeledContent("Average Glucose") { + Group { Text(displayGlucosePreference.format(averageGlucose, includeUnit: false)).fontWeight(.semibold).foregroundStyle(.primary) + Text(" ") + Text(displayGlucosePreference.unit.localizedShortUnitString).foregroundStyle(.secondary) }.contentTransition(.numericText()) + } } } } diff --git a/Loop/Views/Presets/PresetsPerformanceHistoryViewModel.swift b/Loop/Views/Presets/PresetsPerformanceHistoryViewModel.swift index e7bdb8174c..5d04392672 100644 --- a/Loop/Views/Presets/PresetsPerformanceHistoryViewModel.swift +++ b/Loop/Views/Presets/PresetsPerformanceHistoryViewModel.swift @@ -73,7 +73,8 @@ class PresetsPerformanceHistoryViewModel { correctionRange: correctionRange, startDate: startDate, endDate: calculatedEndDate, - allGlucoseValues: allGlucoseValues, + startingGlucose: allGlucoseValues.last(where: { $0.endDate < startDate })?.quantity, + allGlucoseValues: Array(allGlucoseValues.drop(while: { $0.endDate < startDate })), totalCarbs: totalCarbs, totalBolus: totalBolus, timeInAutomation: timeInAutomation @@ -99,21 +100,18 @@ class PresetsPerformanceHistoryViewModel { let correctionRange: ClosedRange? let startDate: Date let endDate: Date + let startingGlucose: LoopQuantity? let allGlucoseValues: [StoredGlucoseSample] let totalCarbs: LoopQuantity let totalBolus: LoopQuantity let timeInAutomation: Double - var startingGlucose: LoopQuantity? { - allGlucoseValues.map(\.quantity).first - } - - var averageGlucose: LoopQuantity { - LoopQuantity( + var averageGlucose: LoopQuantity? { + guard !allGlucoseValues.isEmpty else { return nil } + return LoopQuantity( unit: .milligramsPerDeciliter, doubleValue: ( allGlucoseValues - .dropFirst() .map({ $0.quantity.doubleValue(for: .milligramsPerDeciliter) }) .reduce(0, +) / Double(allGlucoseValues.count) ) @@ -121,9 +119,9 @@ class PresetsPerformanceHistoryViewModel { } var timeInRange: [GlucoseRange: Double] { - guard allGlucoseValues.dropFirst().count > 0 else { return [:] } + guard allGlucoseValues.count > 0 else { return [:] } - let sorted = allGlucoseValues.dropFirst().sorted { $0.startDate < $1.startDate } + let sorted = allGlucoseValues.sorted { $0.startDate < $1.startDate } var durations: [GlucoseRange: TimeInterval] = [:] for (index, sample) in sorted.enumerated() { From eedaf78d5996dabc989c03f803be7822759ecb27 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 27 Mar 2026 15:53:36 -0300 Subject: [PATCH 390/421] [LOOP-5863] add an option to delete all testing data (#919) * add an option to delete all testing data * minor refactor --- Loop/Managers/DeviceDataManager.swift | 24 +++++++++++++++++++++ Loop/Managers/TestingScenariosManager.swift | 2 +- Loop/View Models/SettingsViewModel.swift | 12 +++++++++++ Loop/Views/SettingsView.swift | 17 +++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 01c447e343..c25f3fb28d 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1217,6 +1217,30 @@ extension DeviceDataManager { try await glucoseStore.purgeAllGlucose(for: testingCGMManager.testingDevice) } + + func deleteTestingCarbData(before: Date = Date()) async throws { + guard let testingCGMManager = cgmManager as? TestingCGMManager, + let testingPumpManager = pumpManager as? TestingPumpManager + else { + return + } + + try await carbStore.deleteAllCarbEntries() + } + + func deleteTestingAlertData() async throws { + guard let testingCGMManager = cgmManager as? TestingCGMManager, + let testingPumpManager = pumpManager as? TestingPumpManager + else { + return + } + + await withCheckedContinuation { [weak alertStore = alertManager.alertStore] continuation in + alertStore?.purge(before: Date(), completion: { _ in + continuation.resume() + }) + } + } } extension DeviceDataManager: BolusDurationEstimator { diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index 0bc469e864..9be28dbb7a 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -364,7 +364,7 @@ extension TestingScenariosManager { } -private extension CarbStore { +extension CarbStore { /// Errors if getting carb entries errors, or if deleting any individual entry errors. func deleteAllCarbEntries() async throws { diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 24629b16da..75839c4965 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -199,6 +199,18 @@ class SettingsViewModel { .assign(to: \.mostRecentPumpDataDate, on: self) .store(in: &cancellables) } + + @MainActor func deleteAllTestingData() { + Task { + try? await deviceManager?.deleteTestingPumpData() + + try? await deviceManager?.deleteTestingCGMData() + + try? await deviceManager?.deleteTestingCarbData() + + try? await deviceManager?.deleteTestingAlertData() + } + } } // For previews only diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 767a56e9b7..771b09ebb8 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -33,6 +33,7 @@ struct SettingsView: View { case deleteCGMData case deletePumpData + case deleteAllTestingData } enum ActionSheet: String, Identifiable { @@ -135,6 +136,11 @@ struct SettingsView: View { return makeDeleteAlert(for: self.viewModel.cgmManagerSettingsViewModel) case .deletePumpData: return makeDeleteAlert(for: self.viewModel.pumpManagerSettingsViewModel) + case .deleteAllTestingData: + return SwiftUI.Alert(title: Text("Delete All Testing Data"), + message: Text("Are you sure you want to delete all your testing Data?\n(This action is not reversible)"), + primaryButton: .cancel(), + secondaryButton: .destructive(Text("Delete"), action: viewModel.deleteAllTestingData)) } } .sheet(item: $sheet) { sheet in @@ -488,6 +494,17 @@ extension SettingsView { } } } + if viewModel.cgmManagerSettingsViewModel.isTestingDevice, + viewModel.pumpManagerSettingsViewModel.isTestingDevice + { + Button(action: { alert = .deleteAllTestingData }) { + HStack { + Spacer() + Text("Delete All Testing Data").accentColor(.destructive) + Spacer() + } + } + } } } From 8e35b8f94d0d687eb48b931750e858b73bf89897 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 3 Apr 2026 12:16:50 -0700 Subject: [PATCH 391/421] [LOOP-5353 & LOOP-5870] Inject colorPallete (#920) --- Loop/Managers/LoopAppManager.swift | 1 + Loop/View Controllers/StatusTableViewController.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 2783e05095..796fadb90d 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -624,6 +624,7 @@ class LoopAppManager: NSObject { .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) .environment(\.guidanceColors, .default) .environment(\.loopStatusColorPalette, .loopStatus) + .environment(\.colorPalette, .default) .environment(\.settingsManager, settingsManager) .environment(\.temporaryPresetsManager, temporaryPresetsManager) .edgesIgnoringSafeArea(.top) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index ae398ae2e1..3ae385b5d6 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1564,6 +1564,7 @@ final class StatusTableViewController: LoopChartsTableViewController { .environment(\.appName, Bundle.main.bundleDisplayName) .environment(\.isInvestigationalDevice, FeatureFlags.isInvestigationalDevice) .environment(\.loopStatusColorPalette, .loopStatus) + .environment(\.colorPalette, .default) .environment(\.settingsManager, settingsManager) .environment(\.temporaryPresetsManager, temporaryPresetsManager) .environment(\.dosingStrategySelectionEnabled, FeatureFlags.dosingStrategySelectionEnabled), From cba8a233b35a5d2b0ec4c4fbfc782d4af1f2d38a Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Fri, 3 Apr 2026 12:17:07 -0700 Subject: [PATCH 392/421] [LOOP-5826] Preset Onboarding References (#921) --- Loop.xcodeproj/project.pbxproj | 4 + .../Training/Components/ReferencesView.swift | 84 ++++++++++++++++++ .../Training/PresetsTrainingContent.swift | 86 +++++++++++++++++++ .../Training/PresetsTrainingView.swift | 6 ++ 4 files changed, 180 insertions(+) create mode 100644 Loop/Views/Presets/Training/Components/ReferencesView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 89625f11b1..a6a505e21f 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -221,6 +221,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 */; }; + 841306BB2F7F0D9C00AF0320 /* ReferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841306BA2F7F0D9C00AF0320 /* ReferencesView.swift */; }; 84213C752D932F0F00642E78 /* InsulinDeliveryLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */; }; 84213C892D94941000642E78 /* InsulinDeliveryLogEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */; }; 84213C8B2D94948900642E78 /* InsulinDeliveryOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */; }; @@ -1115,6 +1116,7 @@ 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetLoopManager.swift; sourceTree = ""; }; 80F864E52433BF5D0026EC26 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; 840A2F0D2C0F978E003D5E90 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 841306BA2F7F0D9C00AF0320 /* ReferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferencesView.swift; sourceTree = ""; }; 84213C742D932F0F00642E78 /* InsulinDeliveryLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLog.swift; sourceTree = ""; }; 84213C882D94941000642E78 /* InsulinDeliveryLogEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryLogEvent.swift; sourceTree = ""; }; 84213C8A2D94948900642E78 /* InsulinDeliveryOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryOverview.swift; sourceTree = ""; }; @@ -2451,6 +2453,7 @@ 842E409D2F22F7E2000CCCE0 /* TherapySettingsExampleView.swift */, 842E409F2F22F7E2000CCCE0 /* IntensitySlider.swift */, 842E40A02F22F7E2000CCCE0 /* TintedContent.swift */, + 841306BA2F7F0D9C00AF0320 /* ReferencesView.swift */, ); path = Components; sourceTree = ""; @@ -3699,6 +3702,7 @@ C174233C259BEB0F00399C9D /* ManualEntryDoseViewModel.swift in Sources */, 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, 84FA9D332CF7FD0D004162B4 /* PresetsHistoryView.swift in Sources */, + 841306BB2F7F0D9C00AF0320 /* ReferencesView.swift in Sources */, C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */, A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, diff --git a/Loop/Views/Presets/Training/Components/ReferencesView.swift b/Loop/Views/Presets/Training/Components/ReferencesView.swift new file mode 100644 index 0000000000..33cea79dc2 --- /dev/null +++ b/Loop/Views/Presets/Training/Components/ReferencesView.swift @@ -0,0 +1,84 @@ +// +// ReferencesView.swift +// Loop +// +// Created by Cameron Ingham on 4/2/26. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import SwiftUI + +public struct ReferencesView: View { + + @State private var isExpanded: Bool = false + @State private var selectedURL: URL? = nil + + private let references: [Text] + + init(_ references: [Text]) { + self.references = references + } + + public var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 0) { + Text("References") + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer() + + Text(Image(systemName: "chevron.up")) + .rotationEffect(.degrees(isExpanded ? 0 : 180)) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + .onTapGesture { + isExpanded.toggle() + } + + if isExpanded { + Grid(horizontalSpacing: 4, verticalSpacing: 12) { + ForEach(references.indices, id: \.self) { referenceId in + GridRow(alignment: .top) { + Text("\(referenceId + 1).") + + references[referenceId] + .frame(maxWidth: .infinity, alignment: .leading) + .accentColor(.secondary) + } + } + } + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .animation(.default, value: isExpanded) + .environment(\.openURL, OpenURLAction { url in + self.selectedURL = url + return .handled + }) + .sheet( + isPresented: Binding( + get: { selectedURL != nil }, + set: { _,_ in selectedURL = nil } + ), + content: { + NavigationStack { + WebView(url: selectedURL!) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + selectedURL = nil + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.secondary) + } + } + } + } + } + ) + } +} diff --git a/Loop/Views/Presets/Training/PresetsTrainingContent.swift b/Loop/Views/Presets/Training/PresetsTrainingContent.swift index a50cda3fcc..62c7d044a9 100644 --- a/Loop/Views/Presets/Training/PresetsTrainingContent.swift +++ b/Loop/Views/Presets/Training/PresetsTrainingContent.swift @@ -962,6 +962,92 @@ extension PresetsTraining.Step: PresetsTrainingContent { } } + var references: [Text] { + switch self { + case .customizingPresets, .illness, .dailyActivities, .trainingComplete: + return [] + case .exercise(let exercise): + switch exercise { + case .commonUses: + return [] + case .presetsForExercise: + return [ + Text(verbatim: "Moser O, Zaharieva DP, Adolfsson A, Battelino T, Bracken RM, Buckingham BA, Danne T, Davis EA, Dovč K, Forlenza GP, et al. The use of automated insulin delivery around physical activity and exercise in type 1 diabetes: a position statement of the European Association for the Study of Diabetes (EASD) and the International Society for Pediatric and Adolescent Diabetes (ISPAD). ") + Text("[PMID: 39653802](https://pmc.ncbi.nlm.nih.gov/articles/PMC11732933/)").underline(), + Text(verbatim: "American Diabetes Association Professional Practice Committee. 14. Children and Adolescents: Standards of Care in Diabetes-2025. Diabetes Care. ") + Text("[PMID: 39651980](https://doi.org/10.2337/dc25-S014)").underline(), + Text(verbatim: "Adolfsson P, Taplin CE, Zaharieva DP, Pemberton J, Davis EA, Riddell MC, et al. ISPAD Clinical Practice Consensus Guidelines 2022: Exercise in children and adolescents with diabetes. ") + Text("[PMID: 36537529](https://doi.org/10.1111/pedi.13452)").underline(), + Text(verbatim: "Phillip M, Nimri R, Bergenstal RM, Barnard-Kelly K, Danne T, Hovorka R, et al. Consensus Recommendations for the Use of Automated Insulin Delivery Technologies in Clinical Practice. ") + Text("[PMID: 36066457](https://pmc.ncbi.nlm.nih.gov/articles/PMC9985411/)").underline(), + Text(verbatim: "Braune K, Lal RA, Petruželková L, Scheiner G, Winterdijk P, Schmidt S, et al. Open-source automated insulin delivery: international consensus statement and practical guidance for health-care professionals. ") + Text("[PMID: 34785000](https://www.thelancet.com/journals/landia/article/PIIS2213-8587(21)00267-9/abstract)").underline(), + Text(verbatim: "Moser O, Riddell MC, Eckstein ML, Adolfsson P, Rabasa-Lhoret R, van den Boom L, et al. Glucose management for exercise using continuous glucose monitoring (CGM) and intermittently scanned CGM (isCGM) systems in type 1 diabetes: position statement of the European Association for the Study of Diabetes (EASD) and of the International Society for Pediatric and Adolescent Diabetes (ISPAD) endorsed by JDRF and supported by the American Diabetes Association (ADA). ") + Text("[PMID: 33047169](https://link.springer.com/article/10.1007/s00125-020-05263-9)").underline(), + Text(verbatim: "Scott SN, Fontana FY, Cocks M, Morton JP, Jeukendrup A, Dragulin R, et al. Post-exercise recovery for the endurance athlete with type 1. ") + Text("[PMID: 33864810](https://pubmed.ncbi.nlm.nih.gov/33864810/)").underline(), + Text(verbatim: "Riddell MC, Gallen IW, Smart CE, Taplin CE, Adolfsson P, Lumb AN, et al. Exercise management in type 1 diabetes: a consensus statement. ") + Text("[PMID: 28126459](https://pubmed.ncbi.nlm.nih.gov/28126459/)").underline(), + Text(verbatim: "Yardley JE, Sigal RJ. Exercise strategies for hypoglycemia prevention in individuals with type 1 diabetes. ") + Text("[PMID: 25717276](https://pmc.ncbi.nlm.nih.gov/articles/PMC4334090/)").underline(), + Text(verbatim: "Colberg SR, Sigal RJ, Yardley JE, Riddell MC, Dunstan DW, Dempsey PC, et al. Physical Activity/Exercise and Diabetes: A Position Statement of the American Diabetes Association. ") + Text("[PMID: 27926890](https://pmc.ncbi.nlm.nih.gov/articles/PMC6908414/)").underline() + ] + case .perceivedIntensity: + return [ + Text(verbatim: "Zaharieva DP, Paldus B, Morrison D, Messer LH, O’Neal DN, Maahs DM, Riddell MC, et al. Practical aspects and exercise safety benefits of automated insulin delivery systems in type 1 diabetes. Diabetes Spectr. ") + Text("[PMID: 37193203](https://pmc.ncbi.nlm.nih.gov/articles/PMC10182962/)").underline(), + Text(verbatim: "Borg GA. Psychophysical bases of perceived exertion. Med Sci Sports Exerc. PMID: ") + Text("[PMID: 7154893](https://pubmed.ncbi.nlm.nih.gov/7154893/)").underline() + ] + case .lightToModerateExercise: + return [ + Text(verbatim: "Turner LV, Marak MC, Gal RL, Calhoun P, Li Z, Jacobs PG, Clements MA, Martin CK, Doyle FJ 3rd, Patton SR, Castle JR, Gillingham MB, Beck RW, Rickels MR, Riddell MC; T1DEXI Study Group. Associations between daily step count classifications and continuous glucose monitoring metrics in adults with type 1 diabetes: analysis of the Type 1 Diabetes Exercise Initiative (T1DEXI) cohort. ") + Text("[PMID: 38502241](https://pubmed.ncbi.nlm.nih.gov/38502241/)").underline(), + Text(verbatim: "Riddell MC, Gal RL, Bergford S, Patton SR, Clements MA, Calhoun P, Beaulieu LC, Sherr JL. The Acute Effects of Real-World Physical Activity on Glycemia in Adolescents With Type 1 Diabetes: The Type 1 Diabetes Exercise Initiative Pediatric (T1DEXIP) Study. ") + Text("[PMID: 37922335](https://pubmed.ncbi.nlm.nih.gov/37922335/)").underline(), + Text(verbatim: "Zaharieva DP, McGaugh S, Pooni R, Vienneau T, Ly T, Riddell MC. Improved Open-Loop Glucose Control With Basal Insulin Reduction 90 Minutes Before Aerobic Exercise in Patients With Type 1 Diabetes on Continuous Subcutaneous Insulin Infusion. ") + Text("[PMID: 30796112](https://pubmed.ncbi.nlm.nih.gov/30796112/)").underline(), + Text(verbatim: "Molveau J, Myette-Côté É, Guédet C, Tagougui S, St-Amand R, Suppère C, Heyman E, Messier V, Boudreau V, Legault L, Rabasa-Lhoret R. Impact of pre- and post-exercise strategies on hypoglycemic risk for two modalities of aerobic exercise among adults and adolescents living with type 1 diabetes using continuous subcutaneous insulin infusion: A randomized controlled trial. ") + Text("[PMID: 39653075](https://pubmed.ncbi.nlm.nih.gov/39653075/)").underline(), + Text(verbatim: "Tagougui S, Legault L, Heyman E, Messier V, Suppere C, Potter KJ, Pigny P, Berthoin S, Taleb N, Rabasa-Lhoret R. Anticipated Basal Insulin Reduction to Prevent Exercise-Induced Hypoglycemia in Adults and Adolescents Living with Type 1 Diabetes. ") + Text("[PMID: 35099281](https://pubmed.ncbi.nlm.nih.gov/35099281/)").underline() + ] + case .highIntensityExercise: + return [ + Text(verbatim: "Paldus B, Morrison D, Zaharieva DP, Lee MH, Jones H, Obeyesekere V, La Gerche A, et al. A randomized crossover trial comparing glucose control during moderate‑intensity, high‑intensity, and resistance exercise with hybrid closed‑loop insulin delivery while profiling potential additional signals in adults with type 1 diabetes. Diabetes Care. ") + Text("[PMID: 34789504](https://pubmed.ncbi.nlm.nih.gov/34789504/)").underline(), + Text(verbatim: "Aronson R, Brown RE, Li A, Riddell MC. Optimal insulin correction factor in post‑high‑intensity exercise hyperglycemia in adults with type 1 diabetes: The FIT Study. Diabetes Care. ") + Text("[PMID: 30455336](https://pubmed.ncbi.nlm.nih.gov/30455336/)").underline() + ] + case .mixedIntensityExercise: + return [ + Text(verbatim: "Riddell MC, Gal RL, Bergford S, Patton SR, Clements MA, Calhoun P, et al. The Acute Effects of Real‑World Physical Activity on Glycemia in Adolescents With Type 1 Diabetes: The Type 1 Diabetes Exercise Initiative Pediatric (T1DEXIP) Study. ") + Text("[PMID: 37922335](https://pubmed.ncbi.nlm.nih.gov/37922335/)").underline(), + Text(verbatim: "Zaharieva DP, Yavelberg L, Jamnik V, Cinar A, Turksoy K, Riddell MC. The Effects of Basal Insulin Suspension at the Start of Exercise on Blood Glucose Levels During Continuous Versus Circuit‑Based Exercise in Individuals with Type 1 Diabetes on Continuous Subcutaneous Insulin Infusion. ") + Text("[PMID: 28613947](https://pmc.ncbi.nlm.nih.gov/articles/PMC5510047/)").underline() + ] + case .exerciseAndGlucoseActiveInsulin: + return [ + Text(verbatim: "Riddell MC, Lewis DM, Turner LV, Lal RA, Shahid A, Zaharieva DP. Refining Insulin on Board with netIOB for Automated Insulin Delivery. ") + Text("[PMID: 39143692](https://pmc.ncbi.nlm.nih.gov/articles/PMC11571556/)").underline(), + Text(verbatim: "Li Z, Calhoun P, Rickels MR, Gal RL, Beck RW, Jacobs PG, et al. Factors Affecting Reproducibility of Change in Glucose During Exercise: Results From the Type 1 Diabetes and Exercise Initiative. ") + Text("[PMID: 38456512](https://pmc.ncbi.nlm.nih.gov/articles/PMC11571421/)").underline(), + Text(verbatim: "Zaharieva DP, Morrison D, Paldus B, Lal RA, Buckingham BA, O'Neal DN. Practical Aspects and Exercise Safety Benefits of Automated Insulin Delivery Systems in Type 1 Diabetes. ") + Text("[PMID: 37193203](https://pmc.ncbi.nlm.nih.gov/articles/PMC10182962/)").underline() + ] + case .exerciseAndGlucoseTimeOfDay: + return [ + Text(verbatim: "Riddell MC, Turner LV, Patton SR. Is There an Optimal Time of Day for Exercise? A Commentary on When to Exercise for People Living With Type 1 or Type 2 Diabetes. ") + Text("[PMID: 37193212](https://pmc.ncbi.nlm.nih.gov/articles/PMC10182965/)").underline(), + Text(verbatim: "Morrison D, Paldus B, Zaharieva DP, Lee MH, Vogrin S, Jenkins AJ, et al. Late Afternoon Vigorous Exercise Increases Postmeal but Not Overnight Hypoglycemia in Adults with Type 1 Diabetes Managed with Automated Insulin Delivery. ") + Text("[PMID: 36094458](https://pubmed.ncbi.nlm.nih.gov/36094458/)").underline(), + Text(verbatim: "Yardley JE. Fasting May Alter Blood Glucose Responses to High-Intensity Interval Exercise in Adults With Type 1 Diabetes: A Randomized, Acute Crossover Study. ") + Text("[PMID: 33160882](https://pubmed.ncbi.nlm.nih.gov/33160882/)").underline(), + Text(verbatim: "Toghi-Eshghi SR, Yardley JE. Morning (Fasting) vs Afternoon Resistance Exercise in Individuals With Type 1 Diabetes: A Randomized Crossover Study. ") + Text("[PMID: 31211392](https://academic.oup.com/jcem/article/104/11/5217/5519298)").underline() + ] + case .exerciseAndGlucoseMealTiming: + return [ + Text(verbatim: "McCarthy OM, Christensen MB, Kristensen KB, Schmidt S, Ranjan AG, Bain SC, Bracken RM, Nørgaard K. Automated Insulin Delivery Around Exercise in Adults with Type 1 Diabetes: A Pilot Randomized Controlled Study. ") + Text("[PMID: 37053529](https://pubmed.ncbi.nlm.nih.gov/37053529/)").underline(), + Text(verbatim: "Myette‑Côté É, Molveau J, Wu Z, Raffray M, Devaux M, Tagougui S, et al. A Randomized Crossover Pilot Study Evaluating Glucose Control During Exercise Initiated 1 or 2 h After a Meal in Adults with Type 1 Diabetes Treated with an Automated Insulin Delivery System. ") + Text("[PMID: 36399114](https://pmc.ncbi.nlm.nih.gov/articles/PMC9894601/)").underline(), + Text(verbatim: "Tagougui S, Taleb N, Legault L, Suppère C, Messier V, Boukabous I, Shohoudi A, Ladouceur M, Rabasa-Lhoret R. A single-blind, randomised, crossover study to reduce hypoglycaemia risk during postprandial exercise with closed-loop insulin delivery in adults with type 1 diabetes: announced (with or without bolus reduction) vs unannounced exercise strategies. ") + Text("[PMID: 32740723](https://link.springer.com/article/10.1007/s00125-020-05244-y)").underline() + ] + case .exerciseAndGlucoseCompetitionStress: + return [ + Text(verbatim: "Katz A, Shulkin A, Fortier MA, Yardley JE, Kichler J, Housni A, Talbo MK, Rabasa-Lhoret R, Brazeau AS. Strategies to reduce hyperglycemia-related anxiety in elite athletes with type 1 diabetes: A qualitative analysis. ") + Text("[PMID: 39823464](https://pubmed.ncbi.nlm.nih.gov/39823464/)").underline(), + Text(verbatim: "Riddell MC, Gallen IW, Smart CE, Taplin CE, Adolfsson P, Lumb AN, Kowalski A, Rabasa-Lhoret R, McCrimmon RJ, Hume C, Annan F, Fournier PA, Graham C, Bode B, Galassetti P, Jones TW, Millán IS, Heise T, Peters AL, Petz A, Laffel LM. Exercise management in type 1 diabetes: a consensus statement. ") + Text("[PMID: 28126459](https://pubmed.ncbi.nlm.nih.gov/28126459/)").underline(), + Text(verbatim: "Hobbs N, Brandt R, Maghsoudipour S, Sevil M, Rashid M, Quinn L, Cinar A. Observational Study of Glycemic Impact of Anticipatory and Early-Race Athletic Competition Stress in Type 1 Diabetes. ") + Text("[PMID: 36992757](https://pubmed.ncbi.nlm.nih.gov/36992757/)").underline(), + Text(verbatim: "Riddell MC, Scott SN, Fournier PA, Colberg SR, Gallen IW, Moser O, Stettler C, Yardley JE, Zaharieva DP, Adolfsson P, Bracken RM. The competitive athlete with type 1 diabetes. ") + Text("[PMID: 32533229](https://pubmed.ncbi.nlm.nih.gov/32533229/)").underline() + ] + case .preventingLows: + return [ + Text(verbatim: "Moser O, Zaharieva DP, Adolfsson P, Battelino T, Bracken RM, Buckingham BA, et al. The use of automated insulin delivery around physical activity and exercise in type 1 diabetes: a position statement of the European Association for the Study of Diabetes (EASD) and the International Society for Pediatric and Adolescent Diabetes (ISPAD). ") + Text("[PMID: 39653802](https://pubmed.ncbi.nlm.nih.gov/39653802/)").underline() + ] + case .unplannedActivity: + return [ + Text(verbatim: "Tagougui S, Taleb N, Legault L, Suppère C, Messier V, Boukabous I, Shohoudi A, Ladouceur M, Rabasa-Lhoret R. A single-blind, randomised, crossover study to reduce hypoglycaemia risk during postprandial exercise with closed-loop insulin delivery in adults with type 1 diabetes: announced (with or without bolus reduction) vs unannounced exercise strategies. ") + Text("[PMID: 32740723](https://pubmed.ncbi.nlm.nih.gov/32740723/)").underline(), + Text(verbatim: "Zimmer RT, Auth A, Schierbauer J, Haupt S, Wachsmuth N, Zimmermann P, Voit T, Battelino T, Sourij H, Moser O. (Hybrid) Closed-Loop Systems: From Announced to Unannounced Exercise. ") + Text("[PMID: 38133645](https://pubmed.ncbi.nlm.nih.gov/38133645/)").underline(), + Text(verbatim: "Tagougui S, Taleb N, Legault L, Suppère C, Messier V, Boukabous I, Shohoudi A, Ladouceur M, Rabasa-Lhoret R. A single-blind, randomised, crossover study to reduce hypoglycaemia risk during postprandial exercise with closed-loop insulin delivery in adults with type 1 diabetes: announced (with or without bolus reduction) vs unannounced exercise strategies. ") + Text("[PMID: 32740723](https://pubmed.ncbi.nlm.nih.gov/32740723/)").underline(), + Text(verbatim: "Dovc K, Piona C, Yeşiltepe Mutlu G, Bratina N, Jenko Bizjan B, Lepej D, Nimri R, Atlas E, Muller I, Kordonouri O, Biester T, Danne T, Phillip M, Battelino T. Faster Compared With Standard Insulin Aspart During Day-and-Night Fully Closed-Loop Insulin Therapy in Type 1 Diabetes: A Double-Blind Randomized Crossover Trial. ") + Text("[PMID: 31575640](https://pubmed.ncbi.nlm.nih.gov/31575640/)").underline(), + Text(verbatim: "Dovc K, Macedoni M, Bratina N, Lepej D, Nimri R, Atlas E, Muller I, Kordonouri O, Biester T, Danne T, Phillip M, Battelino T. Closed-loop glucose control in young people with type 1 diabetes during and after unannounced physical activity: a randomised controlled crossover trial. ") + Text("[PMID: 28840263](https://pubmed.ncbi.nlm.nih.gov/28840263/)").underline() + ] + } + } + } + var cta: PresetsTraining.CTA? { switch self { case .customizingPresets: .continue diff --git a/Loop/Views/Presets/Training/PresetsTrainingView.swift b/Loop/Views/Presets/Training/PresetsTrainingView.swift index f2762d4d22..1f817d5a17 100644 --- a/Loop/Views/Presets/Training/PresetsTrainingView.swift +++ b/Loop/Views/Presets/Training/PresetsTrainingView.swift @@ -109,6 +109,12 @@ public struct PresetsTrainingView: View { .padding(.bottom, 24) .padding(.horizontal, 16) + if !step.references.isEmpty { + ReferencesView(step.references) + .padding(.bottom, 24) + .padding(.horizontal, 16) + } + if let cta = step.cta { Spacer(minLength: 0) From 15b64fc4cd90ac4b09b4816a8070fe9a66ec9b08 Mon Sep 17 00:00:00 2001 From: LoopKit Developer Date: Thu, 9 Apr 2026 15:26:39 -0500 Subject: [PATCH 393/421] Update string catalogs from Xcode build after Tidepool sync Xcode extracted new localizable strings and marked stale entries after the tidepool-sync/2026-03-10 merge and build. --- .../Bootstrap/Localizable.xcstrings | 5 + Loop/Localizable.xcstrings | 1222 ++++++++++++++++- LoopUI/Localizable.xcstrings | 27 + WatchApp/InfoPlist.xcstrings | 24 + 4 files changed, 1267 insertions(+), 11 deletions(-) diff --git a/Loop Widget Extension/Bootstrap/Localizable.xcstrings b/Loop Widget Extension/Bootstrap/Localizable.xcstrings index 9ada460552..c9e1ce54e9 100644 --- a/Loop Widget Extension/Bootstrap/Localizable.xcstrings +++ b/Loop Widget Extension/Bootstrap/Localizable.xcstrings @@ -154,6 +154,7 @@ } }, "??" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -867,6 +868,7 @@ }, "g" : { "comment" : "The short unit display string for grams", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1118,6 +1120,7 @@ }, "mg/dL" : { "comment" : "The short unit display string for milligrams of glucose per decilter", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1243,6 +1246,7 @@ }, "mmol/L" : { "comment" : "The short unit display string for millimoles of glucose per liter", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1637,6 +1641,7 @@ }, "U" : { "comment" : "The short unit display string for international units of insulin", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index c298b9f5d1..182fc80fe0 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -3,6 +3,18 @@ "strings" : { "" : { + }, + " " : { + + }, + " " : { + + }, + " -" : { + + }, + " - " : { + }, " (pending: %@)" : { "comment" : "The string format appended to active insulin that describes pending insulin. (1: pending insulin)", @@ -129,6 +141,36 @@ } } } + }, + " **Tip** Use your %@ **Jogging** preset" : { + + }, + " **Tip** Use your %@ **Walking** preset" : { + + }, + " / " : { + + }, + " %@" : { + + }, + " %@" : { + + }, + " %@ · " : { + + }, + " %@ read" : { + + }, + " %lld hours ago" : { + + }, + " grams" : { + + }, + " of " : { + }, " Pre-meal Preset" : { "comment" : "Status row title for premeal override enabled (leading space is to separate from symbol)", @@ -334,6 +376,7 @@ }, " Safety Notifications are OFF" : { "comment" : "Warning text for when Notifications or Critical Alerts Permissions is disabled", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -414,9 +457,13 @@ } } } + }, + " setting. Adjusting it can help reduce the risk of low glucose if you expect unusual fluctuations." : { + }, " Workout Preset" : { "comment" : "Status row title for workout override enabled (leading space is to separate from symbol)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -510,6 +557,12 @@ } } }, + "-.--" : { + + }, + "–" : { + "comment" : "String denoting lack of a recommended bolus amount in the simple bolus calculator" + }, "." : { "comment" : "Full stop character", "localizations" : { @@ -586,6 +639,24 @@ } } } + }, + "**Tip: If glucose rises to >270 mg/dl,** check %@ to see if a bolus is recommended to bring your glucose back into range." : { + + }, + "**Tip:** Try exercising when your active insulin is close to zero at the start of an activity." : { + + }, + "**Try:** If you often experience low glucose, consider exercising earlier in the day before eating." : { + + }, + "**Try:** Reducing your meal bolus if you expect your glucose to drop." : { + + }, + "%@" : { + + }, + "%@ " : { + }, "%@ %@" : { "comment" : "The format for an active custom preset. (1: preset symbol)(2: preset name)", @@ -842,6 +913,15 @@ } } } + }, + "%@ of insulin" : { + + }, + "%@ read" : { + + }, + "%@ Recommended starting values" : { + }, "%@ remaining" : { "comment" : "Estimated remaining duration with more than a minute", @@ -940,6 +1020,7 @@ }, "%@ U Total" : { "comment" : "The subtitle format describing total insulin. (1: localized insulin total)", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1829,6 +1910,7 @@ }, "%1$@ APP SOUNDS" : { "comment" : "App sounds title text (1: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1862,6 +1944,15 @@ } } }, + "%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off in the app." : { + "comment" : "Active preset reminder alert background body. (1: preset name)" + }, + "%1$@ has been active for more than 24 hours. Make sure you still want it enabled, or turn it off." : { + "comment" : "Active preset reminder alert foreground body. (1: preset name)" + }, + "%1$@ has set your correction range to 110 mg/dL or higher." : { + "comment" : "The format string for the high insulin needs preset warning text on the preset detent screen when stopping a preset. (1: app name)" + }, "%1$@ is operating with Closed Loop in the OFF position. Your pump and CGM will continue operating, but the app will not adjust dosing automatically." : { "comment" : "Alert message for closed loop off informational modal. (1: app name)", "localizations" : { @@ -2111,6 +2202,9 @@ } } }, + "%1$@ Still Active" : { + "comment" : "The format title for the preset still active alert. (1: preset name)" + }, "%1$@ Time Settings Need Attention" : { "comment" : "Time change alert title", "localizations" : { @@ -2810,6 +2904,9 @@ } } }, + "%1$@ will set your correction range to 110 mg/dL or higher when this preset is enabled." : { + "comment" : "The format string for the high insulin needs preset warning text on the preset detent screen when starting a preset. (1: app name)" + }, "%1$@ will stop working in %2$@. You will need to rebuild before that." : { "comment" : "Format string for body for notification of upcoming expiration. (1: app name) (2: amount of time until expiration", "localizations" : { @@ -3147,6 +3244,9 @@ } } } + }, + "•" : { + }, "⚠️" : { "localizations" : { @@ -3205,6 +3305,21 @@ } } } + }, + "0" : { + + }, + "3-6" : { + + }, + "6-9" : { + + }, + "9-20" : { + + }, + "10" : { + }, "15 min glucose regression coefficient (b₁), continued with decay over 30 min" : { "comment" : "Description of the prediction input effect for glucose momentum", @@ -3330,6 +3445,9 @@ } } } + }, + "25-33%" : { + }, "30 min comparison of glucose prediction vs actual, continued with decay over 60 min" : { "comment" : "Description of the prediction input effect for retrospective correction", @@ -3449,6 +3567,9 @@ } } } + }, + "100m sprint" : { + }, "A few seconds remaining" : { "comment" : "Estimated remaining duration with a few seconds", @@ -4128,6 +4249,21 @@ } } } + }, + "A percentage **above 100%** tells the system you need **more** insulin" : { + + }, + "A percentage **below 100%** tells the system you need **less** insulin" : { + + }, + "A preset with %@ overall insulin is on. The system is currently delivering less than your preset baseline." : { + + }, + "A preset with %@ overall insulin is on. The system is currently delivering more than your preset baseline." : { + + }, + "A preset with %@ overall insulin is on. This is your new preset baseline and it overrides your Scheduled Basal." : { + }, "A pump must be configured before a bolus can be delivered." : { "comment" : "Alert message for a missing pump error", @@ -5219,6 +5355,9 @@ } } } + }, + "Add Carbs" : { + }, "Add CGM" : { "comment" : "Action sheet title selecting CGM\nThe title of the CGM chooser in settings\nTitle text for button to add CGM device\nTitle text for button to set up a CGM", @@ -5382,6 +5521,7 @@ }, "Add Meal" : { "comment" : "The label of the carb entry button", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -5748,6 +5888,9 @@ } } } + }, + "Adjust Preset Duration" : { + }, "Adjusted for" : { "localizations" : { @@ -5830,6 +5973,12 @@ } } } + }, + "Aerobic" : { + + }, + "Afternoon Exercise" : { + }, "Alert Management" : { "comment" : "Alert Permissions button text\nTitle of alert management screen", @@ -5916,6 +6065,7 @@ }, "Alert Permissions" : { "comment" : "Alert Permissions button text\nNotification & Critical Alert Permissions screen title", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6011,6 +6161,7 @@ }, "Alert Permissions and Mute Alerts" : { "comment" : "Alert Permissions descriptive text", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6296,6 +6447,7 @@ }, "All Alerts Muted" : { "comment" : "Warning text for when alerts are muted", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6355,6 +6507,7 @@ }, "All alerts muted until" : { "comment" : "Label for when mute alert will end", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6405,6 +6558,18 @@ } } } + }, + "All App Sounds Muted" : { + "comment" : "Warning text for when alerts are muted" + }, + "All app sounds, including sounds for all critical alerts such as Urgent Low, Sensor Fail, Pump Expiration, and others will NOT sound." : { + "comment" : "Warning label that all alerts will not sound" + }, + "All app sounds, including sounds for all critical alerts, are currently muted.\n\nTap Unmute to resume app sounds for your alerts." : { + "comment" : "The alert body for unmute alert confirmation" + }, + "All Events" : { + }, "All Favorites" : { "comment" : "section header for list of existing FavoriteFoods", @@ -6458,6 +6623,12 @@ } } } + }, + "All Out" : { + + }, + "All Presets" : { + }, "Amount Consumed" : { "comment" : "Label for carb quantity entry row on carb entry screen", @@ -6646,6 +6817,7 @@ }, "An active override is modifying your carb ratio and insulin sensitivity. If you don't want this to affect your bolus calculation and projected glucose, consider turning off the override." : { "comment" : "Warning to ensure the carb entry is accurate during an override", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -6966,6 +7138,7 @@ }, "An updated bolus recommendation is available." : { "comment" : "Alert message when glucose data returns while on bolus screen", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -8165,6 +8338,12 @@ } } } + }, + "Are you sure you want to turn automation OFF?" : { + "comment" : "Closed loop alert title" + }, + "ask yourself, am I more likely to go high or low during this event?" : { + }, "at %@" : { "comment" : "Format fragment for a specific time", @@ -8290,6 +8469,12 @@ } } } + }, + "at %1$@" : { + "comment" : "when adding a timestamp. (1: the formatted timestamp)\nwhen adding the date and time. (1: the formatted date and time)" + }, + "At 100%%, %@ assumes your insulin needs are the same as usual." : { + }, "Authenticate to Bolus %@ Units" : { "comment" : "The message displayed during a device authentication prompt for bolus specification", @@ -8504,6 +8689,57 @@ } } } + }, + "Automated" : { + + }, + "Automated (↑ Increase)" : { + + }, + "Automated (↓ Decrease)" : { + + }, + "Automated (Preset Basal Rate)" : { + + }, + "Automated (Scheduled)" : { + + }, + "Automated insulin adjustments by %@ reduce a noticeable rise in glucose" : { + + }, + "Automation" : { + + }, + "Automation is off" : { + "comment" : "title for when automation is off" + }, + "Automation is on" : { + "comment" : "title for when automation is on" + }, + "Automation is unavailable" : { + "comment" : "title for when automation is unavailable" + }, + "Automation is unavailable while your CGM sensor is warming up.\n\nIn the meantime, your pump is still able to deliver insulin.\n\nAutomation will resume when CGM readings are received." : { + "comment" : "message when automation is on and CGM is in warmup" + }, + "Automation is unavailable while your insulin is suspended.\n\nResume insulin if you wish for the app to automate insulin delivery." : { + "comment" : "message when automation is on and insulin delivery is suspended" + }, + "Automation OFF" : { + + }, + "Automation ON" : { + + }, + "Automation unavailable" : { + + }, + "Automation was unsuccessful" : { + "comment" : "title for when automation was unsuccessful" + }, + "Basal Rate" : { + }, "Basal Rate Schedule" : { "comment" : "Details for configuration error when basal rate schedule is missing", @@ -8767,6 +9003,9 @@ } } } + }, + "Basal: " : { + }, "Based on your predicted glucose, no bolus is recommended." : { "comment" : "Caption for bolus screen notice when no bolus is recommended for the predicted glucose", @@ -8850,6 +9089,12 @@ } } } + }, + "Because Omar has gone low while working outdoors in the past, he raises his preset correction range to help prevent another low." : { + + }, + "Before" : { + }, "Bluetooth\nOff" : { "comment" : "Message to the user to that the bluetooth is off", @@ -9220,7 +9465,7 @@ } }, "Bolus" : { - "comment" : "Label for bolus entry row on bolus screen\nLabel for bolus entry row on simple bolus screen\nThe label of the bolus entry button\nTitle for bolus entry screen", + "comment" : "Label for bolus entry row on bolus screen\nLabel for bolus entry row on simple bolus screen\nTitle for bolus entry screen", "localizations" : { "ar" : { "stringUnit" : { @@ -9344,6 +9589,9 @@ } } }, + "Bolus Canceled: Delivered %1$@ of %2$@" : { + "comment" : "The title of the cell indicating a bolus has been canceled. (1: delivered volume)(2: total volume)" + }, "Bolus Issue" : { "comment" : "The notification title for a bolus issue", "localizations" : { @@ -9693,6 +9941,9 @@ } } } + }, + "Bolus: " : { + }, "Bolused %1$@ of %2$@" : { "comment" : "The format string for bolus progress. (1: delivered volume)(2: total volume)", @@ -10004,6 +10255,15 @@ } } }, + "Breathing is slightly uncomfortable. Conversation requires maximal effort." : { + + }, + "Breathing more heavily. Can carry on a conversation, but requires more effort." : { + + }, + "Can I use Focus modes with %1$@?" : { + "comment" : "Focus modes section title (1: app name)" + }, "Cancel" : { "comment" : "Button label for cancel\nButton text to cancel\nCancel button for reset loop alert\nCancel export button title\nThe title of the cancel action in an action sheet", "localizations" : { @@ -10532,6 +10792,9 @@ } } } + }, + "Carb Ratio" : { + }, "Carb Ratio Schedule" : { "comment" : "Details for configuration error when carb ratio schedule is missing", @@ -11252,6 +11515,9 @@ } } } + }, + "CAUTION - Investigational device. Limited by Federal (or United States) law to investigational use." : { + }, "Change the pump battery immediately" : { "comment" : "The notification alert describing a low pump battery", @@ -11517,6 +11783,12 @@ } } }, + "Changing it can lower the chance of your glucose levels going too low if you expect unusual changes." : { + + }, + "Check for potential communication issues with your CGM.\n\nIn the meantime, your pump is still able to deliver insulin." : { + "comment" : "message when automation is off and CGM is in signal loss" + }, "Check settings" : { "comment" : "Details for configuration error when one or more loop settings are missing", "extractionState" : "manual", @@ -11958,6 +12230,9 @@ } } } + }, + "Check your glucose levels around 20 to 30 min after eating. If you're still low, consider eating the same amount." : { + }, "Choose a longer absorption time for larger meals, or those containing fats and proteins. This is only guidance to the algorithm and need not be exact." : { "comment" : "Carb entry section footer text explaining absorption time", @@ -12124,9 +12399,12 @@ } } } + }, + "Choose the glucose value (or values) you want %@ to target when changing how much basal insulin you get." : { + }, "Close" : { - "comment" : "Button title to close view\nThe button label of the action used to dismiss the unsafe notification permission alert", + "comment" : "Preset training needed alert cancel button\nThe button label of the action used to dismiss the unsafe notification permission alert", "localizations" : { "da" : { "stringUnit" : { @@ -12213,6 +12491,9 @@ } } } + }, + "Close Training" : { + }, "Closed Loop" : { "comment" : "The title text for the looping enabled switch cell", @@ -12959,6 +13240,15 @@ } } } + }, + "Combination of high and low intensity" : { + + }, + "Competition Stress" : { + + }, + "Complete each section below to learn how presets can be used for a variety of situations." : { + }, "Complete Setup" : { "comment" : "Title text for button to complete setup", @@ -13033,6 +13323,7 @@ }, "Configuration" : { "comment" : "The title of the Configuration section in settings", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -13303,6 +13594,15 @@ } } } + }, + "Congratulations! You've finished the Presets training." : { + + }, + "Consider an exercise you do regularly and think about how hard you push yourself." : { + + }, + "Consider eating around 10 to 20 grams of fast-acting carbs" : { + }, "Continue" : { "comment" : "Button label for continue\nDefault alert dismissal", @@ -13434,6 +13734,9 @@ } } } + }, + "Continue to %@" : { + }, "Continuous Glucose Monitor" : { "comment" : "Descriptive text for Continuous Glucose Monitor", @@ -13553,6 +13856,9 @@ } } } + }, + "Continuous or exercise without breaks" : { + }, "Correction Range" : { "comment" : "The title of the glucose target range schedule screen\n The title text for the glucose target range schedule", @@ -13679,6 +13985,12 @@ } } } + }, + "Correction range is a " : { + + }, + "Correction range is a **safety setting**. Changing it can help lower your risk of going low if you expect unusual changes." : { + }, "Could Not Restart %1$@" : { "comment" : "Format string for title of reset loop alert. (1: App name)", @@ -13726,6 +14038,9 @@ } } } + }, + "Create new presets" : { + }, "Critical Alerts" : { "comment" : "Critical Alerts Status text", @@ -13816,6 +14131,33 @@ } } }, + "Critical Alerts and Time Sensitive Notifications are important types of iOS notifications used for events that require immediate attention. Examples include:" : { + + }, + "Critical Alerts and Time Sensitive Notifications are turned OFF" : { + "comment" : "Both Critical Alerts and Time Sensitive Notifications disabled banner title" + }, + "Critical Alerts and Time Sensitive Notifications are turned OFF. Go to the App to fix the issue now." : { + "comment" : "Both Critical Alerts and Time Sensitive Notifications disabled notification body" + }, + "Critical Alerts and Time Sensitive Notifications are turned off. You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Critical Alerts and Time Sensitive Notifications are turned ON." : { + "comment" : "Both Critical Alerts and Time Sensitive Notifications disabled alert body" + }, + "Critical Alerts and Time Sensitive Notifications are turned off. You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Notifications and Critical Alerts are turned ON." : { + "comment" : "Both Notifications and Critical Alerts disabled alert body" + }, + "Critical Alerts are turned OFF" : { + "comment" : "Critical alerts disabled banner title" + }, + "Critical Alerts are turned OFF. Go to the App to fix the issue now." : { + "comment" : "Critical alerts disabled notification body" + }, + "Critical Alerts are turned off. You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Critical Alerts are turned ON." : { + "comment" : "Critical alerts disabled alert body" + }, + "Critical Alerts will still sound, but all others will be silenced." : { + "comment" : "Additional description text for temporarily silencing non-critical alerts" + }, "Critical Event Log Ready" : { "comment" : "Critical event log ready text", "localizations" : { @@ -14064,6 +14406,15 @@ } } } + }, + "CrossFit" : { + + }, + "Current Basal Rate" : { + + }, + "Current Delivery" : { + }, "Current Glucose" : { "comment" : "Label for glucose entry row on simple bolus screen", @@ -14436,6 +14787,7 @@ }, "Custom Preset" : { "comment" : "The title of the cell indicating a generic custom preset is enabled", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14648,6 +15000,9 @@ } } }, + "Date Created" : { + "comment" : "Preset sorting option description for sorting by date created" + }, "dB" : { "comment" : "The short unit display string for decibles", "localizations" : { @@ -14766,6 +15121,12 @@ } } } + }, + "decrease" : { + + }, + "Decreased Delivery" : { + }, "Delete" : { "localizations" : { @@ -15855,6 +16216,9 @@ } } } + }, + "Delivery Details" : { + }, "Delivery Limits" : { "comment" : "Title text for delivery limits", @@ -15993,6 +16357,15 @@ } } } + }, + "Depending on the activity, you may notice a few common patterns when it comes to your insulin needs:" : { + + }, + "Device Issue" : { + "comment" : "title for when automation is off and there is a device issue" + }, + "Devices" : { + }, "Diabetes Treatment" : { "comment" : "Descriptive text for Therapy Settings", @@ -16153,9 +16526,13 @@ } } } + }, + "Difficulty maintaining exercise or holding a conversation." : { + }, "Disables" : { "comment" : "The action hint of the workout mode toggle button when enabled", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -16274,7 +16651,7 @@ } }, "Dismiss" : { - "comment" : "Default alert dismissal\nThe button label of the action used to dismiss an error alert", + "comment" : "Default alert dismissal", "localizations" : { "ar" : { "stringUnit" : { @@ -16437,6 +16814,9 @@ } } }, + "Don't Start" : { + "comment" : "Label for do not start preset action on scheduled preset reminder alert\nThe title of the notification action to not start a preset" + }, "Done" : { "localizations" : { "cs" : { @@ -16625,6 +17005,9 @@ } } } + }, + "Dose Type" : { + }, "Dosing Strategy" : { "comment" : "The title of the Dosing Strategy section in settings", @@ -16714,6 +17097,9 @@ } } } + }, + "Duration" : { + }, "Duration exceeds: %1$.1f hours" : { "comment" : "Override error description: duration exceed max (1: max duration in hours).", @@ -16785,6 +17171,24 @@ } } } + }, + "Duration: %@" : { + + }, + "During this kind of hard exercise, your body may release hormones that raise glucose. This is more common in the morning before eating." : { + + }, + "Easy breath. Can carry on a conversation." : { + + }, + "Edit Food" : { + + }, + "Edit Preset" : { + + }, + "Edit presets" : { + }, "Enable\nBluetooth" : { "comment" : "Message to the user to enable bluetooth", @@ -17006,6 +17410,7 @@ }, "Enables" : { "comment" : "The action hint of the workout mode toggle button when disabled", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -17123,6 +17528,21 @@ } } }, + "End" : { + + }, + "End Preset" : { + + }, + "End Time" : { + + }, + "End Training?" : { + + }, + "Ensure that notifications are allowed and NOT silenced from %1$@." : { + "comment" : "Focus modes step 4 (1: appName)" + }, "Enter a blood glucose from a meter for a recommended bolus amount." : { "comment" : "Caption for bolus screen notice when glucose data is missing or stale", "localizations" : { @@ -17956,6 +18376,9 @@ } } } + }, + "Event" : { + }, "Event History" : { "comment" : "Segmented button title for insulin delivery log event history", @@ -18069,9 +18492,13 @@ } } } + }, + "Eventually" : { + }, "Eventually %@" : { "comment" : "The subtitle format describing eventual glucose. (1: localized glucose value description)", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -18189,6 +18616,15 @@ } } }, + "Example" : { + + }, + "Example: Allow Notifications from %1$@" : { + "comment" : "Focus mode image 1 caption (1: appName)" + }, + "Example: Silence Notifications from other apps" : { + "comment" : "Focus mode image 2 caption" + }, "Exceeds maximum allowed bolus in settings" : { "comment" : "Bolus error description: bolus exceeds maximum bolus in settings.", "localizations" : { @@ -18455,6 +18891,12 @@ } } } + }, + "Exercise is a common reason to use a preset. Different kinds of exercise and their intensity levels can affect your glucose levels in different ways." : { + + }, + "Explosive sprints or bursts" : { + }, "Export Critical Event Logs" : { "comment" : "The title of the export critical event logs in support", @@ -18722,6 +19164,18 @@ } } } + }, + "Falling / Falling Quickly" : { + + }, + "Falling Slowly" : { + + }, + "FAQ about Alerts" : { + "comment" : "View title for how mute alerts work" + }, + "Favorite Food Insights" : { + }, "Favorite Foods" : { "comment" : "Title for Favorite Foods view", @@ -18954,6 +19408,12 @@ } } } + }, + "Filter" : { + + }, + "Filtered by:" : { + }, "Fingerstick Glucose" : { "comment" : "Label for manual glucose entry row on bolus screen", @@ -19044,8 +19504,21 @@ } } }, + "Fix now by turning Critical Alerts and Time Sensitive Notifications ON." : { + "comment" : "Both Critical Alerts and Time Sensitive Notifications disabled banner body" + }, + "Fix now by turning Critical Alerts ON." : { + "comment" : "Critical alerts disabled banner body" + }, + "Fix now by turning Notifications and Critical Alerts ON." : { + "comment" : "Both Critical Alerts and Notifications disabled banner body" + }, + "Fix now by turning Notifications ON." : { + "comment" : "Notifications disabled banner body" + }, "Fix now by turning Notifications, Critical Alerts and Time Sensitive Notifications ON." : { "comment" : "Secondary text for alerts disabled warning, which appears on the main status screen.", + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -19127,6 +19600,9 @@ } } }, + "Fix now by turning Time Sensitive Notifications ON." : { + "comment" : "Time sensitive notifications disabled banner body" + }, "Food Type" : { "comment" : "Label for food type entry on add favorite food screen", "localizations" : { @@ -19248,6 +19724,7 @@ }, "For %1$@" : { "comment" : "The format string used to describe a finite workout targets duration", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -19364,9 +19841,16 @@ } } } + }, + "For activities that raise your risk of going low, you can set a higher temporary correction range." : { + + }, + "For mixed-intensity activity:" : { + }, "For safety purposes, you should allow Critical Alerts, Time Sensitive and Notification Permissions (non-critical alerts) on your device to continue using %1$@ and cannot turn off individual alarms." : { "comment" : "Description text for silencing time sensitive and non-critical alerts (1: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -19417,6 +19901,15 @@ } } } + }, + "For some people, routine chores and everyday activities can affect glucose levels similar to exercise." : { + + }, + "For these activities, consider setting your insulin needs to **less than 100%**." : { + + }, + "For these activities, consider setting your insulin needs to **more than 100%**." : { + }, "Forecasted blood glucose may still be higher than target range." : { "localizations" : { @@ -19579,6 +20072,7 @@ }, "Frequently asked questions about alerts" : { "comment" : "Label for link to see frequently asked questions", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -19629,9 +20123,13 @@ } } } + }, + "Full out effort. No conversation possible." : { + }, "g" : { "comment" : "The short unit display string for grams", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -19757,6 +20255,7 @@ }, "Get help with Alert Permissions" : { "comment" : "Get help with Alert Permissions support button text", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -19832,6 +20331,9 @@ } } }, + "Get help with iOS Permissions" : { + "comment" : "Get help with iOS Permissions support button text" + }, "Glucose" : { "comment" : "The title of the glucose and prediction graph\nTitle for predicted glucose chart on bolus screen", "localizations" : { @@ -20015,6 +20517,9 @@ } } } + }, + "Glucose Change Chart" : { + }, "Glucose data is %1$@ old" : { "comment" : "The error message when glucose data is too old to be used. (1: glucose data age in minutes)", @@ -20256,6 +20761,7 @@ }, "Glucose Data Now Available" : { "comment" : "Alert title when glucose data returns while on bolus screen", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -20668,8 +21174,15 @@ } } } + }, + "Go to Settings > Focus." : { + "comment" : "Focus modes step 1" + }, + "grams" : { + }, "HARDWARE SOUNDS" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -20720,6 +21233,12 @@ } } } + }, + "Her bolus recommendation is higher than usual because her overall insulin is set higher." : { + + }, + "Her preset is set to **110%%**, which is more than she usually needs. This means %@ will make her basal rates, carb ratio, and insulin sensitivity factor (ISF) stronger. " : { + }, "High Glucose" : { "localizations" : { @@ -20772,6 +21291,18 @@ } } } + }, + "High Intensity (Anaerobic)" : { + + }, + "High-intensity exercise means pushing yourself to your **maximum effort**. It is so hard that talking is nearly impossible, and you can’t keep it up for very long." : { + + }, + "Hiking" : { + + }, + "Historical Data" : { + }, "How can I silence non-Critical Alerts?" : { "localizations" : { @@ -20826,6 +21357,7 @@ } }, "How can I silence only Time Sensitive and Non-Critical alerts?" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -20929,6 +21461,12 @@ } } } + }, + "How does her preset impact her bolus recommendation?" : { + + }, + "How to configure each setting" : { + }, "How to update (LoopDocs)" : { "comment" : "The title text for how to update", @@ -21012,6 +21550,15 @@ } } } + }, + "How to use Presets for everyday activities" : { + + }, + "How to use Presets for exercise" : { + + }, + "How to use Presets when you are sick" : { + }, "https://mysite.herokuapp.com" : { "comment" : "The placeholder text for the nightscout site URL credential", @@ -21024,9 +21571,16 @@ } } } + }, + "if eating less than 2 hours before exercise" : { + + }, + "If glucose drops below 126 mg/dL" : { + }, "If iOS Focus Mode is ON and Mute Alerts is OFF, Critical Alerts will still be delivered and non-Critical Alerts will be silenced until %1$@ is added to each Focus mode as an Allowed App." : { "comment" : "Focus modes descriptive text (1: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -21077,6 +21631,33 @@ } } } + }, + "If you expect your glucose to rise during the activity, you may not need a preset." : { + + }, + "If you have active insulin in your body when you start exercising, you generally have an increased risk of low glucose." : { + + }, + "If you haven’t noticed a rise in glucose with high-intensity exercise, it may be due to:" : { + + }, + "If you often experience low glucose, you may need to reduce how much insulin you deliver for meals eaten 1-2 hours before exercising." : { + + }, + "If you usually experience lows while exercising, watch your glucose levels closely during exercise and consider eating around 3 to 20g of fast-acting carbs." : { + + }, + "If your activity has a higher risk of low glucose, start a physical activity preset at least **1 hour before you begin** and keep it on until you finish." : { + + }, + "If your glucose goes down, you may only need a small decrease in insulin — less than you would for low to moderate-intensity activity." : { + + }, + "If your glucose goes up, you may only need a small increase in insulin — less than you would for high-intensity activity." : { + + }, + "If your glucose isn't dropping, eating too many carbs can raise your blood sugar, trigger more insulin, and increase the risk of low blood sugar during or after the activity." : { + }, "Immediate" : { "comment" : "Immediate Delivery status text", @@ -21207,6 +21788,15 @@ } } } + }, + "Includes basal and automated boluses" : { + + }, + "increase" : { + + }, + "Increased Delivery" : { + }, "Indefinitely" : { "comment" : "The title of a target alert action specifying an indefinitely long workout targets duration", @@ -21677,6 +22267,9 @@ } } }, + "Insulin Automation" : { + "comment" : "Closed loop settings button descriptive text" + }, "Insulin Delivery" : { "comment" : "The title of the insulin delivery graph", "localizations" : { @@ -21801,6 +22394,9 @@ } } } + }, + "Insulin Delivery Log" : { + }, "Insulin effects" : { "comment" : "Details for missing data error when insulin effects are missing\nDetails for missing data error when insulin effects including pending insulin are missing", @@ -21920,6 +22516,9 @@ } } } + }, + "Insulin Event" : { + }, "Insulin Model" : { "comment" : "Details for configuration error when insulin model is missing\n The title text for the insulin model setting row", @@ -22147,6 +22746,9 @@ } } } + }, + "Insulin Resumed" : { + }, "Insulin Sensitivities" : { "comment" : "The title of the insulin sensitivities schedule screen\n The title text for the insulin sensitivity schedule", @@ -22273,6 +22875,9 @@ } } } + }, + "Insulin Sensitivity Factor (ISF)" : { + }, "Insulin Sensitivity Schedule" : { "comment" : "Details for configuration error when insulin sensitivity schedule is missing", @@ -22705,6 +23310,9 @@ } } } + }, + "Interval Training " : { + }, "Invalid absorption time: %1$@ hours" : { "comment" : "Carb error description: invalid absorption time. (1: Input duration in hours).", @@ -23186,8 +23794,12 @@ } } } + }, + "iOS" : { + }, "iOS Critical Alerts and Time Sensitive Alerts are types of Apple notifications. They are used for high-priority events. Some examples include:" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -23239,7 +23851,11 @@ } } }, + "iOS Focus Modes" : { + "comment" : "View title for iOS focus modes\niOS focus modes navigation link label" + }, "IOS FOCUS MODES" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -23290,6 +23906,21 @@ } } } + }, + "iOS Focus Modes enable you to have more control over when apps can send you notifications. If you decide to use these, ensure that notifications are allowed and NOT silenced from %1$@." : { + "comment" : "Description text for focus modes (1: app name)" + }, + "iOS has added features such as ‘Focus Mode’ that enable you to have more control over when apps can send you notifications.\n\nIf you wish to continue receiving important notifications from %1$@ while in a Focus Mode, you must ensure that notifications are allowed and NOT silenced from %1$@ for each Focus Mode." : { + "comment" : "Description text for iOS Focus Modes (1: app name) (2: app name)" + }, + "iOS Permissions" : { + "comment" : "Notification & Critical Alert Permissions screen title\niOS Permissions button text" + }, + "iOS Permissions and Mute All App Sounds" : { + "comment" : "Alert Permissions descriptive text" + }, + "ISF" : { + }, "Issue Report" : { "comment" : "The title text for the issue report menu item\nThe view controller title for the issue report screen", @@ -23481,6 +24112,12 @@ } } }, + "Jogging" : { + + }, + "Keep All Notifications ON for %1$@" : { + "comment" : "Time sensitive notifications callout title (1: app name)" + }, "Large Meal Entered" : { "comment" : "Title of the warning shown when a large meal was entered", "localizations" : { @@ -23563,6 +24200,24 @@ } } } + }, + "Larger glucose drop" : { + + }, + "Last Auto Bolus" : { + + }, + "Last Bolus: " : { + + }, + "Last loop completed" : { + + }, + "Last Used" : { + "comment" : "Preset sorting option description for sorting by last used" + }, + "Later that day, Paloma eats a meal with about 30g of carbs." : { + }, "Launches CGM app" : { "comment" : "Glucose HUD accessibility hint", @@ -23691,7 +24346,7 @@ } }, "Learn More" : { - "comment" : "OK button title for alert shown when delivery status is uncertain", + "comment" : "Learn more section header\nOK button title for alert shown when delivery status is uncertain", "localizations" : { "da" : { "stringUnit" : { @@ -23779,6 +24434,9 @@ } } }, + "Learn more about Alerts" : { + "comment" : "Link to learn more about alerts" + }, "Less than a minute remaining" : { "comment" : "Estimated remaining duration with less than a minute", "localizations" : { @@ -23867,6 +24525,18 @@ } } } + }, + "Let’s walk through a simple example to show how a preset might be used in this situation." : { + + }, + "Let’s walk through some examples to show how presets might be used in these situations." : { + + }, + "Light Intensity (Aerobic)" : { + + }, + "Light-to-moderate intensity exercise can cause a drop in glucose levels. This is because your body uses glucose (or sugar) for energy during physical activity." : { + }, "Live activity" : { "comment" : "Alert Permissions live activity\nLive activity screen title", @@ -24813,6 +25483,9 @@ } } }, + "Loop is already looping." : { + "comment" : "The error message displayed for LoopError.loopInProgress errors." + }, "Loop normally gives 40% of your predicted insulin needs each dosing cycle.\n\nWhen the Glucose Based Partial Application experiment is enabled, Loop will vary the percentage of recommended bolus delivered each cycle with glucose level.\n\nNear correction range, it will use 20% (similar to Temp Basal), and gradually increase to a maximum of 80% at high glucose (200 mg/dL, 11.1 mmol/L).\n\nPlease be aware that during fast rising glucose, such as after an unannounced meal, this feature, combined with velocity and retrospective correction effects, may result in a larger dose than your ISF would call for." : { "comment" : "Description of Glucose Based Partial Application toggle.", "localizations" : { @@ -25192,8 +25865,15 @@ } } }, + "Make sure to keep Notifications, Time Sensitive Notifications, and Critical Alerts turned ON in iOS Settings to receive essential safety and maintenance notifications." : { + "comment" : "Time sensitive notifications callout message" + }, + "Manage iOS Permissions" : { + "comment" : "Manage Permissions in Settings button text" + }, "Manage Permissions in Settings" : { "comment" : "Manage Permissions in Settings button text", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -25274,9 +25954,13 @@ } } } + }, + "Managing Activities of Daily Living" : { + }, "Managing Alerts" : { "comment" : "View title for how mute alerts work", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -25327,6 +26011,9 @@ } } } + }, + "Manual" : { + }, "Manual Dose: %1$@ %2$@" : { "comment" : "Description of a bolus dose entry (1: value (? if no value) in bold, 2: unit)", @@ -25689,6 +26376,15 @@ } } } + }, + "Maximum Intensity (Anaerobic)" : { + + }, + "May experience a rise in glucose." : { + + }, + "May experience drops in glucose." : { + }, "Meal Bolus" : { "comment" : "Title for bolus entry screen when also entering carbs", @@ -25784,9 +26480,19 @@ } } } + }, + "Meal Summary" : { + + }, + "Meal Timing" : { + + }, + "Medium Intensity (Aerobic)" : { + }, "mg/dL" : { "comment" : "The short unit display string for milligrams of glucose per decilter", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -26182,9 +26888,13 @@ } } } + }, + "Mixed-intensity exercise may cause only small changes in glucose levels. Your glucose may go up or down." : { + }, "mmol/L" : { "comment" : "The short unit display string for millimoles of glucose per liter", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -26455,6 +27165,9 @@ } } } + }, + "Monitor your glucose and active insulin at the start of a competition day" : { + }, "More Info" : { "comment" : "Text for more info action on notification of upcoming TestFlight expiration\nText for more info action on notification of upcoming profile expiration", @@ -26568,9 +27281,19 @@ } } } + }, + "Morning Exercise" : { + + }, + "Morning exercise before eating (like a fasted jog) usually causes a smaller drop in glucose levels and may even promote a rise, compared to afternoon exercise." : { + + }, + "Mutable" : { + }, "Mute All Alerts" : { "comment" : "Label for button to mute all alerts", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -26654,6 +27377,7 @@ }, "Mute All Alerts Temporarily" : { "comment" : "Title for mute alert duration selection action sheet", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -26705,8 +27429,14 @@ } } }, + "Mute All App Sounds" : { + "comment" : "Label for button to mute all app sounds" + }, + "Muted until" : { + "comment" : "Label for when mute alert will end" + }, "Name" : { - "comment" : "Label for name row on add favorite food screen", + "comment" : "Label for name row on add favorite food screen\nPreset sorting option description for sorting by name", "localizations" : { "ar" : { "stringUnit" : { @@ -26841,6 +27571,9 @@ } } } + }, + "Navigating the Challenges of Mixed Exercise" : { + }, "Needs Attention" : { "comment" : "Sensor state description for the non-valid state", @@ -27061,6 +27794,12 @@ } } } + }, + "Next, we’ll look at settings you can change and how they affect Omar’s insulin." : { + + }, + "Next, we’ll look at settings you can change and how they affect Paloma’s insulin." : { + }, "Nightscout" : { "comment" : "The title of the Nightscout service", @@ -27193,9 +27932,16 @@ } } } + }, + "No" : { + + }, + "No Activity" : { + }, "No alerts or alarms will sound while muted. Select how long you would you like to mute for." : { "comment" : "Message for mute alert duration selection action sheet", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -27335,6 +28081,12 @@ } } } + }, + "No change in glucose." : { + + }, + "no change needed" : { + }, "No connected devices, or failure during device connection" : { "comment" : "The error message displayed for device connection errors.", @@ -27454,6 +28206,9 @@ } } } + }, + "No Delivery" : { + }, "No Maximum Bolus Configured" : { "comment" : "Alert title for a missing maximum bolus setting error", @@ -27647,6 +28402,7 @@ }, "No Recent Glucose" : { "comment" : "The title of the cell indicating that there is no recent glucose", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -28143,6 +28899,9 @@ } } } + }, + "None in last 24 hours" : { + }, "Notification Delivery" : { "comment" : "Notification Delivery Status text", @@ -28466,6 +29225,7 @@ }, "Notifications give you important %1$@ app information without requiring you to open the app." : { "comment" : "Alert Permissions descriptive text (1: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -28630,6 +29390,9 @@ } } }, + "Observed changes in glucose, subtracting changes modeled from insulin delivery, can be used to estimate carbohydrate absorption." : { + "comment" : "Section explaining carb effects chart" + }, "Off" : { "comment" : "Notification Setting Status is Off", "localizations" : { @@ -28809,7 +29572,7 @@ } }, "OK" : { - "comment" : "Alert acknowledgment OK button\nCritical Alert permissions disabled alert button\nDefault action for alert when alert acknowledgment fails\nNotifications permissions disabled alert button\nText for ok action on notification of upcoming TestFlight expiration\nText for ok action on notification of upcoming profile expiration\nThe title of the notification action to acknowledge a device alert", + "comment" : "Alert acknowledgment OK button\nCritical Alert permissions disabled alert button\nDefault action for alert when alert acknowledgment fails\nLabel for acknowledging the preset has been active for 24 hours\nNotifications permissions disabled alert button\nText for ok action on notification of upcoming TestFlight expiration\nText for ok action on notification of upcoming profile expiration\nThe title of the notification action to acknowledge a device alert", "localizations" : { "ar" : { "stringUnit" : { @@ -28938,6 +29701,25 @@ } } } + }, + "Omar asks himself, **do I expect I will need more or less insulin than usual?**" : { + + }, + "Omar Octopus wants to create a preset for some yard work he’ll be doing around the house." : { + + }, + "Omar sets his correction range a little higher, to %@-%@ %@. This tells %@ to step in sooner." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Omar sets his correction range a little higher, to %1$@-%2$@ %3$@. This tells %4$@ to step in sooner." + } + } + } + }, + "Omar’s Current Therapy Settings" : { + }, "On" : { "comment" : "Notification Setting Status is On", @@ -29069,6 +29851,21 @@ } } } + }, + "On until" : { + + }, + "on until %@" : { + "comment" : "The format for the description of a custom preset end date\nThe format for the description of a finite custom preset end date" + }, + "on until carbs added" : { + "comment" : "The format for the description of a premeal preset end date" + }, + "on until turned off" : { + "comment" : "The format for the description of an indefinite custom preset end date" + }, + "Once saved, Omar’s new preset will display in his Presets lists." : { + }, "Override Presets" : { "comment" : "The title text for the override presets", @@ -29195,6 +29992,27 @@ } } } + }, + "Paloma Porpoise sees her glucose is running higher than usual. She creates a preset to help manage her glucose while she is sick." : { + + }, + "Paloma wants %@ to know she needs more insulin than usual." : { + + }, + "Paloma’s Adjusted Therapy Settings" : { + + }, + "Paloma’s Current Therapy Settings" : { + + }, + "Pay attention to your insulin needs before and after exercising, playing sports, or doing unusually hard physical labor." : { + + }, + "Physical stress, like illness, can cause glucose to rise." : { + + }, + "Planning for physical activity can be tough. If you forget to set a preset ahead of time, consider these strategies:" : { + }, "Possible Missed Meal" : { "comment" : "The notification title for a meal that was possibly not logged in Loop.", @@ -29272,9 +30090,13 @@ } } } + }, + "Power lifting" : { + }, "Pre-Meal Targets" : { "comment" : "The label of the pre-meal mode toggle button", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -30219,6 +31041,36 @@ } } } + }, + "Preset Delivery" : { + + }, + "Preset Disabled" : { + + }, + "Preset Enabled" : { + + }, + "Preset Summary" : { + + }, + "Presets" : { + "comment" : "Presets screen title\nTitle text for button to Preset Settings" + }, + "Presets can be used for a variety of situations. Explore the uses below to learn tips for these common scenarios." : { + + }, + "Presets for Daily Activity" : { + + }, + "Presets for Exercise" : { + + }, + "Presets for Illness" : { + + }, + "Presets Performance History" : { + }, "Profile Expiration" : { "comment" : "Settings App Profile expiration view", @@ -30999,6 +31851,9 @@ } } }, + "Pump Inoperable. Automatic dosing is disabled." : { + "comment" : "The error message displayed for LoopError.pumpInoperable errors." + }, "Pump Manager" : { "comment" : "Details for configuration error when pump manager is missing", "localizations" : { @@ -31574,7 +32429,7 @@ } }, "Pump Suspended. Automatic dosing is disabled." : { - "comment" : "The error message displayed for pumpSuspended errors.", + "comment" : "The error message displayed for LoopError.pumpSuspended errors.", "localizations" : { "da" : { "stringUnit" : { @@ -32039,6 +32894,12 @@ } } } + }, + "Recent Events" : { + + }, + "Recognizing how hard you feel you're working during exercise can help you understand its impact on your glucose levels." : { + }, "Recommendation expired: %1$@ old" : { "comment" : "The error message when a recommendation has expired. (1: age of recommendation in minutes)", @@ -32385,6 +33246,9 @@ } } } + }, + "Recommended bolus adjusted due to preset" : { + }, "Recommended Bolus Exceeds Maximum Bolus" : { "comment" : "Title for bolus screen warning when recommended bolus exceeds max bolus", @@ -32594,6 +33458,12 @@ } } } + }, + "Recommended Insulin Reduction" : { + + }, + "Recommended: " : { + }, "Remote Bolus Entry: %@ U" : { "comment" : "The notification title for a remote bolus. (1: Bolus amount)\nThe notification title for a remote failure. (1: Bolus amount)", @@ -32932,6 +33802,9 @@ } } }, + "Resume insulin if you wish for the app to restart insulin delivery.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on." : { + "comment" : "message when automation is off and insulin delivery is suspended" + }, "Retrospective Correction" : { "comment" : "Title of the prediction input effect for retrospective correction", "localizations" : { @@ -33181,6 +34054,15 @@ } } } + }, + "Review Presets Training" : { + + }, + "safety" : { + + }, + "Same Activity, Different Intensity" : { + }, "Save" : { "localizations" : { @@ -33499,6 +34381,15 @@ } } } + }, + "Scheduled basal" : { + + }, + "Scheduled Basal" : { + + }, + "Scheduled reminder" : { + }, "Select Lock Screen Display Options" : { "comment" : "A section header for the lock screen display options.", @@ -33569,6 +34460,9 @@ } } } + }, + "Self-Initiated Events" : { + }, "Sensor Failed" : { "localizations" : { @@ -33740,9 +34634,12 @@ } } } + }, + "Set the glucose value (or values) you want Tidepool Loop to aim for in adjusting your basal insulin." : { + }, "Settings" : { - "comment" : "Label of button that navigation user to iOS Settings\nSettings screen title\nThe label of the settings button", + "comment" : "Label of button that navigation user to iOS Settings\nSettings screen title\nWord referring to the app's settings screen", "localizations" : { "ar" : { "stringUnit" : { @@ -33942,6 +34839,9 @@ } } } + }, + "She can do this by raising her **Overall Insulin** setting. This tells %@ to deliver more than her usual amount, making her insulin settings stronger." : { + }, "Shows last loop error" : { "comment" : "Loop Completion HUD accessibility hint", @@ -34358,6 +35258,12 @@ } } } + }, + "Since he doesn’t plan to push himself too hard, he expects his insulin needs to stay the same, so he leaves the setting at 100%." : { + + }, + "Since Paloma doesn't know when she'll feel better, she sets hers to “Until I Turn Off”." : { + }, "Site URL" : { "comment" : "The title of the nightscout site URL credential", @@ -34490,6 +35396,21 @@ } } } + }, + "Sitting or laying down, no change in breathing." : { + + }, + "Skip and complete training up to" : { + + }, + "Skip to Chapter" : { + + }, + "Smaller glucose drop" : { + + }, + "Soccer" : { + }, "Software Update" : { "comment" : "Software update button link text", @@ -34567,6 +35488,27 @@ } } } + }, + "Sort" : { + + }, + "Sort By" : { + + }, + "Stable Glucose" : { + + }, + "Start Preset" : { + "comment" : "The title of the notification action to start a preset" + }, + "Start Required Training" : { + "comment" : "CPreset training needed alert start training button" + }, + "Start Scheduled Preset?" : { + "comment" : "Scheduled preset reminder title" + }, + "Start Time" : { + }, "Start time is out of range: %@" : { "comment" : "Carb error description: invalid start time is out of range.", @@ -34882,6 +35824,15 @@ } } } + }, + "Starting your exercise with high active insulin" : { + + }, + "Stay hydrated" : { + + }, + "Stress during a game, match or tournament causes your body to release hormones like adrenaline and cortisol, which may raise your glucose and cause %@ to increase insulin delivery." : { + }, "Support" : { "comment" : "Section title for Support\nThe title of the support section in settings", @@ -35151,6 +36102,15 @@ } } }, + "Swimming" : { + + }, + "Tap “Apps”." : { + "comment" : "Focus modes step 3" + }, + "Tap a provided Focus option — like Do Not Disturb, Personal, or Sleep." : { + "comment" : "Focus modes step 2" + }, "Tap here to set up a CGM" : { "comment" : "Descriptive text for button to add CGM device", "localizations" : { @@ -35438,6 +36398,7 @@ }, "Tap to Add" : { "comment" : "The subtitle of the cell displaying an action to add a manually measurement glucose value", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -35530,6 +36491,9 @@ } } } + }, + "Tap to listen" : { + }, "Tap to Resume" : { "comment" : "The subtitle of the cell displaying an action to resume insulin delivery\nThe subtitle of the cell displaying an action to resume onboarding", @@ -35747,6 +36711,7 @@ }, "Tap to Unmute Alerts" : { "comment" : "Label for button to unmute all alerts", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -35798,8 +36763,12 @@ } } }, + "Tap to Unmute All App Sounds" : { + "comment" : "Label for button to unmute all app sounds" + }, "Tap Unmute to resume sound for your alerts and alarms." : { "comment" : "The alert body for unmute alert confirmation", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -35845,6 +36814,24 @@ } } }, + "Tap your CGM or insulin pump status icons right away for more information and steps to resolve the issue." : { + "comment" : "message when automation is off and there is a bluetooth or pump issue\nmessage when automation is on and there is a bluetooth or pump issue" + }, + "Tap your CGM status icon right away for more information and steps to resolve the issue.\n\nIn the meantime, your pump is still able to deliver insulin." : { + "comment" : "message when automation is off and CGM is inoperable\nmessage when automation is on and CGM is inoperable" + }, + "Temp Basal" : { + + }, + "Temp Basal: " : { + + }, + "Temporarily silence all sounds from %1$@, including sounds for all critical alerts such as Urgent Low, Sensor Fail, Pump Expiration and others." : { + + }, + "Temporary Settings Adjustments" : { + "comment" : "Descriptive text for Preset Settings" + }, "TestFlight" : { "comment" : "Settings app TestFlight section", "localizations" : { @@ -36068,6 +37055,12 @@ } } } + }, + "That said, insulin needs vary from person to person. Some people find they don’t need to adjust their insulin at all for high-intensity exercise." : { + + }, + "The \"Overall Insulin\" percentage controls total insulin delivery by adjusting your:" : { + }, "The bolus amount entered is smaller than the minimum deliverable." : { "comment" : "Alert message for a bolus too small validation error", @@ -36323,6 +37316,9 @@ } } } + }, + "The exercise may not be vigorous enough to produce these results" : { + }, "The legacy model used by Loop, allowing customization of action duration." : { "comment" : "Subtitle description of Walsh insulin model setting", @@ -37026,6 +38022,12 @@ } } } + }, + "These patterns are based on published exercise consensus guidelines and are meant to be used as a starting point. What works for one person may not work for you." : { + + }, + "These recommendations should be used as a starting point. Checking your glucose during exercise will help you find the settings that work best for you." : { + }, "This option only applies when Loop's Dosing Strategy is set to Automatic Bolus." : { "comment" : "String shown when glucose based partial application cannot be enabled because dosing strategy is not set to Automatic Bolus", @@ -37085,8 +38087,37 @@ } } } + }, + "This range is usually higher than your correction range when you are not exercising." : { + + }, + "This reflects a %@ %@ from the original %@ due to preset adjustments." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This reflects a %1$@ %2$@ from the original %3$@ due to preset adjustments." + } + } + } + }, + "This required training will show you how to change your settings with confidence and create custom presets that fit your needs.\n\nThis training covers:" : { + + }, + "Tidepool Loop will actively adjust your insulin dosing in response to your glucose as often as every 5 minutes." : { + "comment" : "message when automation is on and the glucose value is fresh" + }, + "Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM or insulin pump." : { + "comment" : "message when automation is on and pump is in signal loss\nmessage when automation is on and the glucose value is not fresh" + }, + "Tidepool Loop will continue trying to restore automation, but check for potential communication issues with your CGM.\n\nIn the meantime, your pump is still able to deliver insulin." : { + "comment" : "message when automation is on and CGM is in signal loss" + }, + "Time of Day" : { + }, "Time Sensitive Alerts" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -37221,6 +38252,45 @@ } } }, + "Time Sensitive Notifications are turned OFF" : { + "comment" : "Time sensitive notifications disabled banner title" + }, + "Time Sensitive notifications are turned OFF. Go to the App to fix the issue now." : { + "comment" : "Time sensitive notifications disabled notification body" + }, + "Time Sensitive Notifications are turned OFF. You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Notifications are turned ON." : { + "comment" : "Notifications disabled alert body" + }, + "Time Sensitive Notifications are turned OFF. You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Time Sensitive Notifications are turned ON." : { + "comment" : "Time sensitive notifications disabled alert body" + }, + "Tip" : { + + }, + "To be safe, %@ will remind her after 24 hours that the preset is still running." : { + + }, + "To create a new preset, you must complete the required training." : { + "comment" : "Preset training needed alert message" + }, + "To help avoid lows, set a range **higher** than your typical correction range." : { + + }, + "To help avoid lows, set a range higher than your typical correction range." : { + + }, + "To help prevent lows, she will increase her correction range." : { + + }, + "To turn Silent mode on, flip the Ring/Silent switch toward the back of your iPhone." : { + "comment" : "Description text for temporarily silencing non-critical alerts" + }, + "Total Insulin Delivery" : { + + }, + "Training Required for New Presets" : { + "comment" : "Preset training needed alert title" + }, "Transmitter Low Battery" : { "localizations" : { "da" : { @@ -37370,6 +38440,7 @@ }, "Turn off the volume on your iOS device or add %1$@ as an allowed app to each Focus Mode. Time Sensitive and Critical Alerts will still sound, but non-Critical Alerts will be silenced." : { "comment" : "Description text for temporarily silencing non-critical alerts (1: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -37510,8 +38581,24 @@ } } }, + "Turn On Critical Alerts" : { + "comment" : "Critical alerts disabled alert title\nCritical alerts disabled notification title" + }, + "Turn On Critical Alerts and Time Sensitive Notifications" : { + "comment" : "Both Critical Alerts and Time Sensitive Notifications disabled alert title\nBoth Critical Alerts and Time Sensitive Notifications disabled notification title" + }, + "Turn on the preset as soon as you remember and keep it on until the activity ends" : { + + }, + "Turn On Time Sensitive Notifications" : { + "comment" : "Time sensitive notifications disabled alert title" + }, + "Turn On Time Sensitive Notifications " : { + "comment" : "Time sensitive notifications disabled alert title" + }, "U" : { "comment" : "The short unit display string for international units of insulin", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -38075,7 +39162,7 @@ } }, "Unknown" : { - "comment" : "Event title displayed when StoredPumpEvent.title is not set\nThe default description to use when an entry has no dose description\nlabel for when the alert mute end time is unknown\nresult when time cannot be formatted", + "comment" : "Event title displayed when StoredPumpEvent.title is not set\nThe default description to use when an entry has no dose description\nresult when time cannot be formatted", "localizations" : { "ar" : { "stringUnit" : { @@ -38200,7 +39287,7 @@ } }, "Unknown Error: %1$@" : { - "comment" : "The error message displayed for unknown errors. (1: unknown error)", + "comment" : "The error message displayed for unknown LoopError errors. (1: unknown error)", "localizations" : { "ar" : { "stringUnit" : { @@ -38455,7 +39542,7 @@ } }, "Unmute" : { - "comment" : "The title of the action used to unmute alerts", + "comment" : "The title of the action used to unmute app sounds", "localizations" : { "da" : { "stringUnit" : { @@ -38515,6 +39602,7 @@ }, "Unmute Alerts?" : { "comment" : "The alert title for unmute alert confirmation", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -38571,6 +39659,12 @@ } } } + }, + "Unmute All App Sounds?" : { + "comment" : "The alert title for unmute all app sounds confirmation" + }, + "Unsupported" : { + }, "Unsupported Notification Service: %1$@" : { "comment" : "Error message when a service can't be found to handle a push notification. (1: Service Identifier)", @@ -38633,6 +39727,7 @@ }, "until %@" : { "comment" : "The format for the description of a custom preset end date", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -38906,6 +40001,7 @@ }, "Until I turn off" : { "comment" : "The title of a target alert action specifying workout targets duration until it is turned off by the user", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -39178,6 +40274,7 @@ }, "Use the Mute Alerts feature. It allows you to temporarily silence all of your alerts and alarms via the %1$@ app, including Critical Alerts and Time Sensitive Alerts." : { "comment" : "Description text for temporarily silencing all sounds (1: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -39228,6 +40325,12 @@ } } } + }, + "Use the Mute All App Sounds feature. It allows you to temporarily silence (up to 4 hours) all of the sounds from %1$@, including Critical Alerts and Time Sensitive Notifications." : { + "comment" : "Description text for temporarily silencing all sounds (1: app name)" + }, + "Use the slider to rate the effort on a scale of 0–10, with 10 being the hardest you’ve ever worked." : { + }, "Use Workout Glucose Targets" : { "comment" : "The title of the alert controller used to select a duration for workout targets", @@ -39357,6 +40460,7 @@ }, "Use Workout Preset" : { "comment" : "The title of the alert controller used to select a duration for workout targets", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -39443,6 +40547,22 @@ } } } + }, + "Very Easy" : { + + }, + "Viewing entry %lld of %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Viewing entry %1$lld of %2$lld" + } + } + } + }, + "Walking" : { + }, "Walsh" : { "comment" : "Title of insulin model setting", @@ -39572,6 +40692,7 @@ }, "Warning! Safety notifications are turned OFF" : { "comment" : "Alert Permissions Need Attention alert title", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -39646,8 +40767,12 @@ } } } + }, + "What are examples of Critical Alerts and Time Sensitive Notifications?" : { + }, "What are examples of Critical and Time Sensitive alerts?" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -39919,6 +41044,9 @@ } } } + }, + "When deciding to adjust your overall insulin, **ask yourself, does my body need more or less than usual?**" : { + }, "When enabled, Loop can notify you when it detects a meal that wasn't logged." : { "comment" : "Description of missed meal notifications.", @@ -40084,9 +41212,16 @@ } } } + }, + "When using a preset for activity, keep in mind four key factors that may impact your glucose." : { + + }, + "When using high-insulin presets, **you may not need to start your preset 1 hour before**." : { + }, "While mute alerts is on, all alerts from your %1$@ app including Critical and Time Sensitive alerts will temporarily display without sounds and will vibrate only." : { "comment" : "App sounds descriptive text (1: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -40139,6 +41274,7 @@ } }, "While mute alerts is on, your insulin pump and CGM hardware may still sound." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -40195,6 +41331,9 @@ } } } + }, + "While sick, Paloma expects to eat less or not absorb everything she eats." : { + }, "While trying to restart %1$@ an error occured.\n\n%2$@" : { "comment" : "Format string for message of reset loop alert. (1: App name) (2: error description)", @@ -40248,9 +41387,16 @@ } } } + }, + "While turned on, Paloma’s preset will display on the home screen and in her Presets list." : { + + }, + "With Preset On" : { + }, "Workout Targets" : { "comment" : "The label of the workout mode toggle button", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -40370,6 +41516,7 @@ }, "Workout Temp Adjust has been turned on for more than 24 hours. Make sure you still want it enabled, or turn it off in the app." : { "comment" : "Workout override still on reminder alert body.", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -40459,6 +41606,7 @@ }, "Workout Temp Adjust Still On" : { "comment" : "Workout override still on reminder alert title", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -40546,6 +41694,9 @@ } } }, + "Would you like to start your %1$@ preset?\n\nThis will end any active preset." : { + "comment" : "Scheduled preset reminder alert body. (1: preset name)" + }, "Yes" : { "comment" : "The title of the action used when confirming entered amount of carbohydrates.", "localizations" : { @@ -40634,9 +41785,31 @@ } } } + }, + "Yes, Start Now" : { + "comment" : "Label for do yes, start preset now action on scheduled preset reminder alert" + }, + "Yes, turn OFF" : { + + }, + "You can choose how long your preset lasts." : { + + }, + "You can now:" : { + + }, + "You can use presets for a variety of situations. Explore the uses below to learn tips for these common scenarios." : { + + }, + "You do not have to set a new correction range for each preset, but before deciding to adjust your correction range, " : { + + }, + "You don’t need to change the correction range for every preset. But before you decide to change it, ask yourself: *Am I more likely to go high or low during this time?*" : { + }, "You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Notifications, Critical Alerts and Time Sensitive Notifications are turned ON." : { "comment" : "Format for Notifications permissions disabled alert body. (1: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -40718,6 +41891,21 @@ } } }, + "You may review the training materials again at any time via the Learning Hub, located at the bottom of the Preset screen." : { + + }, + "you need **less** insulin than usual" : { + + }, + "you need **more** insulin than usual" : { + + }, + "You’ll have to restart this section and some features will be disabled until you complete the training." : { + + }, + "You’ll need to ensure these settings for each Focus Mode you have enabled or plan to enable." : { + "comment" : "iOS focus modes callout title" + }, "Your %1$@’s time has been changed. %2$@ needs accurate time records to make predictions about your glucose and adjust your insulin accordingly.\n\nCheck in your %1$@ Settings (General / Date & Time) and verify that 'Set Automatically' is turned ON. Failure to resolve could lead to serious under-delivery or over-delivery of insulin." : { "comment" : "Time change alert body. (1: app name)", "localizations" : { @@ -40795,6 +41983,9 @@ } } }, + "Your CGM sensor is warming up.\n\nIn the meantime, your pump is still able to deliver insulin.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on." : { + "comment" : "message when automation is off and CGM is in warmup" + }, "Your glucose is below %1$@. Are you sure you want to bolus?" : { "comment" : "Format string for simple bolus screen warning when glucose is below glucose warning limit.", "localizations" : { @@ -41234,6 +42425,9 @@ } } }, + "Your glucose is rapidly rising. Check that any carbs you've eaten were logged. If you logged carbs, check that the time you entered lines up with when you started eating." : { + "comment" : "Warning to ensure the carb entry is accurate" + }, "Your maximum bolus amount is %1$@." : { "comment" : "Warning for simple bolus when max bolus is exceeded. (1: maximum bolus)", "localizations" : { @@ -41317,6 +42511,12 @@ } } }, + "Your pump and CGM will continue operating but the app will not make automatic adjustments. You will receive your scheduled basal rate(s)." : { + "comment" : "Closed loop alert message" + }, + "Your pump and CGM will continue to operate, but the app will not adjust insulin dosing automatically.\n\nIf you wish for the app to automate your insulin, go to Settings and toggle Closed Loop to on." : { + "comment" : "message when automation is off, glucose value is fresh and devices are good" + }, "Your pump data is stale. %1$@ cannot recommend a bolus amount." : { "comment" : "Caption for bolus screen notice when pump data is missing or stale", "localizations" : { diff --git a/LoopUI/Localizable.xcstrings b/LoopUI/Localizable.xcstrings index 19df68c2c1..716c763765 100644 --- a/LoopUI/Localizable.xcstrings +++ b/LoopUI/Localizable.xcstrings @@ -3,6 +3,7 @@ "strings" : { "\n%1$@\n\n%2$@ is operating with Closed Loop in the ON position." : { "comment" : "Green closed loop ON message (1: last loop string) (2: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -80,6 +81,7 @@ }, "\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but check for potential communication issues with your pump and CGM." : { "comment" : "Red loop message (1: last loop string) (2: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -157,6 +159,7 @@ }, "\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but watch for potential communication issues with your pump and CGM." : { "comment" : "Yellow loop message (1: last loop string) (2: app name)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -234,6 +237,7 @@ }, "\n%1$@ is operating with Closed Loop in the OFF position. Your pump and CGM will continue operating, but the app will not adjust dosing automatically.\n\n%2$@" : { "comment" : "Green closed loop OFF message (1: app name)(2: reason for open loop)", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -309,6 +313,12 @@ } } }, + " %@ ago" : { + "comment" : "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components" + }, + " >%@ ago" : { + "comment" : "Format string describing the time interval since the last completion date, last cgm or last pump communication. (1: The localized date components" + }, "– – –" : { "comment" : "No glucose value representation (3 dashes for mg/dL)", "localizations" : { @@ -448,6 +458,7 @@ }, "%@ ago" : { "comment" : "Format string describing the time interval since the last completion date. (1: The localized date components", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -971,6 +982,7 @@ }, "%1$@ units per hour at %2$@" : { "comment" : "Accessibility format string describing the basal rate. (1: localized basal rate value)(2: last updated time)", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1537,6 +1549,7 @@ }, "Closed Loop OFF" : { "comment" : "Title of green open loop OFF message", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1626,6 +1639,7 @@ }, "Closed Loop ON" : { "comment" : "Title of green closed loop ON message", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -1834,6 +1848,7 @@ }, "g" : { "comment" : "The short unit display string for grams", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2159,8 +2174,12 @@ } } }, + "Last device communication ran %@ ago" : { + "comment" : "Accessbility format label describing the time interval since the last device communication date. (1: The localized date components)" + }, "Loop Failure" : { "comment" : "Title of red loop message", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2399,6 +2418,7 @@ }, "Loop Warning" : { "comment" : "Title of yellow loop message", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -2595,6 +2615,7 @@ }, "mg/dL" : { "comment" : "The short unit display string for milligrams of glucose per decilter", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2720,6 +2741,7 @@ }, "mmol/L" : { "comment" : "The short unit display string for millimoles of glucose per liter", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3209,6 +3231,7 @@ }, "Tap Settings to toggle Closed Loop ON if you wish for the app to automate your insulin." : { "comment" : "Instructions for user to close loop if it is allowed.", + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -3286,6 +3309,7 @@ }, "U" : { "comment" : "The short unit display string for international units of insulin", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3403,6 +3427,9 @@ } } }, + "U/hr" : { + "comment" : "The format string describing the basal rate unit." + }, "Unknown" : { "comment" : "Accessibility value for an unknown value", "localizations" : { diff --git a/WatchApp/InfoPlist.xcstrings b/WatchApp/InfoPlist.xcstrings index 00899fdc66..13e0ce32d7 100644 --- a/WatchApp/InfoPlist.xcstrings +++ b/WatchApp/InfoPlist.xcstrings @@ -250,6 +250,30 @@ } } } + }, + "NSHealthShareUsageDescription" : { + "comment" : "Privacy - Health Share Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake." + } + } + } + }, + "NSHealthUpdateUsageDescription" : { + "comment" : "Privacy - Health Update Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit." + } + } + } } }, "version" : "1.0" From 148dcfdb07d804b61ec97570a992ee8f043c95cf Mon Sep 17 00:00:00 2001 From: LoopKit Developer Date: Thu, 9 Apr 2026 15:52:16 -0500 Subject: [PATCH 394/421] Fix duplicate Deliver button on bolus screen The actionArea was rendered twice: once inline in the VStack (old DIY code) and again via .safeAreaInset (Tidepool's approach). Removed the VStack copy, keeping the .safeAreaInset version which correctly handles keyboard layout. --- Loop/Views/BolusEntryView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index e76b32fc94..91042043a3 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -41,10 +41,6 @@ struct BolusEntryView: View { } .padding(.top, -28) .insetGroupedListStyle() - if !bolusFieldFocused { - actionArea - } - } .navigationBarTitle(self.title) .supportedInterfaceOrientations(.portrait) From 651048f1db68c1c10363b803d9a8ad4ad54cf36e Mon Sep 17 00:00:00 2001 From: LoopKit Developer Date: Thu, 9 Apr 2026 18:08:42 -0500 Subject: [PATCH 395/421] Restore manual dose entry on insulin delivery screen The Tidepool sync replaced the legacy InsulinDeliveryTableViewController with a new SwiftUI InsulinDeliveryLog view that did not expose manual dose entry, removing the "+" toolbar button DIY users relied on. Add an optional onEnterManualDose callback to InsulinDeliveryLog and wire StatusTableViewController to present ManualEntryDoseView, matching the legacy presentation flow. Gated on FeatureFlags.manualDoseEntryEnabled. --- Loop/Localizable.xcstrings | 2 +- .../StatusTableViewController.swift | 12 +++++++++++ .../InsulinDeliveryLog.swift | 20 +++++++++++++++---- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 0c6b0b8d2f..c493bb9383 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -24699,7 +24699,7 @@ } }, "Log Dose" : { - "comment" : "Button text to log a dose\nTitle for dose logging screen", + "comment" : "Accessibility label for the manual dose entry button on the insulin delivery screen\nButton text to log a dose\nTitle for dose logging screen", "localizations" : { "da" : { "stringUnit" : { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 15192ce0c8..a690bc9658 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1351,6 +1351,9 @@ final class StatusTableViewController: LoopChartsTableViewController { navigationController?.pushViewController(viewController, animated: true) } + }, + onEnterManualDose: { [weak self] in + self?.presentManualDoseEntry() } ) .navigationTitle(Text("Insulin")) @@ -1373,6 +1376,15 @@ final class StatusTableViewController: LoopChartsTableViewController { } } + private func presentManualDoseEntry() { + let viewModel = ManualEntryDoseViewModel(delegate: loopManager) + let manualEntryDoseView = ManualEntryDoseView(viewModel: viewModel) + let hostingController = DismissibleHostingController(rootView: manualEntryDoseView, isModalInPresentation: false) + let navigationWrapper = UINavigationController(rootViewController: hostingController) + hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) + present(navigationWrapper, animated: true) + } + private func presentUnmuteAlertConfirmation() { let title = NSLocalizedString("Unmute All App Sounds?", comment: "The alert title for unmute all app sounds confirmation") let body = NSLocalizedString("All app sounds, including sounds for all critical alerts, are currently muted.\n\nTap Unmute to resume app sounds for your alerts.", comment: "The alert body for unmute alert confirmation") diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift index 668ed41b74..c84f30523e 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift @@ -12,15 +12,17 @@ import LoopKitUI import SwiftUI struct InsulinDeliveryLog: View { - + @State private var viewModel: InsulinDeliveryLogViewModel @State var showingFilterMenu = false - + let onTapGesture: (DoseEntry) -> Void - - init(viewModel: InsulinDeliveryLogViewModel, onTapGesture: @escaping (DoseEntry) -> Void) { + let onEnterManualDose: (() -> Void)? + + init(viewModel: InsulinDeliveryLogViewModel, onTapGesture: @escaping (DoseEntry) -> Void, onEnterManualDose: (() -> Void)? = nil) { self.viewModel = viewModel self.onTapGesture = onTapGesture + self.onEnterManualDose = onEnterManualDose } private func totalInsulinDeliveredLabel(from total: LoopQuantity) -> some View { @@ -152,5 +154,15 @@ struct InsulinDeliveryLog: View { .refreshable { await viewModel.fetchData() } + .toolbar { + if FeatureFlags.manualDoseEntryEnabled, let onEnterManualDose { + ToolbarItem(placement: .topBarTrailing) { + Button(action: onEnterManualDose) { + Image(systemName: "plus") + } + .accessibilityLabel(Text("Log Dose", comment: "Accessibility label for the manual dose entry button on the insulin delivery screen")) + } + } + } } } From d77b178c5a9976a65c7a801077de1b66e55fcc56 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 23 Apr 2026 12:07:29 -0500 Subject: [PATCH 396/421] [LOOP-1114] Wire MockSupport loading via SupportProviding (#922) Add supportManager reference to ServicesManager so it can add/remove supports when SupportProviding services are added/removed. Update availableSupports to include SupportProviding services. Filter plugin menu items in SettingsView to avoid duplicate support section entries. Update VersionUpdateViewModel footer for required updates and reorder software update icon placement. Co-authored-by: Claude Opus 4.6 --- Loop/Managers/LoopAppManager.swift | 1 + Loop/Managers/ServicesManager.swift | 11 ++++++++++- Loop/View Models/VersionUpdateViewModel.swift | 4 +++- Loop/Views/SettingsView.swift | 5 ++--- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 796fadb90d..85d6619327 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -443,6 +443,7 @@ class LoopAppManager: NSObject { deviceSupportDelegate: deviceDataManager, servicesManager: servicesManager, alertIssuer: alertManager) + servicesManager.supportManager = supportManager setWhitelistedDevices() diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index a2dc00388a..c7252128fa 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -29,6 +29,7 @@ class ServicesManager { weak var servicesManagerDelegate: ServicesManagerDelegate? weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? + weak var supportManager: SupportManager? private var services = [Service]() @@ -142,6 +143,9 @@ class ServicesManager { if let remoteDataService = service as? RemoteDataService { remoteDataServicesManager.addService(remoteDataService) } + if let provider = service as? SupportProviding { + supportManager?.addSupport(provider.createSupport()) + } saveState() } @@ -149,6 +153,9 @@ class ServicesManager { public func removeActiveService(_ service: Service) { servicesLock.withLock { + if let provider = service as? SupportProviding { + supportManager?.removeSupport(provider.createSupport()) + } if let remoteDataService = service as? RemoteDataService { remoteDataServicesManager.removeService(remoteDataService) } @@ -400,7 +407,9 @@ extension ServicesManager: ServiceOnboardingDelegate { } extension ServicesManager { - var availableSupports: [SupportUI] { activeServices.compactMap { $0 as? SupportUI } } + var availableSupports: [SupportUI] { + activeServices.compactMap { ($0 as? SupportUI) ?? ($0 as? SupportProviding)?.createSupport() } + } } // Service extension for rawValue diff --git a/Loop/View Models/VersionUpdateViewModel.swift b/Loop/View Models/VersionUpdateViewModel.swift index 72267c6651..4745842060 100644 --- a/Loop/View Models/VersionUpdateViewModel.swift +++ b/Loop/View Models/VersionUpdateViewModel.swift @@ -34,7 +34,9 @@ public class VersionUpdateViewModel: ObservableObject { func footer(appName: String) -> String { switch versionUpdate { - case .required, .recommended: + case .required: + return NSLocalizedString("A critical update is available. Your app may not function correctly until you update to the latest version.", comment: "Software update description for required update") + case .recommended: return String(format: NSLocalizedString("A new version of %@ is available and is recommended to continue using the app.", comment: "Software update available section footer (1: app name)"), appName) case .available: return String(format: NSLocalizedString("A new version of %@ is available.", comment: "Required software update section footer (1: app name)"), appName) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 771b09ebb8..7434db61a4 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -97,7 +97,7 @@ struct SettingsView: View { servicesSection } - ForEach(pluginMenuItems) { item in + ForEach(pluginMenuItems.filter({ $0.section != .support })) { item in item.view } @@ -265,9 +265,8 @@ extension SettingsView { private var softwareUpdateSection: some View { Section(footer: Text(viewModel.versionUpdateViewModel.footer(appName: appName))) { NavigationLink(destination: viewModel.versionUpdateViewModel.softwareUpdateView) { - Text(NSLocalizedString("Software Update", comment: "Software update button link text")) - Spacer() viewModel.versionUpdateViewModel.icon + Text(NSLocalizedString("Software Update", comment: "Software update button link text")) } } } From ac2040c092587b7d9095303aa3a4be03c18b17e7 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 29 Apr 2026 13:26:58 -0700 Subject: [PATCH 397/421] [LOOP-5866] Fix Suspended Delivery Handling in Delivery Log (#923) --- .../InsulinDeliveryEventDetailsView.swift | 3 ++- .../InsulinDeliveryLogViewModel.swift | 22 ++++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift index 8e22e1b71f..b1ac8e3616 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift @@ -57,7 +57,8 @@ struct InsulinDeliveryEventDetailsView: View { } var endTimeValue: String? { - doseEntry.endDate.formatted(date: .omitted, time: .standard) + guard doseEntry.startDate != doseEntry.endDate, !doseEntry.isMutable else { return nil } + return doseEntry.endDate.formatted(date: .omitted, time: .standard) } var durationValue: String? { diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index f7c74b8af3..a5177c46fe 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -504,6 +504,15 @@ class InsulinDeliveryLogViewModel { } private func handleDoseEvents(doses: [DoseEntry], decisions: [LightDosingDecision], fetchedDate: Date, events: inout [InsulinDeliveryLogEvent]) { + let isPumpSuspended: Bool = { + if case .suspended = pumpManager.status.basalDeliveryState { + return true + } + return false + }() + + let latestSuspendStartDate = doses.last(where: { $0.type == .suspend })?.startDate + for dose in doses { let decision = decisions.first(where: { $0.id == dose.decisionId }) switch dose.type { @@ -512,17 +521,18 @@ class InsulinDeliveryLogViewModel { case .bolus: handleBolusEvents(dose: dose, decision: decision, events: &events) case .resume, .suspend: - handleSuspendResumeEvents(dose: dose, fetchedDate: fetchedDate, events: &events) + let isActiveSuspension = isPumpSuspended && dose.type == .suspend && dose.startDate == latestSuspendStartDate + handleSuspendResumeEvents(dose: dose, fetchedDate: fetchedDate, isActiveSuspension: isActiveSuspension, events: &events) } } } - - private func handleSuspendResumeEvents(dose: DoseEntry, fetchedDate: Date, events: inout [InsulinDeliveryLogEvent]) { + + private func handleSuspendResumeEvents(dose: DoseEntry, fetchedDate: Date, isActiveSuspension: Bool, events: inout [InsulinDeliveryLogEvent]) { guard dose.type == .suspend else { return } - + events.append(InsulinDeliveryLogEvent(id: dose.syncIdentifier ?? UUID().uuidString, type: .pumpEvent(.insulin(.suspended), dose), date: dose.startDate)) - - if !dose.isMutable || dose.endDate <= fetchedDate { + + if !isActiveSuspension && (!dose.isMutable || dose.endDate <= fetchedDate) { events.append(InsulinDeliveryLogEvent(id: dose.syncIdentifier ?? UUID().uuidString, type: .pumpEvent(.insulin(.resumed), dose), date: dose.endDate)) } } From 0214f92c848a29cbd566fe188114a2ae3df004dd Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 30 Apr 2026 13:08:06 -0500 Subject: [PATCH 398/421] [LOOP-1114] Add blocking modal and open loop enforcement for required updates. (#925) * [LOOP-1114] Add blocking modal and open loop enforcement for required updates When a support plugin returns .required from checkVersion, Loop now: - Forces the app into open loop mode - Sends a critical user notification - Presents a non-dismissable modal with an App Store link The modal and notification use the dynamic app name for white labeling. * Clean up unneeded checks for pre iOS 15 support --- Loop.xcodeproj/project.pbxproj | 4 ++ Loop/Managers/AlertPermissionsChecker.swift | 6 +-- Loop/Managers/Alerts/AlertManager.swift | 8 +-- Loop/Managers/Alerts/StoredAlert.swift | 21 ++------ .../UserNotificationAlertScheduler.swift | 4 +- Loop/Managers/LoopAppManager.swift | 34 +++++++++++- Loop/Managers/NotificationManager.swift | 16 ++++++ Loop/Managers/SettingsManager.swift | 9 +--- Loop/Managers/SupportManager.swift | 5 ++ ...icationsCriticalAlertPermissionsView.swift | 12 ++--- Loop/Views/RequiredVersionUpdateView.swift | 53 +++++++++++++++++++ 11 files changed, 124 insertions(+), 48 deletions(-) create mode 100644 Loop/Views/RequiredVersionUpdateView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index a6a505e21f..1df8e44c89 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -444,6 +444,7 @@ 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 */; }; + C151634E2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C151634D2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift */; }; C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */; }; C152B9F72C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */; }; C1550B0C2E6F249A009369DC /* LoopCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1550B0B2E6F249A009369DC /* LoopCircleView.swift */; }; @@ -1409,6 +1410,7 @@ C13DA2AF24F6C7690098BB29 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryUncertaintyAlertManager.swift; sourceTree = ""; }; C14952142995822A0095AA84 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; + C151634D2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequiredVersionUpdateView.swift; sourceTree = ""; }; C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntry.swift; sourceTree = ""; }; C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntryTests.swift; sourceTree = ""; }; C1550B0B2E6F249A009369DC /* LoopCircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCircleView.swift; sourceTree = ""; }; @@ -2215,6 +2217,7 @@ 84E8BBAF2CC979300078E6CF /* Presets */, B43B5C552EAFBF170096A6AE /* RecentGlucoseTableViewCell.swift */, B43B5C532EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib */, + C151634D2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */, C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */, @@ -3689,6 +3692,7 @@ 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */, C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */, + C151634E2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift in Sources */, 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */, 439A7944211FE22F0041B75F /* NSUserActivity.swift in Sources */, 4328E0331CFC091100E199AA /* WatchContext+LoopKit.swift in Sources */, diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index c1e7dfafed..fe9409b8d9 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -74,10 +74,8 @@ public class AlertPermissionsChecker: ObservableObject { if FeatureFlags.criticalAlertsEnabled { newSettings.criticalAlertsDisabled = settings.criticalAlertSetting == .disabled } - if #available(iOS 15.0, *) { - newSettings.scheduledDeliveryEnabled = settings.scheduledDeliverySetting == .enabled - newSettings.timeSensitiveDisabled = settings.alertSetting != .disabled && settings.timeSensitiveSetting == .disabled - } + newSettings.scheduledDeliveryEnabled = settings.scheduledDeliverySetting == .enabled + newSettings.timeSensitiveDisabled = settings.alertSetting != .disabled && settings.timeSensitiveSetting == .disabled self.notificationCenterSettings = newSettings completion?() } diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index aa07efde78..b683e43ddc 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -212,14 +212,10 @@ public final class AlertManager { notificationContent.title = NSLocalizedString("Loop Failure", comment: "The notification title for a loop failure") let shouldMuteAlert = alertMuter.shouldMuteAlert(scheduledAt: timeUntilNotification) if isCritical, FeatureFlags.criticalAlertsEnabled { - if #available(iOS 15.0, *) { - notificationContent.interruptionLevel = .critical - } + notificationContent.interruptionLevel = .critical notificationContent.sound = shouldMuteAlert ? .defaultCriticalSound(withAudioVolume: 0.0) : .defaultCritical } else { - if #available(iOS 15.0, *) { - notificationContent.interruptionLevel = .timeSensitive - } + notificationContent.interruptionLevel = .timeSensitive notificationContent.sound = shouldMuteAlert ? nil : .default } notificationContent.categoryIdentifier = LoopNotificationCategory.loopNotRunning.rawValue diff --git a/Loop/Managers/Alerts/StoredAlert.swift b/Loop/Managers/Alerts/StoredAlert.swift index db8805380a..fb77d85005 100644 --- a/Loop/Managers/Alerts/StoredAlert.swift +++ b/Loop/Managers/Alerts/StoredAlert.swift @@ -190,26 +190,11 @@ extension Alert.InterruptionLevel { // Since this is arbitrary anyway, might as well make it match iOS's values switch self { case .active: - if #available(iOS 15.0, *) { - return NSNumber(value: UNNotificationInterruptionLevel.active.rawValue) - } else { - // https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/active - return 1 - } + return NSNumber(value: UNNotificationInterruptionLevel.active.rawValue) case .timeSensitive: - if #available(iOS 15.0, *) { - return NSNumber(value: UNNotificationInterruptionLevel.timeSensitive.rawValue) - } else { - // https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/timesensitive - return 2 - } + return NSNumber(value: UNNotificationInterruptionLevel.timeSensitive.rawValue) case .critical: - if #available(iOS 15.0, *) { - return NSNumber(value: UNNotificationInterruptionLevel.critical.rawValue) - } else { - // https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/critical - return 3 - } + return NSNumber(value: UNNotificationInterruptionLevel.critical.rawValue) } } diff --git a/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift b/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift index 0f0cf8220b..584f92e2cb 100644 --- a/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift +++ b/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift @@ -69,9 +69,7 @@ fileprivate extension Alert { userNotificationContent.title = backgroundContent.title userNotificationContent.body = backgroundContent.body userNotificationContent.sound = userNotificationSound(muted: muted) - if #available(iOS 15.0, *) { - userNotificationContent.interruptionLevel = interruptionLevel.userNotificationInterruptLevel - } + userNotificationContent.interruptionLevel = interruptionLevel.userNotificationInterruptLevel userNotificationContent.categoryIdentifier = categoryIdentifier ?? "" userNotificationContent.threadIdentifier = identifier.value // Used to match categoryIdentifier, but I /think/ we want multiple threads for multiple alert types, no? userNotificationContent.userInfo = [ diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 85d6619327..a2b8d94549 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -101,6 +101,7 @@ class LoopAppManager: NSObject { private var statefulPluginManager: StatefulPluginManager! private var criticalEventLogExportManager: CriticalEventLogExportManager! private var deviceLog: PersistentDeviceLog! + private var requiredUpdateModalPresented = false // HealthStorePreferredGlucoseUnitDidChange will be notified once the user completes the health access form. Set to .milligramsPerDeciliter until then public private(set) var displayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) @@ -444,7 +445,11 @@ class LoopAppManager: NSObject { servicesManager: servicesManager, alertIssuer: alertManager) servicesManager.supportManager = supportManager - + + supportManager.onRequiredUpdate = { [weak self] in + self?.handleRequiredVersionUpdate() + } + setWhitelistedDevices() onboardingManager = OnboardingManager(pluginManager: pluginManager, @@ -759,6 +764,30 @@ class LoopAppManager: NSObject { deviceDataManager.deviceWhitelist = DeviceWhitelist(cgmDevices: Array(whitelistedCGMs), pumpDevices: Array(whitelistedPumps)) } + private func handleRequiredVersionUpdate() { + settingsManager.mutateLoopSettings { settings in + settings.dosingEnabled = false + } + settingsViewModel.closedLoopPreference = false + + guard !requiredUpdateModalPresented else { return } + requiredUpdateModalPresented = true + + let appName = Bundle.main.bundleDisplayName + NotificationManager.sendRequiredUpdateNotification(appName: appName) + + let updateView = RequiredVersionUpdateView(appName: appName) { [weak self] in + self?.supportManager.openAppStore() + } + let hostingController = UIHostingController(rootView: updateView) + hostingController.modalPresentationStyle = .overFullScreen + hostingController.modalTransitionStyle = .crossDissolve + hostingController.isModalInPresentation = true + hostingController.view.backgroundColor = .clear + + rootViewController?.topmostViewController.present(hostingController, animated: true) + } + private func isProtectedDataAvailable() -> Bool { let fileManager = FileManager.default do { @@ -870,7 +899,8 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { LoopNotificationCategory.remoteBolusFailure.rawValue, LoopNotificationCategory.remoteCarbs.rawValue, LoopNotificationCategory.remoteCarbsFailure.rawValue, - LoopNotificationCategory.missedMeal.rawValue: + LoopNotificationCategory.missedMeal.rawValue, + LoopNotificationCategory.requiredUpdate.rawValue: completionHandler([.badge, .sound, .list, .banner]) default: // For all others, banners are not to be displayed while in the foreground diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 55470153a2..42eff19f42 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -282,6 +282,22 @@ extension NotificationManager { } } + static func sendRequiredUpdateNotification(appName: String) { + let notification = UNMutableNotificationContent() + notification.title = String(format: NSLocalizedString("Required %1$@ App Update", comment: "The notification title for a required app update (1: app name)"), appName) + notification.body = String(format: NSLocalizedString("To continue to use %1$@, go to the App Store to install the latest version.", comment: "The notification body for a required app update (1: app name)"), appName) + notification.interruptionLevel = .critical + notification.sound = .defaultCritical + + let request = UNNotificationRequest( + identifier: LoopNotificationCategory.requiredUpdate.rawValue, + content: notification, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) + } + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index 5ef8affca8..a97566bc38 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -444,13 +444,8 @@ private extension NotificationSettings { let timeSensitiveSetting: NotificationSettings.NotificationSetting let scheduledDeliverySetting: NotificationSettings.NotificationSetting - if #available(iOS 15.0, *) { - timeSensitiveSetting = NotificationSettings.NotificationSetting(notificationSettings.timeSensitiveSetting) - scheduledDeliverySetting = NotificationSettings.NotificationSetting(notificationSettings.scheduledDeliverySetting) - } else { - timeSensitiveSetting = .unknown - scheduledDeliverySetting = .unknown - } + timeSensitiveSetting = NotificationSettings.NotificationSetting(notificationSettings.timeSensitiveSetting) + scheduledDeliverySetting = NotificationSettings.NotificationSetting(notificationSettings.scheduledDeliverySetting) self.init(authorizationStatus: NotificationSettings.AuthorizationStatus(notificationSettings.authorizationStatus), soundSetting: NotificationSettings.NotificationSetting(notificationSettings.soundSetting), diff --git a/Loop/Managers/SupportManager.swift b/Loop/Managers/SupportManager.swift index 724c180e28..60be36c1fa 100644 --- a/Loop/Managers/SupportManager.swift +++ b/Loop/Managers/SupportManager.swift @@ -37,6 +37,8 @@ public final class SupportManager { } } + var onRequiredUpdate: (() -> Void)? + private let alertIssuer: AlertIssuer private let deviceSupportDelegate: DeviceSupportDelegate private let pluginManager: PluginManager @@ -134,6 +136,9 @@ extension SupportManager { Task { @MainActor in let versionUpdate = await checkVersion() self.notify(versionUpdate) + if versionUpdate == .required { + self.onRequiredUpdate?() + } } } diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index e73195634a..af6bf95491 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -53,16 +53,12 @@ public struct NotificationsCriticalAlertPermissionsView: View { { manageNotifications notificationsEnabledStatus - if #available(iOS 15.0, *) { - if !checker.notificationCenterSettings.notificationsDisabled { - notificationDelivery - } + if !checker.notificationCenterSettings.notificationsDisabled { + notificationDelivery } criticalAlertsStatus - if #available(iOS 15.0, *) { - if !checker.notificationCenterSettings.notificationsDisabled { - timeSensitiveStatus - } + if !checker.notificationCenterSettings.notificationsDisabled { + timeSensitiveStatus } } notificationAndCriticalAlertPermissionSupportSection diff --git a/Loop/Views/RequiredVersionUpdateView.swift b/Loop/Views/RequiredVersionUpdateView.swift new file mode 100644 index 0000000000..a6ff2d9f8d --- /dev/null +++ b/Loop/Views/RequiredVersionUpdateView.swift @@ -0,0 +1,53 @@ +// +// RequiredVersionUpdateView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct RequiredVersionUpdateView: View { + let appName: String + let openAppStore: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.4) + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 40)) + .foregroundColor(.red) + .padding(.top, 8) + + Text(String(format: NSLocalizedString("Required %1$@ App Update", comment: "Title for required version update modal (1: app name)"), appName)) + .font(.headline) + .multilineTextAlignment(.center) + + VStack(spacing: 12) { + Text(String(format: NSLocalizedString("A critical issue has been discovered in this version of %1$@.", comment: "Required update modal paragraph 1 (1: app name)"), appName)) + Text(String(format: NSLocalizedString("Until you update the app, you will not be able to use %1$@.", comment: "Required update modal paragraph 2 (1: app name)"), appName)) + Text(NSLocalizedString("Please go to the App Store to install the latest version.", comment: "Required update modal paragraph 3")) + } + .font(.subheadline) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + + Divider() + + Button(action: openAppStore) { + Text(NSLocalizedString("App Store", comment: "Button title to open the App Store for a required update")) + .fontWeight(.semibold) + } + .padding(.bottom, 8) + } + .padding(24) + .background(Color(.systemBackground)) + .cornerRadius(14) + .shadow(radius: 10) + .padding(.horizontal, 40) + } + } +} From 67c7fe1f7e25c5759297ea5936256a056947f8fe Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 30 Apr 2026 11:40:06 -0700 Subject: [PATCH 399/421] [LOOP-5878, LOOP-5797, LOOP-5772 & LOOP-5771] Remove Duplicate Reference & Fix Status Icon Mismatch/Freshness Sync (#924) * [LOOP-5878] Remove Duplicate Reference * [LOOP-5797] Fix Loop Status Icon Mismatch * [LOOP-5771] Freshness Sync * [LOOP-5771] Freshness Color Sync --- .../StatusTableViewController.swift | 3 ++ Loop/View Models/SettingsViewModel.swift | 11 ++--- Loop/Views/LoopStatusModalView.swift | 45 ++++++++----------- .../Training/PresetsTrainingContent.swift | 1 - LoopUI/Views/LoopCompletionHUDView.swift | 5 +++ 5 files changed, 29 insertions(+), 36 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 3ae385b5d6..7b0814786a 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1636,6 +1636,9 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView.loopCompletionHUD.lastLoopCompleted = loopManager.lastLoopCompleted hudView.loopCompletionHUD.mostRecentGlucoseDataDate = loopManager.mostRecentGlucoseDataDate hudView.loopCompletionHUD.mostRecentPumpDataDate = loopManager.mostRecentPumpDataDate + hudView.loopCompletionHUD.onAgoUpdate = { [weak self] ago in + self?.loopCompletionModalViewModel.ago = ago + } hudView.cgmStatusHUD.stateColors = .cgmStatus hudView.cgmStatusHUD.tintColor = .label diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 75839c4965..9b918876ab 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -131,18 +131,13 @@ class SettingsViewModel { } var loopStatusCircleFreshness: LoopCompletionFreshness { - var age: TimeInterval - if automaticDosingEnabled { let lastLoopCompletion = lastLoopCompletion ?? Date().addingTimeInterval(.minutes(16)) - age = abs(min(0, lastLoopCompletion.timeIntervalSinceNow)) + let age = abs(min(0, lastLoopCompletion.timeIntervalSinceNow)) + return LoopCompletionFreshness(age: age) } else { - let mostRecentGlucoseDataDate = mostRecentGlucoseDataDate ?? Date().addingTimeInterval(.minutes(16)) - let mostRecentPumpDataDate = mostRecentPumpDataDate ?? Date().addingTimeInterval(.minutes(16)) - age = max(abs(min(0, mostRecentPumpDataDate.timeIntervalSinceNow)), abs(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow))) + return .fresh } - - return LoopCompletionFreshness(age: age) } @ObservationIgnored lazy private var cancellables = Set() diff --git a/Loop/Views/LoopStatusModalView.swift b/Loop/Views/LoopStatusModalView.swift index f271906bf4..57d0c7d497 100644 --- a/Loop/Views/LoopStatusModalView.swift +++ b/Loop/Views/LoopStatusModalView.swift @@ -34,25 +34,23 @@ struct LoopStatusModalView: View { var body: some View { VStack { - TimelineView(.animation) { _ in - closeButton - .padding(5) - .frame(maxWidth: .infinity, alignment: .trailing) - - LoopCircleView(closedLoop: viewModel.loopIconClosed, freshness: viewModel.freshness, deviceIssue: deviceIssue) - .environment(\.loopStatusColorPalette, loopStatusColors) - .padding(.bottom) - - if viewModel.loopIconClosed, - let lastLoopCompletedFormattedTime = viewModel.lastLoopCompletedFormattedTime - { - lastLoopCompleted(lastLoopCompletedString: lastLoopCompletedFormattedTime) - } - - automationDetails - .padding([.top, .horizontal]) - .padding(.bottom, 10) + closeButton + .padding(5) + .frame(maxWidth: .infinity, alignment: .trailing) + + LoopCircleView(closedLoop: viewModel.loopIconClosed, freshness: viewModel.freshness, deviceIssue: deviceIssue) + .environment(\.loopStatusColorPalette, loopStatusColors) + .padding(.bottom) + + if viewModel.loopIconClosed, + let lastLoopCompletedFormattedTime = viewModel.lastLoopCompletedFormattedTime + { + lastLoopCompleted(lastLoopCompletedString: lastLoopCompletedFormattedTime) } + + automationDetails + .padding([.top, .horizontal]) + .padding(.bottom, 10) } .padding(10) .background(Color(UIColor.systemGroupedBackground)) @@ -212,21 +210,14 @@ class LoopStatusModalViewModel { }() var freshness: LoopCompletionFreshness { - guard !isPumpInSignalLoss, !isCGMInSignalLoss else { - return .stale - } - guard loopIconClosed else { return .fresh } - + return LoopCompletionFreshness(age: ago) } - var ago: TimeInterval? { - guard let lastLoopCompleted else { return nil } - return abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) - } + var ago: TimeInterval? var includeDateTimeStamp: Bool { // only include if last loop was before today guard let lastLoopCompleted else { return false } diff --git a/Loop/Views/Presets/Training/PresetsTrainingContent.swift b/Loop/Views/Presets/Training/PresetsTrainingContent.swift index 62c7d044a9..77d9eaf2ac 100644 --- a/Loop/Views/Presets/Training/PresetsTrainingContent.swift +++ b/Loop/Views/Presets/Training/PresetsTrainingContent.swift @@ -1040,7 +1040,6 @@ extension PresetsTraining.Step: PresetsTrainingContent { return [ Text(verbatim: "Tagougui S, Taleb N, Legault L, Suppère C, Messier V, Boukabous I, Shohoudi A, Ladouceur M, Rabasa-Lhoret R. A single-blind, randomised, crossover study to reduce hypoglycaemia risk during postprandial exercise with closed-loop insulin delivery in adults with type 1 diabetes: announced (with or without bolus reduction) vs unannounced exercise strategies. ") + Text("[PMID: 32740723](https://pubmed.ncbi.nlm.nih.gov/32740723/)").underline(), Text(verbatim: "Zimmer RT, Auth A, Schierbauer J, Haupt S, Wachsmuth N, Zimmermann P, Voit T, Battelino T, Sourij H, Moser O. (Hybrid) Closed-Loop Systems: From Announced to Unannounced Exercise. ") + Text("[PMID: 38133645](https://pubmed.ncbi.nlm.nih.gov/38133645/)").underline(), - Text(verbatim: "Tagougui S, Taleb N, Legault L, Suppère C, Messier V, Boukabous I, Shohoudi A, Ladouceur M, Rabasa-Lhoret R. A single-blind, randomised, crossover study to reduce hypoglycaemia risk during postprandial exercise with closed-loop insulin delivery in adults with type 1 diabetes: announced (with or without bolus reduction) vs unannounced exercise strategies. ") + Text("[PMID: 32740723](https://pubmed.ncbi.nlm.nih.gov/32740723/)").underline(), Text(verbatim: "Dovc K, Piona C, Yeşiltepe Mutlu G, Bratina N, Jenko Bizjan B, Lepej D, Nimri R, Atlas E, Muller I, Kordonouri O, Biester T, Danne T, Phillip M, Battelino T. Faster Compared With Standard Insulin Aspart During Day-and-Night Fully Closed-Loop Insulin Therapy in Type 1 Diabetes: A Double-Blind Randomized Crossover Trial. ") + Text("[PMID: 31575640](https://pubmed.ncbi.nlm.nih.gov/31575640/)").underline(), Text(verbatim: "Dovc K, Macedoni M, Bratina N, Lepej D, Nimri R, Atlas E, Muller I, Kordonouri O, Biester T, Danne T, Phillip M, Battelino T. Closed-loop glucose control in young people with type 1 diabetes during and after unannounced physical activity: a randomised controlled crossover trial. ") + Text("[PMID: 28840263](https://pubmed.ncbi.nlm.nih.gov/28840263/)").underline() ] diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index 2835a4bc00..28cf874f03 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -83,6 +83,8 @@ public final class LoopCompletionHUDView: BaseHUDView { public var closedLoopDisallowedLocalizedDescription: String? + public var onAgoUpdate: ((TimeInterval?) -> Void)? + public func assertTimer(_ active: Bool = true) { if active && window != nil, let date = lastLoopCompleted { initTimer(date) @@ -152,6 +154,7 @@ public final class LoopCompletionHUDView: BaseHUDView { let ago = min(abs(min(0, date.timeIntervalSinceNow)), TimeInterval.days(7)) freshness = LoopCompletionFreshness(age: ago) + onAgoUpdate?(ago) if let timeString = ago.truncatedTimeAgoString { switch traitCollection.preferredContentSizeCategory { @@ -183,6 +186,7 @@ public final class LoopCompletionHUDView: BaseHUDView { accessibilityLabel = nil } } else if !loopIconClosed, let mostRecentPumpDataDate, let mostRecentGlucoseDataDate { + onAgoUpdate?(nil) let ago = max(abs(min(0, mostRecentPumpDataDate.timeIntervalSinceNow)), abs(min(0, mostRecentGlucoseDataDate.timeIntervalSinceNow))) // when closed loop is off, always present fresh unless there is a device issue (handled else where) @@ -206,6 +210,7 @@ public final class LoopCompletionHUDView: BaseHUDView { accessibilityLabel = nil } } else { + onAgoUpdate?(nil) caption?.text = "" accessibilityLabel = LocalizedString("Waiting for first run", comment: "Accessibility label describing completion HUD waiting for first run") } From 642d5a24f5a5c923e21bdbc948cabce0fdbe1a8a Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 30 Apr 2026 11:40:15 -0700 Subject: [PATCH 400/421] [LOOP-5867] Move LoopAlgorithm operation in updateDisplayState off the main thread (#926) --- Loop/Managers/LoopDataManager.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 3ecf758f98..613d3695b1 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -521,7 +521,7 @@ final class LoopDataManager: ObservableObject { var input = try await fetchData(for: now, ensureDosingCoverageStart: lastManualBolusVisibilityWindowStartDate) input.recommendationType = .manualBolus newState.input = input - newState.output = LoopAlgorithm.run(input: input) + newState.output = await runAlgorithm(input: input) let lastStoredManualBolus = input.doses.last( where: { @@ -543,6 +543,10 @@ final class LoopDataManager: ObservableObject { await updateRemoteRecommendation() } + private nonisolated func runAlgorithm(input: StoredDataAlgorithmInput) async -> AlgorithmOutput { + LoopAlgorithm.run(input: input) + } + /// Cancel the active temp basal if it was automatically issued func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async throws { guard case .tempBasal(let dose) = deliveryDelegate?.basalDeliveryState, (dose.automatic ?? true) else { return } From 79adfea76f75feb71b94bcd13fb8a10c4abaf9cb Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 5 May 2026 17:53:12 -0500 Subject: [PATCH 401/421] Updates to wording and layout tweak for Required Software Update (#927) * Updates to wording and layout tweak for Required Software Update * Tweaks --- Loop/Views/RequiredVersionUpdateView.swift | 11 ++++++----- Loop/Views/SettingsView.swift | 7 +++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Loop/Views/RequiredVersionUpdateView.swift b/Loop/Views/RequiredVersionUpdateView.swift index a6ff2d9f8d..5017d5e577 100644 --- a/Loop/Views/RequiredVersionUpdateView.swift +++ b/Loop/Views/RequiredVersionUpdateView.swift @@ -19,7 +19,7 @@ struct RequiredVersionUpdateView: View { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 40)) - .foregroundColor(.red) + .foregroundColor(.critical) .padding(.top, 8) Text(String(format: NSLocalizedString("Required %1$@ App Update", comment: "Title for required version update modal (1: app name)"), appName)) @@ -27,18 +27,19 @@ struct RequiredVersionUpdateView: View { .multilineTextAlignment(.center) VStack(spacing: 12) { - Text(String(format: NSLocalizedString("A critical issue has been discovered in this version of %1$@.", comment: "Required update modal paragraph 1 (1: app name)"), appName)) - Text(String(format: NSLocalizedString("Until you update the app, you will not be able to use %1$@.", comment: "Required update modal paragraph 2 (1: app name)"), appName)) - Text(NSLocalizedString("Please go to the App Store to install the latest version.", comment: "Required update modal paragraph 3")) + Text(String(format: NSLocalizedString("A critical issue has been discovered in this version of %1$@. To continue using the app, you must update to the latest version.", comment: "Required update modal paragraph 1 (1: app name)"), appName)) + Text(String(format: NSLocalizedString("You will continue to receive your scheduled basal rate, but %1$@ will not make automated adjustments.", comment: "Required update modal paragraph 2 (1: app name)"), appName)) + Text(NSLocalizedString("Please go to the App Store now to update the app.", comment: "Required update modal paragraph 3")) } .font(.subheadline) .multilineTextAlignment(.center) - .foregroundColor(.secondary) Divider() + .padding(.horizontal, -40) Button(action: openAppStore) { Text(NSLocalizedString("App Store", comment: "Button title to open the App Store for a required update")) + .font(.title3) .fontWeight(.semibold) } .padding(.bottom, 8) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 7434db61a4..72e50343fe 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -265,8 +265,11 @@ extension SettingsView { private var softwareUpdateSection: some View { Section(footer: Text(viewModel.versionUpdateViewModel.footer(appName: appName))) { NavigationLink(destination: viewModel.versionUpdateViewModel.softwareUpdateView) { - viewModel.versionUpdateViewModel.icon - Text(NSLocalizedString("Software Update", comment: "Software update button link text")) + HStack { + Text(NSLocalizedString("Software Update", comment: "Software update button link text")) + Spacer() + viewModel.versionUpdateViewModel.icon + } } } } From 36eba3caa121f56ed258917ca28ee90fe91c9f13 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 11 May 2026 12:05:11 -0500 Subject: [PATCH 402/421] LoopDataManager: force post-dose updateRemoteRecommendation Without this, automatic temp basal changes, no-op Loop cycles, and manual temp basal cancellations never produce the "updateRemoteRecommendation" dosing decision that NightscoutService pairs with the cached "loop" decision to upload Loop pill + forecast. Add a force parameter to updateRemoteRecommendation() that bypasses the "manual bolus rec changed" gate, forwarded through updateDisplayState as forceStoreRemoteRecommendation. Call updateDisplayState(force: true) at the end of loop() (success and error branches) and cancelActiveTempBasal(). DIY divergence from tidepool/Loop. See memory/divergence_ns_post_dose_decision.md. Original NS pairing logic introduced in NightscoutService commit 08abe274 (2022-07-18) assumed a separate post-dose decision; later refactoring consolidated it into the "loop" decision, breaking the pairing for non-bolus paths. --- Loop/Managers/LoopDataManager.swift | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index aceba96158..bed2bd7330 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -548,7 +548,7 @@ final class LoopDataManager: ObservableObject { self.notify(forChange: .forecast) } - func updateDisplayState() async { + func updateDisplayState(forceStoreRemoteRecommendation: Bool = false) async { var newState = AlgorithmDisplayState() do { @@ -585,7 +585,7 @@ final class LoopDataManager: ObservableObject { activeInsulin: displayState.activeInsulin ) - await updateRemoteRecommendation() + await updateRemoteRecommendation(force: forceStoreRemoteRecommendation) } private nonisolated func runAlgorithm(input: StoredDataAlgorithmInput) async -> AlgorithmOutput { @@ -618,6 +618,10 @@ final class LoopDataManager: ObservableObject { } await dosingDecisionStore.storeDosingDecision(dosingDecision) + + // DIY: refresh post-dose forecast and persist an "updateRemoteRecommendation" + // decision so Nightscout sees the post-cancel state. + await updateDisplayState(forceStoreRemoteRecommendation: true) } func loop() async { @@ -747,6 +751,12 @@ final class LoopDataManager: ObservableObject { analyticsServicesManager?.loopDidError(error: loopError) NotificationCenter.default.post(name: .LoopCycleCompleted, object: self) } + + // DIY: refresh post-dose forecast and persist an "updateRemoteRecommendation" + // decision (Nightscout's Loop pill + forecast source — paired with the just-stored + // "loop" decision by NightscoutService). Runs for both success and error paths. + await updateDisplayState(forceStoreRemoteRecommendation: true) + logger.default("Loop ended") } @@ -815,13 +825,18 @@ final class LoopDataManager: ObservableObject { displayState.output?.dosesRelativeToBasal ?? [] } - func updateRemoteRecommendation() async { + func updateRemoteRecommendation(force: Bool = false) async { if lastManualBolusRecommendation == nil { lastManualBolusRecommendation = displayState.output?.recommendation?.manual } - guard lastManualBolusRecommendation != displayState.output?.recommendation?.manual else { - // no change + let recommendationChanged = lastManualBolusRecommendation != displayState.output?.recommendation?.manual + + // DIY: post-dose "updateRemoteRecommendation" decisions are also Nightscout's + // Loop pill + forecast source (NightscoutService pairs them with the cached "loop" + // decision). Force-store after every Loop cycle and temp basal cancel so NS stays + // current even when the manual bolus recommendation hasn't changed. + guard force || recommendationChanged else { return } From e500280a12d2090ea46aec687384d6813b55fe0c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 11 May 2026 13:13:16 -0500 Subject: [PATCH 403/421] LiveActivityManagementView: use standard SwiftUI Picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResizeablePicker was the only cross-module use of LoopKitUI's ResizeablePicker (which Tidepool keeps internal-only). Replacing it with a standard Picker(.wheel) here means LoopKit no longer needs DIY-only public modifiers on ResizeablePicker — eliminating a small but ongoing DIY divergence in LoopKitUI. --- Loop/Views/LiveActivityManagementView.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift index fac5576c55..84dba8590d 100644 --- a/Loop/Views/LiveActivityManagementView.swift +++ b/Loop/Views/LiveActivityManagementView.swift @@ -40,9 +40,13 @@ struct LiveActivityManagementView: View { .foregroundStyle(viewModel.isEditingMode ? .blue : .primary) }, expandedContent: { - ResizeablePicker(selection: self.$viewModel.mode.animation(), - data: LiveActivityMode.all, - formatter: { $0.name() }) + Picker(selection: $viewModel.mode.animation(), label: EmptyView()) { + ForEach(LiveActivityMode.all, id: \.self) { mode in + Text(mode.name()).tag(mode) + } + } + .pickerStyle(.wheel) + .labelsHidden() } ) .onChange(of: viewModel.mode) { _ in From 522f8c1fda3dff8e395ec1a58e4c8fd6c77a6343 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 11 May 2026 14:18:23 -0500 Subject: [PATCH 404/421] Live Activity: render preset SF Symbol instead of Optional debug text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: lock-screen Live Activity preview was showing raw Swift debug output like 'Optional(LoopKit.PresetSymbol(symbolType: ..., value: "figure.outdoor.cycle"))' above the preset name, because TemporaryScheduleOverride.getTitle() was interpolating preset.symbol directly: return "\(preset.symbol) \(preset.name)" preset.symbol is Optional (a struct introduced by the 2026-03-10 tidepool sync — wraps an SF Symbol name with a tint), not a String, so Swift's default Optional interpolation produced the debug dump. Fix: - Add iconSystemSymbolName: String? to the Preset Codable struct in GlucoseActivityAttributes (with backward-compatible decoder). - Replace getTitle() -> String with liveActivityTitleAndSymbol() -> (title, systemSymbolName?). Emoji symbols are folded inline into the title (they render fine as text); .systemImage symbols are returned as a separate SF Symbol name; .image (asset) symbols can't be loaded from the widget bundle so we render the name without an icon. Pre-meal preset also gets a fork.knife icon. - In ChartView, render the preset label as Text(Image(systemName: iconSystemSymbolName)) + Text(" ") + Text(title) when a symbol is present, else just Text(title). --- .../Live Activity/ChartView.swift | 14 ++++++--- .../GlucoseActivityAttributes.swift | 23 +++++++++++++++ .../Live Activity/LiveActivityManager.swift | 29 ++++++++++++++----- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index 241c1f4667..2d68a5067e 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -171,10 +171,16 @@ struct ChartView: View { } if let preset = self.preset, preset.endDate > Date.now { - Text(preset.title) - .font(.footnote) - .padding(.trailing, 5) - .padding(.top, 2) + Group { + if let symbolName = preset.iconSystemSymbolName { + Text(Image(systemName: symbolName)) + Text(" ") + Text(preset.title) + } else { + Text(preset.title) + } + } + .font(.footnote) + .padding(.trailing, 5) + .padding(.top, 2) } } } diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift index 1b3328d65b..0c4f5104d2 100644 --- a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -52,10 +52,33 @@ public struct GlucoseActivityAttributes: ActivityAttributes { public struct Preset: Codable, Hashable { public let title: String + /// SF Symbol name to render alongside the title. nil means render the title alone. + /// Emoji symbols are folded into `title` directly (they render as plain text); only + /// `.systemImage` symbols use this field. + public let iconSystemSymbolName: String? public let startDate: Date public let endDate: Date public let minValue: Double public let maxValue: Double + + public init(title: String, iconSystemSymbolName: String? = nil, startDate: Date, endDate: Date, minValue: Double, maxValue: Double) { + self.title = title + self.iconSystemSymbolName = iconSystemSymbolName + self.startDate = startDate + self.endDate = endDate + self.minValue = minValue + self.maxValue = maxValue + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.title = try container.decode(String.self, forKey: .title) + self.iconSystemSymbolName = try container.decodeIfPresent(String.self, forKey: .iconSystemSymbolName) + self.startDate = try container.decode(Date.self, forKey: .startDate) + self.endDate = try container.decode(Date.self, forKey: .endDate) + self.minValue = try container.decode(Double.self, forKey: .minValue) + self.maxValue = try container.decode(Double.self, forKey: .maxValue) + } } public struct GlucoseRangeValue: Identifiable, Codable, Hashable { diff --git a/Loop/Managers/Live Activity/LiveActivityManager.swift b/Loop/Managers/Live Activity/LiveActivityManager.swift index 317bcb8b47..179b4a0536 100644 --- a/Loop/Managers/Live Activity/LiveActivityManager.swift +++ b/Loop/Managers/Live Activity/LiveActivityManager.swift @@ -162,8 +162,10 @@ class LiveActivityManager : LiveActivityManagerProxy { // presetEnd < presetStart and drawing a RectangleMark with those backwards dates // forces SwiftUI Charts to expand the x-axis far into the past. if presetStart <= presetEnd { + let (title, iconSymbol) = override.liveActivityTitleAndSymbol() presetContext = Preset( - title: override.getTitle(), + title: title, + iconSystemSymbolName: iconSymbol, startDate: presetStart, endDate: presetEnd, minValue: override.settings.targetRange?.lowerBound.doubleValue(for: unit) ?? 0, @@ -559,16 +561,29 @@ class LiveActivityManager : LiveActivityManagerProxy { } extension TemporaryScheduleOverride { - func getTitle() -> String { - switch (self.context) { + /// Returns the Live Activity preset display: a plain-text title (with emoji folded in) + /// and, when the preset uses an SF Symbol, the symbol name to render via Image(systemName:). + func liveActivityTitleAndSymbol() -> (title: String, systemSymbolName: String?) { + switch context { case .preset(let preset): - return "\(preset.symbol) \(preset.name)" + guard let symbol = preset.symbol else { + return (preset.name, nil) + } + switch symbol.symbolType { + case .emoji: + return ("\(symbol.value) \(preset.name)", nil) + case .systemImage: + return (preset.name, symbol.value) + case .image: + // Asset-image symbols can't be loaded from the widget bundle; render name only. + return (preset.name, nil) + } case .custom: - return NSLocalizedString("Custom preset", comment: "The title of the cell indicating a generic custom preset is enabled") + return (NSLocalizedString("Custom preset", comment: "The title of the cell indicating a generic custom preset is enabled"), nil) case .preMeal: - return NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)") + return (NSLocalizedString("Pre-meal Preset", comment: "Status row title for premeal override enabled"), "fork.knife") @unknown default: - return "" + return ("", nil) } } } From 6f848c7e58265455bd4bd1777bf71bd1a2c6a9ca Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 11 May 2026 14:22:16 -0500 Subject: [PATCH 405/421] Live Activity: move preset label to top-leading so it doesn't overlap y-axis labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart's y-axis labels (175/150/125/100) render on the right side of the plot, but the preset-name label (e.g. '🚴 Biking') was also at top-trailing, so they crowded the same top-right corner. Switched the ZStack alignment to .leading and updated the label padding from trailing→leading so the preset sits in the empty top-left area. --- Loop Widget Extension/Live Activity/ChartView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index 2d68a5067e..8f8266c66b 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -98,7 +98,7 @@ struct ChartView: View { } var body: some View { - ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)){ + ZStack(alignment: Alignment(horizontal: .leading, vertical: .top)){ Chart { if let preset = self.preset, (preset.minValue > 0 || preset.maxValue > 0), predicatedData.count > 0, preset.endDate > Date.now.addingTimeInterval(.hours(-6)) { let (presetMin, presetMax) = adjustedRange(min: preset.minValue, max: preset.maxValue) @@ -179,7 +179,7 @@ struct ChartView: View { } } .font(.footnote) - .padding(.trailing, 5) + .padding(.leading, 5) .padding(.top, 2) } } From 72dc6239f1ed52c84bee8c428f9356efa7c0d3e4 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 11 May 2026 14:28:09 -0500 Subject: [PATCH 406/421] Live Activity: anchor preset label inside the plot area (top-trailing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use .chartPlotStyle's overlay (alignment: .topTrailing) to render the preset label inside the plot rectangle rather than via an outer ZStack. The plot area excludes axis labels, so right-aligning inside it puts the label flush with the right edge of the chart without overlapping the y-axis numbers (175/150/125/100) that render along the trailing axis. Removed the surrounding ZStack and the trailing/.top vs leading/.top alignment juggling — not needed anymore. --- .../Live Activity/ChartView.swift | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift index 8f8266c66b..3773b5c1b2 100644 --- a/Loop Widget Extension/Live Activity/ChartView.swift +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -98,8 +98,7 @@ struct ChartView: View { } var body: some View { - ZStack(alignment: Alignment(horizontal: .leading, vertical: .top)){ - Chart { + Chart { if let preset = self.preset, (preset.minValue > 0 || preset.maxValue > 0), predicatedData.count > 0, preset.endDate > Date.now.addingTimeInterval(.hours(-6)) { let (presetMin, presetMax) = adjustedRange(min: preset.minValue, max: preset.maxValue) RectangleMark( @@ -146,9 +145,24 @@ struct ChartView: View { "Low": Self.colorBelowRange, "Default": Color("glucose") ]) - .chartPlotStyle { plotContent in - plotContent.background(.cyan.opacity(0.15)) - } + .chartPlotStyle { plotContent in + plotContent + .background(.cyan.opacity(0.15)) + .overlay(alignment: .topTrailing) { + if let preset = self.preset, preset.endDate > Date.now { + Group { + if let symbolName = preset.iconSystemSymbolName { + Text(Image(systemName: symbolName)) + Text(" ") + Text(preset.title) + } else { + Text(preset.title) + } + } + .font(.footnote) + .padding(.trailing, 4) + .padding(.top, 2) + } + } + } .chartLegend(.hidden) .chartYScale(domain: [yAxisMarks.first ?? 0, yAxisMarks.last ?? 0]) .chartYAxis { @@ -161,26 +175,12 @@ struct ChartView: View { .foregroundStyle(Color.primary) } } - .chartXAxis { - AxisMarks(position: .automatic, values: .stride(by: .hour)) { _ in - AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .narrow)), anchor: .top) - .foregroundStyle(Color.primary) - AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) - .foregroundStyle(Color.primary) - } - } - - if let preset = self.preset, preset.endDate > Date.now { - Group { - if let symbolName = preset.iconSystemSymbolName { - Text(Image(systemName: symbolName)) + Text(" ") + Text(preset.title) - } else { - Text(preset.title) - } - } - .font(.footnote) - .padding(.leading, 5) - .padding(.top, 2) + .chartXAxis { + AxisMarks(position: .automatic, values: .stride(by: .hour)) { _ in + AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .narrow)), anchor: .top) + .foregroundStyle(Color.primary) + AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) + .foregroundStyle(Color.primary) } } } From 830af33d06b462f46ee832d3d70497819c47ef01 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 11 May 2026 14:34:03 -0500 Subject: [PATCH 407/421] Settings: promote Live Activity to top-level menu under Alert Management Moved the 'Live activity' NavigationLink out of AlertManagementView and into SettingsView's alertManagementSection, directly below the Alert Management row. Both now share a Section using the LargeButton style. The new Live Activity row uses 'rectangle.on.rectangle' as the icon and the descriptive text 'Lock Screen, Dynamic Island, and CarPlay display'. Rationale: Live Activity is an output/display concern, not an alert permission concern, so users were unlikely to look for it inside Alert Management. Surfacing it at the top level makes it discoverable. --- Loop/Views/AlertManagementView.swift | 5 ----- Loop/Views/SettingsView.swift | 13 +++++++++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 4b2866a724..a500c26b7f 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -98,11 +98,6 @@ struct AlertManagementView: View { } } } - - NavigationLink(destination: LiveActivityManagementView()) - { - Text(NSLocalizedString("Live activity", comment: "Alert Permissions live activity")) - } } } diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index bb51a891b1..cb20eed9f4 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -317,6 +317,19 @@ extension SettingsView { ) .accessibilityIdentifier("settingsViewAlertManagement") } + NavigationLink(destination: LiveActivityManagementView()) { + LargeButton( + action: {}, + includeArrow: false, + imageView: Image(systemName: "rectangle.on.rectangle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30), + label: NSLocalizedString("Live Activity", comment: "Live Activity settings button text"), + descriptiveText: NSLocalizedString("Lock Screen, Dynamic Island, and CarPlay display", comment: "Live Activity settings descriptive text") + ) + .accessibilityIdentifier("settingsViewLiveActivity") + } } } From ce1935b54c5bcb1fdc4c5bfbfad7d41d0ec4eb32 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 16 May 2026 10:28:11 -0500 Subject: [PATCH 408/421] LoopAppManager: force-unwrap watch/statusExtension managers in diagnostic report Both fields are implicitly-unwrapped optionals that are guaranteed to be set by the time the diagnostic report runs; reflecting the Optional wrappers produced opaque "Optional(...)" lines that obscured the underlying state. Matches how the other manager fields above and below these lines are rendered. --- Loop/Managers/LoopAppManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index a2b8d94549..a360b8d8e9 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -1067,9 +1067,9 @@ extension LoopAppManager: DiagnosticReportGenerator { await alertManager.generateDiagnosticReport(), await deviceDataManager.generateDiagnosticReport(), "", - String(reflecting: self.watchManager), + String(reflecting: self.watchManager!), "", - String(reflecting: self.statusExtensionManager), + String(reflecting: self.statusExtensionManager!), "", await loopDataManager.generateDiagnosticReport(), "", From 4eea0b24b07f1ebae0b886e68a6b3cadbb23e97d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 16 May 2026 12:09:49 -0500 Subject: [PATCH 409/421] Restore predicted carb-effect line on ICE and food-insight charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The carb-effect chart on the Carb Absorption screen and the Favorite Foods insights screen lost their predicted carb-effect line in the Tidepool stateless-algorithm refactor. `dynamicGlucoseEffects(from: end, ...)` generated effects only from now+1h forward, but the chart's visible window ends at roughly now+1h — so the two ranges only touched at a single point and nothing rendered. Widen the output sampling window to `from: start` at both call sites (carb-absorption review and historical-charts data). The carb-absorption model itself is unchanged; only the sample window grows to span the chart. No dosing-path impact: the main Loop algorithm runs through LoopAlgorithm (SPM), not these UI-only helpers. Reported by @marionbarker in LoopKit/LoopWorkspace#213. --- Loop/Managers/LoopDataManager+CarbAbsorption.swift | 2 +- Loop/Managers/LoopDataManager.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager+CarbAbsorption.swift b/Loop/Managers/LoopDataManager+CarbAbsorption.swift index 643bddc9ed..dfc1ce65c7 100644 --- a/Loop/Managers/LoopDataManager+CarbAbsorption.swift +++ b/Loop/Managers/LoopDataManager+CarbAbsorption.swift @@ -86,7 +86,7 @@ extension LoopDataManager { ) let carbEffects = carbStatus.dynamicGlucoseEffects( - from: end, + from: start, to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), carbRatios: carbRatioWithOverrides, insulinSensitivities: sensitivityWithOverrides, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index bed2bd7330..fd574107da 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1507,7 +1507,7 @@ extension LoopDataManager: FavoriteFoodInsightsViewModelDelegate { ) let carbEffects = carbStatus.dynamicGlucoseEffects( - from: end, + from: start, to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), carbRatios: carbRatioWithOverrides, insulinSensitivities: sensitivityWithOverrides, From 3072541390b24502f624d7eb210ab8e837a3b0f7 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 16 May 2026 15:11:00 -0500 Subject: [PATCH 410/421] LoopAppManager: restore submodule SHAs in diagnostic report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marion's #2399 (b6e88416) added submodule branch+SHA to the build-details section of the diagnostic report. The script and BuildDetails.submodules accessor survived the Tidepool merges, but the report-builder block itself was relocated from DeviceDataManager.swift to LoopAppManager in Tidepool's refactor, and the merge kept Tidepool's older shape — so the consumer of .submodules was effectively dropped. Port the original change into generateDiagnosticReport(): - drop the now-redundant Loop-submodule gitRevision/gitBranch lines - rename workspaceGit{Revision,Branch} to "Workspace SHA/branch" - append the submodule list (alphabetized, "name: branch, sha") --- Loop/Managers/LoopAppManager.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index a360b8d8e9..7b16e274fd 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -1049,17 +1049,24 @@ extension LoopAppManager: DiagnosticReportGenerator { /// - parameter completion: A closure called once the report has been generated. The closure takes a single argument of the report string. func generateDiagnosticReport() async -> String { + let submodulesInfo = BuildDetails.default.submodules + .sorted(by: { $0.key < $1.key }) + .map { key, value in + "* \(key): \(value.branch), \(value.commitSHA)" + } + .joined(separator: "\n") + let entries: [String] = [ "## Build Details", "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", "* profileExpiration: \(BuildDetails.default.profileExpirationString)", - "* 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")", + "* Workspace branch: \(BuildDetails.default.workspaceGitBranch ?? "N/A")", + "* Workspace SHA: \(BuildDetails.default.workspaceGitRevision ?? "N/A")", + "* Submodule name: branch, SHA", + "\(submodulesInfo)", "", "## FeatureFlags", "\(FeatureFlags)", From 52564fcb6e2201f43b68f9574351d7c66da04b1c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 19 May 2026 15:06:19 -0500 Subject: [PATCH 411/421] Label manually-entered boluses as External Insulin and support deletion Adds an `external` case to the bolus event type so manually-entered doses appear as "External Insulin" in the delivery log instead of being lumped in with corrections. The event details screen gains a destructive Delete button (with confirmation) that removes the dose via DoseStore.deleteDose when the underlying DoseEntry is manually entered. --- Loop/Models/InsulinDeliveryLogEvent.swift | 1 + .../InsulinDeliveryEventDetailsView.swift | 41 +++++++++++++++++-- .../InsulinDeliveryLog.swift | 9 +++- .../InsulinDeliveryLogEventRow.swift | 22 ++++++++-- .../InsulinDeliveryLogViewModel.swift | 37 ++++++++++++++++- 5 files changed, 101 insertions(+), 9 deletions(-) diff --git a/Loop/Models/InsulinDeliveryLogEvent.swift b/Loop/Models/InsulinDeliveryLogEvent.swift index 80bb2a5213..9305601a9a 100644 --- a/Loop/Models/InsulinDeliveryLogEvent.swift +++ b/Loop/Models/InsulinDeliveryLogEvent.swift @@ -32,6 +32,7 @@ struct InsulinDeliveryLogEvent: Hashable, Identifiable { case automated case meal(recommendedAmount: LoopQuantity, carbAmount: LoopQuantity, emoji: String) case correction(recommendedAmount: LoopQuantity?) + case external } case bolus(BolusEventType, programmedAmount: LoopQuantity?, deliveryAmount: LoopQuantity) diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift index b1ac8e3616..c5fb9a0714 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift @@ -11,16 +11,24 @@ import LoopKit import SwiftUI struct InsulinDeliveryEventDetailsView: View { - + + @Environment(\.dismiss) private var dismiss + let basalUnitsFormatter = QuantityFormatter(for: .internationalUnitsPerHour) let bolusUnitsFormatter = QuantityFormatter(for: .internationalUnit) let durationFormatter = DateComponentsFormatter() - + let pumpEventType: InsulinDeliveryLogEvent.EventType.PumpEventType let doseEntry: DoseEntry let onTapGesture: (DoseEntry) -> Void + let onDelete: ((DoseEntry) async -> Void)? + + @State private var showingDeleteConfirmation = false var doseTypeValue: String { + if case .bolus(.external, _, _) = pumpEventType { + return NSLocalizedString("External Insulin", comment: "Dose type label for a manually-entered bolus") + } switch pumpEventType { case .basal(let basalEventType, _): switch basalEventType { @@ -144,11 +152,38 @@ struct InsulinDeliveryEventDetailsView: View { } header: { Text("Delivery Details") } - .navigationTitle(Text("Insulin Event")) .contentShape(Rectangle()) .onTapGesture { onTapGesture(doseEntry) } + + if doseEntry.manuallyEntered, let onDelete { + Section { + Button(role: .destructive) { + showingDeleteConfirmation = true + } label: { + HStack { + Spacer() + Text("Delete External Insulin") + Spacer() + } + } + } + .confirmationDialog( + Text("Delete this manually-entered insulin entry?"), + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + Task { + await onDelete(doseEntry) + dismiss() + } + } + Button("Cancel", role: .cancel) { } + } + } } + .navigationTitle(Text("Insulin Event")) } } diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift index c84f30523e..d8b1fd04e7 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLog.swift @@ -110,7 +110,14 @@ struct InsulinDeliveryLog: View { if case let .pumpEvent(pumpEventType, doseEntry) = event.type, let doseEntry { NavigationLink { - InsulinDeliveryEventDetailsView(pumpEventType: pumpEventType, doseEntry: doseEntry, onTapGesture: onTapGesture) + InsulinDeliveryEventDetailsView( + pumpEventType: pumpEventType, + doseEntry: doseEntry, + onTapGesture: onTapGesture, + onDelete: { entry in + await viewModel.deleteDose(entry) + } + ) } label: { EmptyView() } diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift index 324f6215bb..17d02c99ed 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogEventRow.swift @@ -247,9 +247,25 @@ struct InsulinDeliveryLogEventRow: View { .foregroundStyle(.secondary) } } - + Spacer() - + + Text(event.date.formatted(date: .omitted, time: .shortened)) + .font(.system(size: dateFontSize)) + .foregroundStyle(.secondary) + } + case .external: + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + bolusTitle(deliveryAmount: deliveryAmount, programmedAmount: programmedAmount) + + Text("External Insulin") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + Text(event.date.formatted(date: .omitted, time: .shortened)) .font(.system(size: dateFontSize)) .foregroundStyle(.secondary) @@ -363,7 +379,7 @@ struct InsulinDeliveryLogEventRow: View { EmptyView() case .pumpEvent(.bolus(let bolusEventType, _, _), _): switch bolusEventType { - case .automated, .correction: + case .automated, .correction, .external: EmptyView() case .meal(_, let carbAmount, let emoji): VStack(alignment: .leading, spacing: 8) { diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift index a5177c46fe..a0f563103d 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryLogViewModel.swift @@ -78,7 +78,8 @@ class InsulinDeliveryLogViewModel { .pumpEvent(.basal(.manualTempBasal, rate: _), _), .pumpEvent(.insulin, _), .pumpEvent(.bolus(.correction, _, _), _), - .pumpEvent(.bolus(.meal, _, _), _): + .pumpEvent(.bolus(.meal, _, _), _), + .pumpEvent(.bolus(.external, _, _), _): return true default: return false @@ -154,6 +155,17 @@ class InsulinDeliveryLogViewModel { } } + func deleteDose(_ doseEntry: DoseEntry) async { + await withCheckedContinuation { continuation in + doseStore.deleteDose(doseEntry) { error in + if let error { + self.log.error("Error deleting dose: %{public}@", String(describing: error)) + } + continuation.resume() + } + } + } + func fetchData() async { if case let .fetched(data) = state { state = .refreshing(data) @@ -397,7 +409,28 @@ class InsulinDeliveryLogViewModel { } private func handleBolusEvents(dose: DoseEntry, decision: LightDosingDecision?, events: inout [InsulinDeliveryLogEvent]) { - if dose.automatic == true { + if dose.manuallyEntered { + events.append( + InsulinDeliveryLogEvent( + id: dose.syncIdentifier ?? UUID().uuidString, + type: .pumpEvent( + .bolus( + .external, + programmedAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.programmedUnits + ), + deliveryAmount: LoopQuantity( + unit: .internationalUnit, + doubleValue: dose.deliveredUnits ?? dose.programmedUnits + ) + ), + dose + ), + date: dose.startDate + ) + ) + } else if dose.automatic == true { events.append( InsulinDeliveryLogEvent( id: dose.syncIdentifier ?? UUID().uuidString, From 3f4fd6047244282c1de679b185049e42719e6d3a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 19 May 2026 15:20:57 -0500 Subject: [PATCH 412/421] Add External Insulin strings to catalog --- Loop/Localizable.xcstrings | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index c493bb9383..0e5aaad83f 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -15668,6 +15668,10 @@ } } }, + "Delete External Insulin" : { + "comment" : "A button to delete a manually-entered insulin dose.", + "isCommentAutoGenerated" : true + }, "Delete Food" : { "localizations" : { "da" : { @@ -16122,6 +16126,10 @@ } } }, + "Delete this manually-entered insulin entry?" : { + "comment" : "A confirmation dialog asking the user to confirm the deletion of a manually-entered insulin dose.", + "isCommentAutoGenerated" : true + }, "Deliver" : { "comment" : "Button text to deliver a bolus", "localizations" : { @@ -19070,6 +19078,9 @@ } } }, + "External Insulin" : { + "comment" : "Dose type label for a manually-entered bolus" + }, "Failed to Resume Insulin Delivery" : { "comment" : "The alert title for a resume error", "localizations" : { From 05d2f4aed9194f86b586a445131a93fec6b61ed0 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 19 May 2026 15:22:41 -0500 Subject: [PATCH 413/421] PresetsTrainingContent: fix Text wrapping with fixedSize --- Loop/Views/Presets/Training/PresetsTrainingContent.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Loop/Views/Presets/Training/PresetsTrainingContent.swift b/Loop/Views/Presets/Training/PresetsTrainingContent.swift index 77d9eaf2ac..623bc849b9 100644 --- a/Loop/Views/Presets/Training/PresetsTrainingContent.swift +++ b/Loop/Views/Presets/Training/PresetsTrainingContent.swift @@ -66,7 +66,8 @@ extension PresetsTraining.Step: PresetsTrainingContent { VStack(alignment: .leading) { Text("The \"Overall Insulin\" percentage controls total insulin delivery by adjusting your:") - + .fixedSize(horizontal: false, vertical: true) + BulletedListView { Text("Basal Rate") Text("Carb Ratio") @@ -153,7 +154,8 @@ extension PresetsTraining.Step: PresetsTrainingContent { case .overallInsulin: Text("Paloma wants \(appName) to know she needs more insulin than usual.") - + .fixedSize(horizontal: false, vertical: true) + TherapySettingsExampleView( title: NSLocalizedString("Paloma’s Current Therapy Settings", comment: ""), components: [ @@ -164,7 +166,8 @@ extension PresetsTraining.Step: PresetsTrainingContent { ) Text("She can do this by raising her **Overall Insulin** setting. This tells \(appName) to deliver more than her usual amount, making her insulin settings stronger.") - + .fixedSize(horizontal: false, vertical: true) + if let image = Image.optional("PresetsTrainingIllnessOverallInsulin") { image .resizable() From e7a1e210816a985c25755cf8a01334eaac7fd7ae Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 19 May 2026 15:25:33 -0500 Subject: [PATCH 414/421] PresetsView: remove Performance History entry point --- Loop/Views/Presets/PresetsView.swift | 53 +++++----------------------- 1 file changed, 8 insertions(+), 45 deletions(-) diff --git a/Loop/Views/Presets/PresetsView.swift b/Loop/Views/Presets/PresetsView.swift index f3c28115a8..e31efd6219 100644 --- a/Loop/Views/Presets/PresetsView.swift +++ b/Loop/Views/Presets/PresetsView.swift @@ -49,11 +49,6 @@ enum ActiveSheet: Identifiable { struct PresetsView: View { - // Define navigation routes - enum NavigationDestination: Hashable { - case presetsHistory - } - @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference @Environment(\.appName) private var appName @Environment(\.settingsManager) private var settingsManager @@ -187,33 +182,13 @@ struct PresetsView: View { } } - // Support Section - VStack(alignment: .leading, spacing: 16) { - Text("Support") - .font(.headline.weight(.semibold)) - .padding(.horizontal, 10) - - NavigationLink(value: NavigationDestination.presetsHistory) { - HStack { - Image("performance-history-empty") - .resizable() - .scaledToFit() - .frame(width: 32, height: 32) - - Text("Performance History") - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.gray) - } - } - .padding(10) - .foregroundStyle(.primary) - .background(RoundedRectangle(cornerRadius: 8) - .fill(Color(UIColor.tertiarySystemBackground)) - .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1) - .frame(maxWidth: .infinity)) - - if trainingCompletion.isComplete { + if trainingCompletion.isComplete { + // Support Section + VStack(alignment: .leading, spacing: 16) { + Text("Support") + .font(.headline.weight(.semibold)) + .padding(.horizontal, 10) + Button { activeSheet = .training() } label: { @@ -222,7 +197,7 @@ struct PresetsView: View { .resizable() .scaledToFit() .frame(width: 32, height: 32) - + Text("Learning Hub") Spacer() Image(systemName: "chevron.right") @@ -244,18 +219,6 @@ struct PresetsView: View { .background(Color(UIColor.secondarySystemBackground)) .navigationTitle(Text("Presets", comment: "Presets screen title")) .navigationBarItems(trailing: dismissButton) - .navigationDestination(for: NavigationDestination.self) { route in - switch route { - case .presetsHistory: - PresetsHistoryView( - temporaryPresetsManager: temporaryPresetsManager, - glucoseStore: glucoseStore, - carbStore: carbStore, - doseStore: doseStore, - automationHistory: automationHistory - ) - } - } } .sheet(item: $activeSheet) { sheet in switch sheet { From fd313d06dc8c2827ced2dbceaf24350c5389a0dd Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 20 May 2026 16:49:32 -0500 Subject: [PATCH 415/421] WatchApp: drop WKWatchKitApp, keep WKApplication (fix watch embedding) The dev merge resurrected the legacy WKWatchKitApp key alongside DIY's modern WKApplication single-target watch app, causing 'WatchKit App doesn't contain any WatchKit Extensions' on archive/device builds. Keep WKApplication + dev's WKSupportsLiveActivityLaunchAttributeTypes; remove WKWatchKitApp. --- WatchApp/Info.plist | 2 -- 1 file changed, 2 deletions(-) diff --git a/WatchApp/Info.plist b/WatchApp/Info.plist index 3e263f21d3..3e7b8187eb 100644 --- a/WatchApp/Info.plist +++ b/WatchApp/Info.plist @@ -47,7 +47,5 @@ GlucoseActivityAttributes - WKWatchKitApp - From a81435c2b7dba98d7dd0902dc4ba35d69f49298e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 20 May 2026 18:27:31 -0500 Subject: [PATCH 416/421] Add Apple Health access status screen to Settings New "Apple Health" settings row pushes a detail page showing the write/share authorization status (Allowed/Denied/Not Set) for glucose and insulin, with a warning indicator when sharing is denied. Read authorization is intentionally not reported by iOS, so the page explains that, notes Loop reads insulin data from other apps, and points the user at the Health app to review or change access (the app cannot re-prompt once a choice is made). Exposes DeviceDataManager.healthKitSharingStatus(for:) so the Settings layer can read per-type sharing authorization. --- Loop.xcodeproj/project.pbxproj | 4 + Loop/Managers/DeviceDataManager.swift | 9 +++ Loop/Views/HealthAccessView.swift | 108 ++++++++++++++++++++++++++ Loop/Views/SettingsView.swift | 39 ++++++++++ 4 files changed, 160 insertions(+) create mode 100644 Loop/Views/HealthAccessView.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index d17636e443..8e3ef627cb 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -80,6 +80,7 @@ 1DB619AC270BAD3D006C9D07 /* VersionUpdateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */; }; 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */; }; 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */; }; + BAF043BF0BC70EC99AE79F71 /* HealthAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4854073AAE5C2CB97167F63 /* HealthAccessView.swift */; }; 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */; }; 1DFE9E172447B6270082C280 /* UserNotificationAlertSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DFE9E162447B6270082C280 /* UserNotificationAlertSchedulerTests.swift */; }; 3ED3198A2EB659E600820BCF /* GlucoseLiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED319882EB659E600820BCF /* GlucoseLiveActivityConfiguration.swift */; }; @@ -827,6 +828,7 @@ 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionUpdateViewModel.swift; sourceTree = ""; }; 1DC63E7325351BDF004605DA /* TrueTime.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TrueTime.framework; path = Carthage/Build/iOS/TrueTime.framework; sourceTree = ""; }; 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + E4854073AAE5C2CB97167F63 /* HealthAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAccessView.swift; sourceTree = ""; }; 1DFE9E162447B6270082C280 /* UserNotificationAlertSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationAlertSchedulerTests.swift; sourceTree = ""; }; 3ED319862EB659E600820BCF /* BasalViewActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalViewActivity.swift; sourceTree = ""; }; 3ED319872EB659E600820BCF /* ChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartView.swift; sourceTree = ""; }; @@ -2005,6 +2007,7 @@ B43B5C532EAFBF110096A6AE /* RecentGlucoseTableViewCell.xib */, C151634D2FA2C9C800FEECE8 /* RequiredVersionUpdateView.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, + E4854073AAE5C2CB97167F63 /* HealthAccessView.swift */, DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */, C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */, 43F64DD81D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift */, @@ -3530,6 +3533,7 @@ 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */, 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */, + BAF043BF0BC70EC99AE79F71 /* HealthAccessView.swift in Sources */, A9B607B0247F000F00792BE4 /* UserNotifications+Loop.swift in Sources */, 43F89CA322BDFBBD006BB54E /* UIActivityIndicatorView.swift in Sources */, A999D40624663D18004C89D4 /* PumpManagerError.swift in Sources */, diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 2f3832a1d4..a5a6cda38c 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -553,6 +553,15 @@ final class DeviceDataManager { completion(authorizationRequestStatus) } } + + /// The sharing (write) authorization status for a HealthKit type. + /// + /// HealthKit only exposes share/write authorization. Read authorization is + /// intentionally hidden by the system for privacy, so there is no equivalent + /// accessor for read access. + func healthKitSharingStatus(for type: HKObjectType) -> HKAuthorizationStatus { + healthStore.authorizationStatus(for: type) + } // Get HealthKit authorization for all of the stores func authorizeHealthStore(_ completion: @escaping (HKAuthorizationRequestStatus) -> Void) { diff --git a/Loop/Views/HealthAccessView.swift b/Loop/Views/HealthAccessView.swift new file mode 100644 index 0000000000..b0f1103e83 --- /dev/null +++ b/Loop/Views/HealthAccessView.swift @@ -0,0 +1,108 @@ +// +// HealthAccessView.swift +// Loop +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit +import LoopKitUI +import SwiftUI + +/// Shows the app's Apple Health access for glucose and insulin. +/// +/// HealthKit only exposes *sharing* (write) authorization, so this screen can show +/// write status definitively. Read authorization is intentionally hidden by the system +/// for privacy and cannot be displayed — the screen explains that and points the user at +/// the Settings / Health apps, which are the only place permissions can be changed (the +/// app cannot re-prompt once the user has responded). +struct HealthAccessView: View { + + @Environment(\.appName) private var appName + + /// Closures so status is re-read each time the screen appears (e.g. after the user + /// returns from the Settings app having changed a permission). + let glucoseSharingStatus: () -> HKAuthorizationStatus + let insulinSharingStatus: () -> HKAuthorizationStatus + + @State private var glucoseStatus: HKAuthorizationStatus = .notDetermined + @State private var insulinStatus: HKAuthorizationStatus = .notDetermined + + var body: some View { + List { + Section { + statusRow(label: NSLocalizedString("Glucose", comment: "Health access row label for glucose"), status: glucoseStatus) + statusRow(label: NSLocalizedString("Insulin", comment: "Health access row label for insulin"), status: insulinStatus) + } header: { + Text("Sharing to Apple Health", comment: "Health access section header for write access") + } footer: { + Text(String(format: NSLocalizedString("Whether %1$@ is allowed to save glucose and insulin data to Apple Health.", comment: "Health access write section footer (1: app name)"), appName)) + } + + Section { + Text(String(format: NSLocalizedString("%1$@ also reads insulin data from other apps in Apple Health. Apple Health does not report whether an app may read data, so read access can't be shown here.", comment: "Health access reading explanation (1: app name)"), appName)) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(String(format: NSLocalizedString("Once you have allowed or denied a data type, %1$@ cannot ask again from within the app. To review or change access, open the Health app, tap your profile, then Apps → %1$@.", comment: "Health access change instructions (1: app name)"), appName)) + .font(.subheadline) + .foregroundStyle(.secondary) + } header: { + Text("Reading & Changing Access", comment: "Health access section header for reading and changing access") + } + } + .listStyle(.insetGrouped) + .navigationTitle(Text("Apple Health", comment: "Title of the Apple Health access screen")) + .onAppear(perform: refresh) + } + + private func refresh() { + glucoseStatus = glucoseSharingStatus() + insulinStatus = insulinSharingStatus() + } + + private func statusRow(label: String, status: HKAuthorizationStatus) -> some View { + HStack { + Text(label) + Spacer() + Image(systemName: status.iconSystemName) + .foregroundStyle(status.tintColor) + Text(status.localizedDescription) + .foregroundStyle(.secondary) + } + } +} + +private extension HKAuthorizationStatus { + var localizedDescription: String { + switch self { + case .sharingAuthorized: + return NSLocalizedString("Allowed", comment: "Health sharing status: authorized") + case .sharingDenied: + return NSLocalizedString("Denied", comment: "Health sharing status: denied") + case .notDetermined: + return NSLocalizedString("Not Set", comment: "Health sharing status: not determined") + @unknown default: + return NSLocalizedString("Unknown", comment: "Health sharing status: unknown") + } + } + + var iconSystemName: String { + switch self { + case .sharingAuthorized: return "checkmark.circle.fill" + case .sharingDenied: return "xmark.circle.fill" + case .notDetermined: return "circle" + @unknown default: return "questionmark.circle" + } + } + + var tintColor: Color { + switch self { + case .sharingAuthorized: return .green + case .sharingDenied: return .red + case .notDetermined: return .secondary + @unknown default: return .secondary + } + } +} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index cb20eed9f4..e3bdd8c218 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -6,6 +6,7 @@ // Copyright © 2020 LoopKit Authors. All rights reserved. // +import HealthKit import LoopKit import LoopKitUI import MockKit @@ -85,6 +86,7 @@ struct SettingsView: View { } presetsSection deviceSettingsSection + healthAccessSection if FeatureFlags.allowExperimentalFeatures { favoriteFoodsSection } @@ -333,6 +335,43 @@ extension SettingsView { } } + private func healthKitSharingStatus(for type: HKObjectType) -> HKAuthorizationStatus { + viewModel.deviceManager?.healthKitSharingStatus(for: type) ?? .notDetermined + } + + @ViewBuilder + private var healthAccessWarning: some View { + let denied = healthKitSharingStatus(for: HealthKitSampleStore.glucoseType) == .sharingDenied + || healthKitSharingStatus(for: HealthKitSampleStore.insulinQuantityType) == .sharingDenied + if denied { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.critical) + .accessibilityIdentifier("settingsViewHealthAccessWarning") + } + } + + private var healthAccessSection: some View { + Section { + NavigationLink(destination: HealthAccessView( + glucoseSharingStatus: { healthKitSharingStatus(for: HealthKitSampleStore.glucoseType) }, + insulinSharingStatus: { healthKitSharingStatus(for: HealthKitSampleStore.insulinQuantityType) } + )) { + LargeButton( + action: {}, + includeArrow: false, + imageView: Image(systemName: "heart.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30), + secondaryImageView: healthAccessWarning, + label: NSLocalizedString("Apple Health", comment: "Apple Health settings button text"), + descriptiveText: NSLocalizedString("Glucose and Insulin Data Access", comment: "Apple Health settings descriptive text") + ) + .accessibilityIdentifier("settingsViewHealthAccess") + } + } + } + private var therapySettingsView: some View { TherapySettingsView( mode: .settings, From 8a7dac5ff9fbf6c6137e81f74f5186a73f281f0c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 20 May 2026 19:07:09 -0500 Subject: [PATCH 417/421] Allow deleting any dose from dose details behind a feature flag Adds FeatureFlags.doseDeletionEnabled (off by default). When enabled, the dose details screen offers a Delete action for any bolus/basal dose, not just external (manually-entered) ones. Deleting a Loop-recorded dose that still has active insulin (within the ~6h window) shows an extra confirmation warning that Loop may make up for the reduced active insulin by dosing more. External-dose deletion is unchanged and still always available. --- Common/FeatureFlags.swift | 8 +++ .../InsulinDeliveryEventDetailsView.swift | 52 +++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index dbbc902310..886122d6fc 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -21,6 +21,7 @@ struct FeatureFlagConfiguration: Decodable { let apidraInsulinModelEnabled: Bool let includeServicesInSettingsEnabled: Bool let manualDoseEntryEnabled: Bool + let doseDeletionEnabled: Bool let insulinDeliveryReservoirViewEnabled: Bool let mockTherapySettingsEnabled: Bool let observeHealthKitCarbSamplesFromOtherApps: Bool @@ -108,6 +109,12 @@ struct FeatureFlagConfiguration: Decodable { self.manualDoseEntryEnabled = true #endif + #if DOSE_DELETION_ENABLED + self.doseDeletionEnabled = true + #else + self.doseDeletionEnabled = false + #endif + // Swift compiler config is inverse, since the default state is enabled. #if INSULIN_DELIVERY_RESERVOIR_VIEW_DISABLED self.insulinDeliveryReservoirViewEnabled = false @@ -250,6 +257,7 @@ extension FeatureFlagConfiguration : CustomDebugStringConvertible { "* siriEnabled: \(siriEnabled)", "* dosingStrategySelectionEnabled: \(dosingStrategySelectionEnabled)", "* manualDoseEntryEnabled: \(manualDoseEntryEnabled)", + "* doseDeletionEnabled: \(doseDeletionEnabled)", "* allowDebugFeatures: \(allowDebugFeatures)", "* simpleBolusCalculatorEnabled: \(simpleBolusCalculatorEnabled)", "* usePositiveMomentumAndRCForManualBoluses: \(usePositiveMomentumAndRCForManualBoluses)", diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift index c5fb9a0714..051b1c344d 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift @@ -86,6 +86,48 @@ struct InsulinDeliveryEventDetailsView: View { } } + private var isExternalDose: Bool { + doseEntry.manuallyEntered + } + + private var isDeletableDoseType: Bool { + switch pumpEventType { + case .bolus, .basal: + return true + case .insulin: + return false + } + } + + // External (manually-entered) doses can always be deleted; deleting any other + // (Loop-recorded) dose is gated behind the doseDeletion feature flag. + private var showDeleteButton: Bool { + onDelete != nil && isDeletableDoseType && (isExternalDose || FeatureFlags.doseDeletionEnabled) + } + + /// True while the dose still contributes active insulin (within the ~6h insulin activity window). + private var doseHasActiveInsulin: Bool { + doseEntry.endDate > Date().addingTimeInterval(-.hours(6)) + } + + private var deleteButtonTitle: String { + isExternalDose + ? NSLocalizedString("Delete External Insulin", comment: "Button to delete a manually-entered insulin dose") + : NSLocalizedString("Delete Dose", comment: "Button to delete a dose") + } + + private var deleteConfirmationTitle: String { + isExternalDose + ? NSLocalizedString("Delete this manually-entered insulin entry?", comment: "Confirmation title for deleting a manually-entered insulin dose") + : NSLocalizedString("Delete this dose?", comment: "Confirmation title for deleting a dose") + } + + /// Extra warning shown when deleting a Loop-recorded dose that still has active insulin. + private var deleteConfirmationMessage: String? { + guard !isExternalDose, doseHasActiveInsulin else { return nil } + return NSLocalizedString("This dose still has active insulin. Deleting it may cause Loop to make up for the reduced active insulin by dosing more.", comment: "Warning when deleting a Loop-recorded dose that still has active insulin") + } + var body: some View { List { Section { @@ -157,20 +199,20 @@ struct InsulinDeliveryEventDetailsView: View { onTapGesture(doseEntry) } - if doseEntry.manuallyEntered, let onDelete { + if showDeleteButton, let onDelete { Section { Button(role: .destructive) { showingDeleteConfirmation = true } label: { HStack { Spacer() - Text("Delete External Insulin") + Text(deleteButtonTitle) Spacer() } } } .confirmationDialog( - Text("Delete this manually-entered insulin entry?"), + Text(deleteConfirmationTitle), isPresented: $showingDeleteConfirmation, titleVisibility: .visible ) { @@ -181,6 +223,10 @@ struct InsulinDeliveryEventDetailsView: View { } } Button("Cancel", role: .cancel) { } + } message: { + if let deleteConfirmationMessage { + Text(deleteConfirmationMessage) + } } } } From 1997657e98d39f73490590cffe3242aafb4b0d51 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 20 May 2026 19:10:11 -0500 Subject: [PATCH 418/421] FeatureFlags: enable doseDeletion by default (disable via DOSE_DELETION_DISABLED) --- Common/FeatureFlags.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 886122d6fc..d44a9203c2 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -109,10 +109,11 @@ struct FeatureFlagConfiguration: Decodable { self.manualDoseEntryEnabled = true #endif - #if DOSE_DELETION_ENABLED - self.doseDeletionEnabled = true - #else + // Swift compiler config is inverse, since the default state is enabled. + #if DOSE_DELETION_DISABLED self.doseDeletionEnabled = false + #else + self.doseDeletionEnabled = true #endif // Swift compiler config is inverse, since the default state is enabled. From c876c5635afca620f2dcbde815fb050a0baf629d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 20 May 2026 20:17:40 -0500 Subject: [PATCH 419/421] Clear stale Last Bolus when the dose is gone updateDisplayState only ever advanced lastManualBolus to a newer bolus, so a deleted (or otherwise removed) bolus lingered in the status "Last Bolus" footer. Now reflect the most recent user-entered bolus actually present in the store, clearing/downgrading when it is gone, while still preserving a just-enacted bolus the store may not have persisted yet. --- Loop/Managers/LoopDataManager.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index fd574107da..b05e0c63f9 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -564,10 +564,20 @@ final class LoopDataManager: ObservableObject { $0.startDate >= lastManualBolusVisibilityWindowStartDate && $0.deliveryType == .bolus && $0.automatic == false }) - if let lastStoredManualBolus, - self.lastManualBolus == nil || lastStoredManualBolus.startDate >= self.lastManualBolus!.startDate - { - self.lastManualBolus = LastManualBolus(amount: lastStoredManualBolus.volume, startDate: lastStoredManualBolus.startDate) + // Reflect the most recent user-entered bolus still present in the store. This + // both updates to a newer bolus and clears/downgrades the value when the shown + // bolus is no longer there (e.g. the user deleted it) — the previous logic only + // ever moved forward, so a deleted bolus lingered in the "Last Bolus" footer. + // A just-enacted bolus that the store may not have persisted yet is preserved. + let recentlyEnactedCutoff = now.addingTimeInterval(-.minutes(1)) + if let lastStoredManualBolus { + let shownIsNewerThanStored = (self.lastManualBolus?.startDate).map { $0 > lastStoredManualBolus.startDate } ?? false + let shownWasJustEnacted = (self.lastManualBolus?.startDate).map { $0 >= recentlyEnactedCutoff } ?? false + if !(shownIsNewerThanStored && shownWasJustEnacted) { + self.lastManualBolus = LastManualBolus(amount: lastStoredManualBolus.volume, startDate: lastStoredManualBolus.startDate) + } + } else if let lastManualBolus = self.lastManualBolus, lastManualBolus.startDate < recentlyEnactedCutoff { + self.lastManualBolus = nil } } catch { let loopError = error as? LoopError ?? .unknownError(error) From a3c99349fb7f648c9a49ca266844e38beae9b1ef Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 20 May 2026 20:20:36 -0500 Subject: [PATCH 420/421] Don't allow deleting in-progress (mutable) doses --- .../InsulinDeliveryEventDetailsView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift index 051b1c344d..841e0f55b7 100644 --- a/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift +++ b/Loop/Views/Insulin Delivery Log/InsulinDeliveryEventDetailsView.swift @@ -100,9 +100,10 @@ struct InsulinDeliveryEventDetailsView: View { } // External (manually-entered) doses can always be deleted; deleting any other - // (Loop-recorded) dose is gated behind the doseDeletion feature flag. + // (Loop-recorded) dose is gated behind the doseDeletion feature flag. In-progress + // (mutable) doses can never be deleted — you can't delete a dose still being delivered. private var showDeleteButton: Bool { - onDelete != nil && isDeletableDoseType && (isExternalDose || FeatureFlags.doseDeletionEnabled) + onDelete != nil && isDeletableDoseType && !doseEntry.isMutable && (isExternalDose || FeatureFlags.doseDeletionEnabled) } /// True while the dose still contributes active insulin (within the ~6h insulin activity window). From 82193f41bf61a41a749c6bb8c8ef65d386bb2bae Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 20 May 2026 23:47:51 -0500 Subject: [PATCH 421/421] Restore localized Intents.strings file references (fix ITMS-90626) The Intents.intentdefinition variant group listed all 26 locales as children, but the sync's .strings cleanup dropped the PBXFileReference definitions for 22 of them (es, ru, en, it, fr, de, zh-Hans, nl, nb, pl, ja, pt-BR, vi, da, sv, fi, ro, tr, he, ar, sk, cs), leaving dangling children. Those locales' Intents.strings were never compiled, so Apple flagged missing localized descriptions for the NewCarbEntry and EnableOverridePreset custom intents across all of them. Re-add the file references (reusing the IDs the variant group already points at); the strings now bundle for every declared locale. --- Loop.xcodeproj/project.pbxproj | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 8e3ef627cb..5d12d694f3 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -1215,6 +1215,28 @@ B66D1F422E6A5D6600471149 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/Main.xcstrings; sourceTree = ""; }; B6F22EF72E95A03800CCA05F /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Intents.strings; sourceTree = ""; }; B6F22EF92E95A03C00CCA05F /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Intents.strings; sourceTree = ""; }; + 43785E9F2122774A0057DED1 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Intents.strings; sourceTree = ""; }; + 43785EA12122774B0057DED1 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Intents.strings; sourceTree = ""; }; + 43C98058212A799E003B5D17 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Intents.strings; sourceTree = ""; }; + C12CB9AC23106A3C00F84978 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Intents.strings; sourceTree = ""; }; + C12CB9AE23106A5C00F84978 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Intents.strings; sourceTree = ""; }; + C12CB9B023106A5F00F84978 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Intents.strings; sourceTree = ""; }; + C12CB9B223106A6000F84978 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Intents.strings"; sourceTree = ""; }; + C12CB9B423106A6100F84978 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Intents.strings; sourceTree = ""; }; + C12CB9B623106A6200F84978 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Intents.strings; sourceTree = ""; }; + C12CB9B823106A6300F84978 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF132335EC4B005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF292335EC58005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Intents.strings"; sourceTree = ""; }; + 7D9BEF3F2335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF552335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF6B2335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF812335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Intents.strings; sourceTree = ""; }; + 7D9BF13A23370E8B005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Intents.strings; sourceTree = ""; }; + F5D9C01727DABBE0002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Intents.strings; sourceTree = ""; }; + F5E0BDD327E1D71C0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Intents.strings; sourceTree = ""; }; + C1C3127F297E4C0400296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intents.strings; sourceTree = ""; }; + C1C247882995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Intents.strings; sourceTree = ""; }; + C1C5357529C6346A00E32DF9 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Intents.strings; sourceTree = ""; }; C1004DEF2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; C1004DFD2981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; C1004E052981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; };

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/421] [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/421] [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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