diff --git a/Common/Models/PumpManager.swift b/Common/Models/PumpManager.swift index 3c2486ef68..5ec574366c 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.managerIdentifier : MockPumpManager.self + MockPumpManager.pluginIdentifier : MockPumpManager.self ] var availableStaticPumpManagers: [PumpManagerDescriptor] { if FeatureFlags.allowSimulators { return [ - PumpManagerDescriptor(identifier: MockPumpManager.managerIdentifier, localizedTitle: MockPumpManager.localizedTitle) + PumpManagerDescriptor(identifier: MockPumpManager.pluginIdentifier, localizedTitle: MockPumpManager.localizedTitle) ] } else { return [] @@ -31,7 +31,7 @@ extension PumpManager { var rawValue: RawValue { return [ - "managerIdentifier": self.managerIdentifier, + "managerIdentifier": self.pluginIdentifier, "state": self.rawState ] } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index dacc5be194..dcc947db00 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -374,6 +374,7 @@ 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 */; }; + B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B470F5832AB22B5100049695 /* StatefulPluggable.swift */; }; B48B0BAC24900093009A48DE /* PumpStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */; }; B490A03F24D0550F00F509FA /* GlucoseRangeCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A03E24D0550F00F509FA /* GlucoseRangeCategory.swift */; }; B490A04124D0559D00F509FA /* DeviceLifecycleProgressState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A04024D0559D00F509FA /* DeviceLifecycleProgressState.swift */; }; @@ -387,6 +388,7 @@ B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */; }; B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */; }; B4D620D424D9EDB900043B3C /* GuidanceColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D620D324D9EDB900043B3C /* GuidanceColors.swift */; }; + B4D904412AA8989100CBD826 /* StatefulPluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */; }; B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */; }; B4E96D4B248A6B6E002DABAD /* DeviceStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */; }; B4E96D4F248A6E20002DABAD /* CGMStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */; }; @@ -1298,6 +1300,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 = ""; }; + 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 = ""; }; B490A03E24D0550F00F509FA /* GlucoseRangeCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseRangeCategory.swift; sourceTree = ""; }; @@ -1308,6 +1311,7 @@ B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCompletionFreshnessTests.swift; sourceTree = ""; }; B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = ""; }; B4D620D324D9EDB900043B3C /* GuidanceColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidanceColors.swift; sourceTree = ""; }; + B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPluginManager.swift; sourceTree = ""; }; B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticDosingStatus.swift; sourceTree = ""; }; B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusHUDView.swift; sourceTree = ""; }; B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDView.swift; sourceTree = ""; }; @@ -2294,10 +2298,12 @@ 43C094491CACCC73001F6403 /* NotificationManager.swift */, A97F250725E056D500F0EE19 /* OnboardingManager.swift */, 432E73CA1D24B3D6009AD15D /* RemoteDataServicesManager.swift */, + B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */, A9C62D852331703000535612 /* Service.swift */, A9C62D872331703000535612 /* ServicesManager.swift */, C1F7822527CC056900C0919A /* SettingsManager.swift */, E9BB27AA23B85C3500FB4987 /* SleepStore.swift */, + B470F5832AB22B5100049695 /* StatefulPluggable.swift */, 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */, 1D63DEA426E950D400F46FA5 /* SupportManager.swift */, 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */, @@ -3654,10 +3660,12 @@ B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */, 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */, E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */, + B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */, E98A55ED24EDD6380008715D /* LatestStoredSettingsProvider.swift in Sources */, C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */, A9B996F227238705002DC09C /* DosingDecisionStore.swift in Sources */, 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */, + B4D904412AA8989100CBD826 /* StatefulPluginManager.swift in Sources */, E9B355292935919E0076AB04 /* MissedMealSettings.swift in Sources */, 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, diff --git a/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json b/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json new file mode 100644 index 0000000000..579e60790c --- /dev/null +++ b/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group 3403.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf b/Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf new file mode 100644 index 0000000000..14057221ed Binary files /dev/null and b/Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf differ diff --git a/Loop/DefaultAssets.xcassets/phone.imageset/Contents.json b/Loop/DefaultAssets.xcassets/phone.imageset/Contents.json new file mode 100644 index 0000000000..507753a905 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/phone.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group 3405.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/phone.imageset/Group 3405.pdf b/Loop/DefaultAssets.xcassets/phone.imageset/Group 3405.pdf new file mode 100644 index 0000000000..fc12ec3959 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/phone.imageset/Group 3405.pdf differ diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 650d74a597..808a34c81a 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -28,7 +28,7 @@ final class AnalyticsServicesManager { } func removeService(_ analyticsService: AnalyticsService) { - analyticsServices.removeAll { $0.serviceIdentifier == analyticsService.serviceIdentifier } + analyticsServices.removeAll { $0.pluginIdentifier == analyticsService.pluginIdentifier } } private func logEvent(_ name: String, withProperties properties: [AnyHashable: Any]? = nil, outOfSession: Bool = false) { diff --git a/Loop/Managers/CGMManager.swift b/Loop/Managers/CGMManager.swift index 041e632288..fe39e3926c 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.managerIdentifier: MockCGMManager.self + MockCGMManager.pluginIdentifier: MockCGMManager.self ] var availableStaticCGMManagers: [CGMManagerDescriptor] { if FeatureFlags.allowSimulators { return [ - CGMManagerDescriptor(identifier: MockCGMManager.managerIdentifier, localizedTitle: MockCGMManager.localizedTitle) + CGMManagerDescriptor(identifier: MockCGMManager.pluginIdentifier, localizedTitle: MockCGMManager.localizedTitle) ] } else { return [] @@ -40,7 +40,7 @@ extension CGMManager { var rawValue: [String: Any] { return [ - "managerIdentifier": managerIdentifier, + "managerIdentifier": pluginIdentifier, "state": self.rawState ] } diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 95ef432094..c44359e94c 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -97,9 +97,9 @@ final class DeviceDataManager { dispatchPrecondition(condition: .onQueue(.main)) setupCGM() - if cgmManager?.managerIdentifier != oldValue?.managerIdentifier { + if cgmManager?.pluginIdentifier != oldValue?.pluginIdentifier { if let cgmManager = cgmManager { - analyticsServicesManager.cgmWasAdded(identifier: cgmManager.managerIdentifier) + analyticsServicesManager.cgmWasAdded(identifier: cgmManager.pluginIdentifier) } else { analyticsServicesManager.cgmWasRemoved() } @@ -125,9 +125,9 @@ final class DeviceDataManager { cgmManager = nil } - if pumpManager?.managerIdentifier != oldValue?.managerIdentifier { + if pumpManager?.pluginIdentifier != oldValue?.pluginIdentifier { if let pumpManager = pumpManager { - analyticsServicesManager.pumpWasAdded(identifier: pumpManager.managerIdentifier) + analyticsServicesManager.pumpWasAdded(identifier: pumpManager.pluginIdentifier) } else { analyticsServicesManager.pumpWasRemoved() } @@ -207,6 +207,8 @@ final class DeviceDataManager { sleepDataAuthorizationRequired } + private(set) var statefulPluginManager: StatefulPluginManager! + // MARK: Services private(set) var servicesManager: ServicesManager! @@ -427,7 +429,9 @@ final class DeviceDataManager { 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, @@ -588,7 +592,7 @@ final class DeviceDataManager { var availableCGMManagers: [CGMManagerDescriptor] { var availableCGMManagers = pluginManager.availableCGMManagers + availableStaticCGMManagers if let pumpManagerAsCGMManager = pumpManager as? CGMManager { - availableCGMManagers.append(CGMManagerDescriptor(identifier: pumpManagerAsCGMManager.managerIdentifier, localizedTitle: pumpManagerAsCGMManager.localizedTitle)) + availableCGMManagers.append(CGMManagerDescriptor(identifier: pumpManagerAsCGMManager.pluginIdentifier, localizedTitle: pumpManagerAsCGMManager.localizedTitle)) } availableCGMManagers = availableCGMManagers.filter({ cgmManager in @@ -641,7 +645,7 @@ final class DeviceDataManager { } public func setupCGMManagerFromPumpManager(withIdentifier identifier: String) -> CGMManager? { - guard identifier == pumpManager?.managerIdentifier, let cgmManager = pumpManager as? CGMManager else { + guard identifier == pumpManager?.pluginIdentifier, let cgmManager = pumpManager as? CGMManager else { return nil } @@ -704,19 +708,20 @@ private extension DeviceDataManager { cgmManager?.cgmManagerDelegate = self cgmManager?.delegateQueue = queue + reportPluginInitializationComplete() glucoseStore.managedDataInterval = cgmManager?.managedDataInterval glucoseStore.healthKitStorageDelay = cgmManager.map{ type(of: $0).healthKitStorageDelay } ?? 0 updatePumpManagerBLEHeartbeatPreference() if let cgmManager = cgmManager { - alertManager?.addAlertResponder(managerIdentifier: cgmManager.managerIdentifier, + alertManager?.addAlertResponder(managerIdentifier: cgmManager.pluginIdentifier, alertResponder: cgmManager) - alertManager?.addAlertSoundVendor(managerIdentifier: cgmManager.managerIdentifier, + alertManager?.addAlertSoundVendor(managerIdentifier: cgmManager.pluginIdentifier, soundVendor: cgmManager) cgmHasValidSensorSession = cgmManager.cgmManagerStatus.hasValidSensorSession - analyticsServicesManager.identifyCGMType(cgmManager.managerIdentifier) + analyticsServicesManager.identifyCGMType(cgmManager.pluginIdentifier) } if let cgmManagerUI = cgmManager as? CGMManagerUI { @@ -729,6 +734,7 @@ private extension DeviceDataManager { pumpManager?.pumpManagerDelegate = self pumpManager?.delegateQueue = queue + reportPluginInitializationComplete() doseStore.device = pumpManager?.status.device pumpManagerHUDProvider = pumpManager?.hudProvider(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowedInsulinTypes: allowedInsulinTypes) @@ -738,14 +744,14 @@ private extension DeviceDataManager { doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents } if let pumpManager = pumpManager { - alertManager?.addAlertResponder(managerIdentifier: pumpManager.managerIdentifier, + alertManager?.addAlertResponder(managerIdentifier: pumpManager.pluginIdentifier, alertResponder: pumpManager) - alertManager?.addAlertSoundVendor(managerIdentifier: pumpManager.managerIdentifier, + alertManager?.addAlertSoundVendor(managerIdentifier: pumpManager.pluginIdentifier, soundVendor: pumpManager) deliveryUncertaintyAlertManager = DeliveryUncertaintyAlertManager(pumpManager: pumpManager, alertPresenter: alertPresenter) - analyticsServicesManager.identifyPumpType(pumpManager.managerIdentifier) + analyticsServicesManager.identifyPumpType(pumpManager.pluginIdentifier) } } @@ -756,6 +762,58 @@ private extension DeviceDataManager { } } +// MARK: - Plugins +extension DeviceDataManager { + func reportPluginInitializationComplete() { + let allActivePlugins = self.allActivePlugins + + for plugin in servicesManager.activeServices { + plugin.initializationComplete(for: allActivePlugins) + } + + for plugin in statefulPluginManager.activeStatefulPlugins { + plugin.initializationComplete(for: allActivePlugins) + } + + for plugin in availableSupports { + plugin.initializationComplete(for: allActivePlugins) + } + + cgmManager?.initializationComplete(for: allActivePlugins) + pumpManager?.initializationComplete(for: allActivePlugins) + } + + var allActivePlugins: [Pluggable] { + var allActivePlugins: [Pluggable] = servicesManager.activeServices + + for plugin in statefulPluginManager.activeStatefulPlugins { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == plugin.pluginIdentifier }) { + allActivePlugins.append(plugin) + } + } + + for plugin in availableSupports { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == plugin.pluginIdentifier }) { + allActivePlugins.append(plugin) + } + } + + if let cgmManager = cgmManager { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == cgmManager.pluginIdentifier }) { + allActivePlugins.append(cgmManager) + } + } + + if let pumpManager = pumpManager { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == pumpManager.pluginIdentifier }) { + allActivePlugins.append(pumpManager) + } + } + + return allActivePlugins + } +} + // MARK: - Client API extension DeviceDataManager { func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { @@ -867,7 +925,7 @@ extension DeviceDataManager { extension DeviceDataManager: DeviceManagerDelegate { func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) { - deviceLog.log(managerIdentifier: manager.managerIdentifier, deviceIdentifier: deviceIdentifier, type: type, message: message, completion: completion) + deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: deviceIdentifier, type: type, message: message, completion: completion) } var allowDebugFeatures: Bool { @@ -915,7 +973,7 @@ extension DeviceDataManager: CGMManagerDelegate { func cgmManagerWantsDeletion(_ manager: CGMManager) { dispatchPrecondition(condition: .onQueue(queue)) - log.default("CGM manager with identifier '%{public}@' wants deletion", manager.managerIdentifier) + log.default("CGM manager with identifier '%{public}@' wants deletion", manager.pluginIdentifier) DispatchQueue.main.async { if let cgmManagerUI = self.cgmManager as? CGMManagerUI { @@ -978,13 +1036,13 @@ extension DeviceDataManager: CGMManagerDelegate { extension DeviceDataManager: CGMManagerOnboardingDelegate { func cgmManagerOnboarding(didCreateCGMManager cgmManager: CGMManagerUI) { - log.default("CGM manager with identifier '%{public}@' created", cgmManager.managerIdentifier) + 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.managerIdentifier) + log.default("CGM manager with identifier '%{public}@' onboarded", cgmManager.pluginIdentifier) DispatchQueue.main.async { self.refreshDeviceData() @@ -1117,7 +1175,7 @@ extension DeviceDataManager: PumpManagerDelegate { func pumpManagerWillDeactivate(_ pumpManager: PumpManager) { dispatchPrecondition(condition: .onQueue(queue)) - log.default("Pump manager with identifier '%{public}@' will deactivate", pumpManager.managerIdentifier) + log.default("Pump manager with identifier '%{public}@' will deactivate", pumpManager.pluginIdentifier) DispatchQueue.main.async { self.pumpManager = nil @@ -1186,13 +1244,13 @@ extension DeviceDataManager: PumpManagerDelegate { extension DeviceDataManager: PumpManagerOnboardingDelegate { func pumpManagerOnboarding(didCreatePumpManager pumpManager: PumpManagerUI) { - log.default("Pump manager with identifier '%{public}@' created", pumpManager.managerIdentifier) + log.default("Pump manager with identifier '%{public}@' created", pumpManager.pluginIdentifier) self.pumpManager = pumpManager } func pumpManagerOnboarding(didOnboardPumpManager pumpManager: PumpManagerUI) { precondition(pumpManager.isOnboarded) - log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.managerIdentifier) + log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.pluginIdentifier) DispatchQueue.main.async { self.refreshDeviceData() diff --git a/Loop/Managers/LoggingServicesManager.swift b/Loop/Managers/LoggingServicesManager.swift index 25b63ac1f9..287371aa01 100644 --- a/Loop/Managers/LoggingServicesManager.swift +++ b/Loop/Managers/LoggingServicesManager.swift @@ -24,7 +24,7 @@ final class LoggingServicesManager: Logging { } func removeService(_ loggingService: LoggingService) { - loggingServices.removeAll { $0.serviceIdentifier == loggingService.serviceIdentifier } + loggingServices.removeAll { $0.pluginIdentifier == loggingService.pluginIdentifier } } func log (_ message: StaticString, subsystem: String, category: String, type: OSLogType, _ args: [CVarArg]) { diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 95a7ed6d2d..b8e23d0bba 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -226,6 +226,7 @@ class LoopAppManager: NSObject { onboardingManager = OnboardingManager(pluginManager: pluginManager, bluetoothProvider: bluetoothStateManager, deviceDataManager: deviceDataManager, + statefulPluginManager: deviceDataManager.statefulPluginManager, servicesManager: deviceDataManager.servicesManager, loopDataManager: deviceDataManager.loopManager, supportManager: supportManager, @@ -238,11 +239,8 @@ class LoopAppManager: NSObject { if let analyticsService = support as? AnalyticsService { analyticsServicesManager.addService(analyticsService) } + support.initializationComplete(for: deviceDataManager.allActivePlugins) } - for support in supportManager.availableSupports { - support.initializationComplete(for: deviceDataManager.servicesManager.activeServices) - } - deviceDataManager.onboardingManager = onboardingManager @@ -254,7 +252,7 @@ class LoopAppManager: NSObject { } analyticsServicesManager.identify("Dosing Strategy", value: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue) - let serviceNames = deviceDataManager.servicesManager.activeServices.map { $0.serviceIdentifier } + let serviceNames = deviceDataManager.servicesManager.activeServices.map { $0.pluginIdentifier } analyticsServicesManager.identify("Services", array: serviceNames) if FeatureFlags.scenariosEnabled { diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index b39e0d7d35..b9f6c8c232 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -15,6 +15,7 @@ class OnboardingManager { private let pluginManager: PluginManager private let bluetoothProvider: BluetoothProvider private let deviceDataManager: DeviceDataManager + private let statefulPluginManager: StatefulPluginManager private let servicesManager: ServicesManager private let loopDataManager: LoopDataManager private let supportManager: SupportManager @@ -39,10 +40,20 @@ class OnboardingManager { private var onboardingCompletion: (() -> Void)? - init(pluginManager: PluginManager, bluetoothProvider: BluetoothProvider, deviceDataManager: DeviceDataManager, servicesManager: ServicesManager, loopDataManager: LoopDataManager, supportManager: SupportManager, windowProvider: WindowProvider?, userDefaults: UserDefaults = .standard) { + init(pluginManager: PluginManager, + bluetoothProvider: BluetoothProvider, + deviceDataManager: DeviceDataManager, + statefulPluginManager: StatefulPluginManager, + servicesManager: ServicesManager, + loopDataManager: LoopDataManager, + supportManager: SupportManager, + windowProvider: WindowProvider?, + userDefaults: UserDefaults = .standard) + { self.pluginManager = pluginManager self.bluetoothProvider = bluetoothProvider self.deviceDataManager = deviceDataManager + self.statefulPluginManager = statefulPluginManager self.servicesManager = servicesManager self.loopDataManager = loopDataManager self.supportManager = supportManager @@ -122,7 +133,7 @@ class OnboardingManager { let onboarding = onboardingType.createOnboarding() guard !onboarding.isOnboarded else { - completedOnboardingIdentifiers.append(onboarding.onboardingIdentifier) + completedOnboardingIdentifiers.append(onboarding.pluginIdentifier) continue } @@ -155,7 +166,7 @@ class OnboardingManager { dispatchPrecondition(condition: .onQueue(.main)) if let activeOnboarding = self.activeOnboarding, !isSuspended { - completedOnboardingIdentifiers.append(activeOnboarding.onboardingIdentifier) + completedOnboardingIdentifiers.append(activeOnboarding.pluginIdentifier) self.activeOnboarding = nil } continueOnboarding() @@ -238,25 +249,25 @@ class OnboardingManager { extension OnboardingManager: OnboardingDelegate { func onboardingDidUpdateState(_ onboarding: OnboardingUI) { - guard onboarding.onboardingIdentifier == activeOnboarding?.onboardingIdentifier else { return } + guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } userDefaults.onboardingManagerActiveOnboardingRawValue = onboarding.rawValue } func onboarding(_ onboarding: OnboardingUI, hasNewTherapySettings therapySettings: TherapySettings) { - guard onboarding.onboardingIdentifier == activeOnboarding?.onboardingIdentifier else { return } + guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } loopDataManager.therapySettings = therapySettings } func onboarding(_ onboarding: OnboardingUI, hasNewDosingEnabled dosingEnabled: Bool) { - guard onboarding.onboardingIdentifier == activeOnboarding?.onboardingIdentifier else { return } + guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } loopDataManager.mutateSettings { settings in settings.dosingEnabled = dosingEnabled } } func onboardingDidSuspend(_ onboarding: OnboardingUI) { - log.debug("OnboardingUI %@ did suspend", onboarding.onboardingIdentifier) - guard onboarding.onboardingIdentifier == activeOnboarding?.onboardingIdentifier else { return } + log.debug("OnboardingUI %@ did suspend", onboarding.pluginIdentifier) + guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } self.isSuspended = true } } @@ -270,7 +281,7 @@ extension OnboardingManager: CompletionDelegate { return } - self.log.debug("completionNotifyingDidComplete during activeOnboarding", activeOnboarding.onboardingIdentifier) + self.log.debug("completionNotifyingDidComplete during activeOnboarding", activeOnboarding.pluginIdentifier) // The `completionNotifyingDidComplete` callback can be called by an onboarding plugin to signal that the user is done with // the onboarding UI, like when pausing, so the onboarding UI can be dismissed. This doesn't necessarily mean that the @@ -340,7 +351,7 @@ extension OnboardingManager: CGMManagerProvider { guard let cgmManager = deviceDataManager.cgmManager else { return deviceDataManager.setupCGMManager(withIdentifier: identifier, prefersToSkipUserInteraction: prefersToSkipUserInteraction) } - guard cgmManager.managerIdentifier == identifier else { + guard cgmManager.pluginIdentifier == identifier else { return .failure(OnboardingError.invalidState) } @@ -384,7 +395,7 @@ extension OnboardingManager: PumpManagerProvider { guard let pumpManager = deviceDataManager.pumpManager else { return deviceDataManager.setupPumpManager(withIdentifier: identifier, initialSettings: settings, prefersToSkipUserInteraction: prefersToSkipUserInteraction) } - guard pumpManager.managerIdentifier == identifier else { + guard pumpManager.pluginIdentifier == identifier else { return .failure(OnboardingError.invalidState) } @@ -396,15 +407,22 @@ extension OnboardingManager: PumpManagerProvider { } } +// MARK: - StatefulPluggableProvider + +extension OnboardingManager: StatefulPluggableProvider { + func statefulPlugin(withIdentifier identifier: String) -> StatefulPluggable? { + statefulPluginManager.statefulPlugin(withIdentifier: identifier) } +} + // MARK: - ServiceProvider -extension OnboardingManager: ServiceProvider { +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.serviceIdentifier == identifier }) else { + guard let service = activeServices.first(where: { $0.pluginIdentifier == identifier }) else { return servicesManager.setupService(withIdentifier: identifier) } @@ -421,6 +439,7 @@ extension OnboardingManager: ServiceProvider { } // MARK: - TherapySettingsProvider + extension OnboardingManager: TherapySettingsProvider { var onboardingTherapySettings: TherapySettings { return loopDataManager.therapySettings @@ -446,7 +465,7 @@ fileprivate extension OnboardingUI { var rawValue: RawValue { return [ - "onboardingIdentifier": onboardingIdentifier, + "onboardingIdentifier": pluginIdentifier, "state": rawState ] } diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index 3b86cf7f1e..c256013ba0 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -65,7 +65,7 @@ final class RemoteDataServicesManager { func removeService(_ remoteDataService: RemoteDataService) { lock.withLock { - unlockedRemoteDataServices.removeAll { $0.serviceIdentifier == remoteDataService.serviceIdentifier } + unlockedRemoteDataServices.removeAll { $0.pluginIdentifier == remoteDataService.pluginIdentifier } } clearQueryAnchors(for: remoteDataService) } @@ -81,7 +81,7 @@ final class RemoteDataServicesManager { private func dispatchQueue(for remoteDataService: RemoteDataService, withRemoteDataType remoteDataType: RemoteDataType) -> DispatchQueue { - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: remoteDataType) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: remoteDataType) return dispatchQueue(key) } @@ -228,7 +228,7 @@ extension RemoteDataServicesManager { private func uploadAlertData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .alert) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .alert) dispatchQueue(key).async { let semaphore = DispatchSemaphore(value: 0) @@ -263,7 +263,7 @@ extension RemoteDataServicesManager { private func uploadCarbData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .carb) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .carb) dispatchQueue(key).async { let semaphore = DispatchSemaphore(value: 0) @@ -305,7 +305,7 @@ extension RemoteDataServicesManager { private func uploadDoseData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .dose) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .dose) dispatchQueue(key).async { let semaphore = DispatchSemaphore(value: 0) @@ -347,7 +347,7 @@ extension RemoteDataServicesManager { private func uploadDosingDecisionData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .dosingDecision) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .dosingDecision) dispatchQueue(key).async { let semaphore = DispatchSemaphore(value: 0) @@ -394,7 +394,7 @@ extension RemoteDataServicesManager { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .glucose) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .glucose) dispatchQueue(key).async { let semaphore = DispatchSemaphore(value: 0) @@ -436,7 +436,7 @@ extension RemoteDataServicesManager { private func uploadPumpEventData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .pumpEvent) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .pumpEvent) dispatchQueue(for: remoteDataService, withRemoteDataType: .pumpEvent).async { let semaphore = DispatchSemaphore(value: 0) @@ -478,7 +478,7 @@ extension RemoteDataServicesManager { private func uploadSettingsData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .settings) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .settings) dispatchQueue(for: remoteDataService, withRemoteDataType: .settings).async { let semaphore = DispatchSemaphore(value: 0) @@ -520,7 +520,7 @@ extension RemoteDataServicesManager { private func uploadTemporaryOverrideData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .overrides) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .overrides) dispatchQueue(for: remoteDataService, withRemoteDataType: .overrides).async { let semaphore = DispatchSemaphore(value: 0) @@ -600,7 +600,7 @@ extension RemoteDataServicesManager { func serviceForPushNotification(_ notification: [String: AnyObject]) throws -> RemoteDataService { let defaultServiceIdentifier = "NightscoutService" let serviceIdentifier = notification["serviceIdentifier"] as? String ?? defaultServiceIdentifier - guard let service = remoteDataServices.first(where: {$0.serviceIdentifier == serviceIdentifier}) else { + guard let service = remoteDataServices.first(where: {$0.pluginIdentifier == serviceIdentifier}) else { throw RemoteDataServicesManagerCommandError.unsupportedServiceIdentifier(serviceIdentifier) } return service @@ -626,7 +626,7 @@ protocol RemoteDataServicesManagerDelegate: AnyObject { fileprivate extension UserDefaults { private func queryAnchorKey(for remoteDataService: RemoteDataService, withRemoteDataType remoteDataType: RemoteDataType) -> String { - return "com.loopkit.Loop.RemoteDataServicesManager.\(remoteDataService.serviceIdentifier).\(remoteDataType.rawValue)QueryAnchor" + return "com.loopkit.Loop.RemoteDataServicesManager.\(remoteDataService.pluginIdentifier).\(remoteDataType.rawValue)QueryAnchor" } func getQueryAnchor(for remoteDataService: RemoteDataService, withRemoteDataType remoteDataType: RemoteDataType) -> T? where T: RawRepresentable, T.RawValue == [String: Any] { diff --git a/Loop/Managers/Service.swift b/Loop/Managers/Service.swift index 3966109931..1541208712 100644 --- a/Loop/Managers/Service.swift +++ b/Loop/Managers/Service.swift @@ -13,15 +13,15 @@ import MockKit let staticServices: [Service.Type] = [MockService.self] let staticServicesByIdentifier: [String: Service.Type] = staticServices.reduce(into: [:]) { (map, Type) in - map[Type.serviceIdentifier] = Type + map[Type.pluginIdentifier] = Type } let availableStaticServices = staticServices.map { (Type) -> ServiceDescriptor in - return ServiceDescriptor(identifier: Type.serviceIdentifier, localizedTitle: Type.localizedTitle) + return ServiceDescriptor(identifier: Type.pluginIdentifier, localizedTitle: Type.localizedTitle) } func ServiceFromRawValue(_ rawValue: [String: Any]) -> Service? { - guard let serviceIdentifier = rawValue["serviceIdentifier"] as? String, + guard let serviceIdentifier = rawValue["statefulPluginIdentifier"] as? String, let rawState = rawValue["state"] as? Service.RawStateValue, let ServiceType = staticServicesByIdentifier[serviceIdentifier] else { @@ -30,16 +30,3 @@ func ServiceFromRawValue(_ rawValue: [String: Any]) -> Service? { return ServiceType.init(rawState: rawState) } - -extension Service { - - typealias RawValue = [String: Any] - - var rawValue: RawValue { - return [ - "serviceIdentifier": serviceIdentifier, - "state": rawState - ] - } - -} diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 2593560706..7e62e95333 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["serviceIdentifier"] as? String else { + guard let identifier = rawValue["statefulPluginIdentifier"] as? String else { return nil } @@ -124,6 +124,7 @@ class ServicesManager { public func addActiveService(_ service: Service) { servicesLock.withLock { service.serviceDelegate = self + service.stateDelegate = self services.append(service) @@ -153,9 +154,10 @@ class ServicesManager { analyticsServicesManager.removeService(analyticsService) } - services.removeAll { $0.serviceIdentifier == service.serviceIdentifier } + services.removeAll { $0.pluginIdentifier == service.pluginIdentifier } service.serviceDelegate = nil + service.stateDelegate = nil saveState() } @@ -171,6 +173,7 @@ class ServicesManager { rawServices.forEach { rawValue in if let service = serviceFromRawValue(rawValue) { service.serviceDelegate = self + service.stateDelegate = self services.append(service) @@ -238,6 +241,19 @@ public protocol ServicesManagerDelegate: AnyObject { func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws } +// MARK: - StatefulPluggableDelegate +extension ServicesManager: StatefulPluggableDelegate { + func pluginDidUpdateState(_ plugin: StatefulPluggable) { + saveState() + } + + func pluginWantsDeletion(_ plugin: StatefulPluggable) { + guard let service = plugin as? Service else { return } + log.default("Service with identifier '%{public}@' deleted", service.pluginIdentifier) + removeActiveService(service) + } +} + // MARK: - ServiceDelegate extension ServicesManager: ServiceDelegate { @@ -256,15 +272,6 @@ extension ServicesManager: ServiceDelegate { return semanticVersion } - - func serviceDidUpdateState(_ service: Service) { - saveState() - } - - func serviceWantsDeletion(_ service: Service) { - log.default("Service with identifier '%{public}@' deleted", service.serviceIdentifier) - removeActiveService(service) - } func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws { @@ -380,13 +387,13 @@ extension ServicesManager: AlertIssuer { extension ServicesManager: ServiceOnboardingDelegate { func serviceOnboarding(didCreateService service: Service) { - log.default("Service with identifier '%{public}@' created", service.serviceIdentifier) + log.default("Service with identifier '%{public}@' created", service.pluginIdentifier) addActiveService(service) } func serviceOnboarding(didOnboardService service: Service) { precondition(service.isOnboarded) - log.default("Service with identifier '%{public}@' onboarded", service.serviceIdentifier) + log.default("Service with identifier '%{public}@' onboarded", service.pluginIdentifier) } } diff --git a/Loop/Managers/StatefulPluggable.swift b/Loop/Managers/StatefulPluggable.swift new file mode 100644 index 0000000000..ab1be4754d --- /dev/null +++ b/Loop/Managers/StatefulPluggable.swift @@ -0,0 +1,20 @@ +// +// StatefulPluggable.swift +// Loop +// +// Created by Nathaniel Hamming on 2023-09-13. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import LoopKit + +extension StatefulPluggable { + typealias RawValue = [String: Any] + + var rawValue: RawValue { + return [ + "statefulPluginIdentifier": pluginIdentifier, + "state": rawState + ] + } +} diff --git a/Loop/Managers/StatefulPluginManager.swift b/Loop/Managers/StatefulPluginManager.swift new file mode 100644 index 0000000000..22fc035b0c --- /dev/null +++ b/Loop/Managers/StatefulPluginManager.swift @@ -0,0 +1,125 @@ +// +// StatefulPluginManager.swift +// Loop +// +// Created by Nathaniel Hamming on 2023-09-06. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import LoopCore +import Combine + +class StatefulPluginManager: StatefulPluggableProvider { + + private let pluginManager: PluginManager + + private let servicesManager: ServicesManager + + private var statefulPlugins = [StatefulPluggable]() + + private let statefulPluginLock = UnfairLock() + + @PersistedProperty(key: "StatefulPlugins") + var rawStatefulPlugins: [StatefulPluggable.RawStateValue]? + + init(pluginManager: PluginManager, + servicesManager: ServicesManager) + { + self.pluginManager = pluginManager + self.servicesManager = servicesManager + restoreState() + } + + public var availableStatefulPluginIdentifiers: [String] { + return pluginManager.availableStatefulPluginIdentifiers + } + + func statefulPlugin(withIdentifier identifier: String) -> StatefulPluggable? { + for plugin in statefulPlugins { + if plugin.pluginIdentifier == identifier { + return plugin + } + } + + return setupStatefulPlugin(withIdentifier: identifier) + } + + func statefulPluginType(withIdentifier identifier: String) -> StatefulPluggable.Type? { + pluginManager.getStatefulPluginTypeByIdentifier(identifier) + } + + func setupStatefulPlugin(withIdentifier identifier: String) -> StatefulPluggable? { + guard let statefulPluinType = pluginManager.getStatefulPluginTypeByIdentifier(identifier) else { return nil } + + // init without raw value + let statefulPlugin = statefulPluinType.init(rawState: [:]) + statefulPlugin?.initializationComplete(for: servicesManager.activeServices) + addActiveStatefulPlugin(statefulPlugin) + + return statefulPlugin + } + + private func statefulPluginTypeFromRawValue(_ rawValue: StatefulPluggable.RawStateValue) -> StatefulPluggable.Type? { + guard let identifier = rawValue["statefulPluginIdentifier"] as? String else { + return nil + } + + return statefulPluginType(withIdentifier: identifier) + } + + private func statefulPluginFromRawValue(_ rawValue: StatefulPluggable.RawStateValue) -> StatefulPluggable? { + guard let statefulPluginType = statefulPluginTypeFromRawValue(rawValue), + let rawState = rawValue["state"] as? StatefulPluggable.RawStateValue + else { + return nil + } + + return statefulPluginType.init(rawState: rawState) + } + + public var activeStatefulPlugins: [StatefulPluggable] { + return statefulPluginLock.withLock { statefulPlugins } + } + + public func addActiveStatefulPlugin(_ statefulPlugin: StatefulPluggable?) { + guard let statefulPlugin = statefulPlugin else { return } + statefulPluginLock.withLock { + statefulPlugin.stateDelegate = self + statefulPlugins.append(statefulPlugin) + saveState() + } + } + + public func removeActiveStatefulPlugin(_ statefulPlugin: StatefulPluggable) { + statefulPluginLock.withLock { + statefulPlugins.removeAll { $0.pluginIdentifier == statefulPlugin.pluginIdentifier } + saveState() + } + } + + private func saveState() { + rawStatefulPlugins = statefulPlugins.compactMap { $0.rawValue } + } + + private func restoreState() { + let rawStatefulPlugins = rawStatefulPlugins ?? [] + rawStatefulPlugins.forEach { rawValue in + if let statefulPlugin = statefulPluginFromRawValue(rawValue) { + statefulPlugin.initializationComplete(for: servicesManager.activeServices) + statefulPlugins.append(statefulPlugin) + } + } + } +} + +extension StatefulPluginManager: StatefulPluggableDelegate { + func pluginDidUpdateState(_ plugin: StatefulPluggable) { + saveState() + } + + func pluginWantsDeletion(_ plugin: LoopKit.StatefulPluggable) { + removeActiveStatefulPlugin(plugin) + } +} diff --git a/Loop/Managers/SupportManager.swift b/Loop/Managers/SupportManager.swift index dae07c7e25..58cddddf74 100644 --- a/Loop/Managers/SupportManager.swift +++ b/Loop/Managers/SupportManager.swift @@ -54,7 +54,7 @@ public final class SupportManager { self.pluginManager = pluginManager self.staticSupportTypes = [] staticSupportTypesByIdentifier = self.staticSupportTypes.reduce(into: [:]) { (map, type) in - map[type.supportIdentifier] = type + map[type.pluginIdentifier] = type } restoreState() @@ -75,7 +75,7 @@ public final class SupportManager { for bundle in remainingSupportBundles { do { if let support = try bundle.loadAndInstantiateSupport() { - log.debug("Loaded support plugin: %{public}@", support.identifier) + log.debug("Loaded support plugin: %{public}@", support.pluginIdentifier) addSupport(support) } } catch { @@ -111,8 +111,8 @@ public final class SupportManager { extension SupportManager { func addSupport(_ support: SupportUI) { supports.mutate { - if $0[support.identifier] == nil { - $0[support.identifier] = support + if $0[support.pluginIdentifier] == nil { + $0[support.pluginIdentifier] = support support.delegate = self } } @@ -124,7 +124,7 @@ extension SupportManager { func removeSupport(_ support: SupportUI) { supports.mutate { - $0[support.identifier] = nil + $0[support.pluginIdentifier] = nil support.delegate = self } } @@ -156,7 +156,7 @@ extension SupportManager { supports.value.values.forEach { support in group.addTask { - return (await support.checkVersion(bundleIdentifier: Bundle.main.bundleIdentifier!, currentVersion: Bundle.main.shortVersionString), support.identifier) + return (await support.checkVersion(bundleIdentifier: Bundle.main.bundleIdentifier!, currentVersion: Bundle.main.shortVersionString), support.pluginIdentifier) } } @@ -331,7 +331,7 @@ fileprivate extension UserDefaults { extension SupportUI { var rawValue: RawStateValue { return [ - "supportIdentifier": Self.supportIdentifier, + "supportIdentifier": Self.pluginIdentifier, "state": rawState ] } diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index eabf1b060a..b71e357433 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -199,7 +199,7 @@ extension TestingScenariosManagerRequirements { if instance.hasCGMData { if let cgmManager = deviceManager.cgmManager as? TestingCGMManager { if instance.shouldReloadManager?.cgm == true { - testingCGMManager = reloadCGMManager(withIdentifier: cgmManager.managerIdentifier) + testingCGMManager = reloadCGMManager(withIdentifier: cgmManager.pluginIdentifier) } else { testingCGMManager = cgmManager } @@ -212,7 +212,7 @@ extension TestingScenariosManagerRequirements { if instance.hasPumpData { if let pumpManager = deviceManager.pumpManager as? TestingPumpManager { if instance.shouldReloadManager?.pump == true { - testingPumpManager = reloadPumpManager(withIdentifier: pumpManager.managerIdentifier) + testingPumpManager = reloadPumpManager(withIdentifier: pumpManager.pluginIdentifier) } else { testingPumpManager = pumpManager } @@ -243,9 +243,9 @@ extension TestingScenariosManagerRequirements { } instance.deviceActions.forEach { [testingCGMManager, testingPumpManager] action in - if testingCGMManager?.managerIdentifier == action.managerIdentifier { + if testingCGMManager?.pluginIdentifier == action.managerIdentifier { testingCGMManager?.trigger(action: action) - } else if testingPumpManager?.managerIdentifier == action.managerIdentifier { + } else if testingPumpManager?.pluginIdentifier == action.managerIdentifier { testingPumpManager?.trigger(action: action) } } diff --git a/Loop/Plugins/PluginManager.swift b/Loop/Plugins/PluginManager.swift index bec19e0602..a254d26872 100644 --- a/Loop/Plugins/PluginManager.swift +++ b/Loop/Plugins/PluginManager.swift @@ -145,6 +145,37 @@ class PluginManager { return ServiceDescriptor(identifier: identifier, localizedTitle: title) }) } + + func getStatefulPluginTypeByIdentifier(_ identifier: String) -> StatefulPluggable.Type? { + for bundle in pluginBundles { + if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.statefulPluginIdentifier.rawValue) as? String, name == identifier { + do { + try bundle.loadAndReturnError() + + if let principalClass = bundle.principalClass as? NSObject.Type { + + if let plugin = principalClass.init() as? StatefulPlugin { + return plugin.pluginType + } else { + fatalError("PrincipalClass does not conform to StatefulPlugin") + } + + } else { + fatalError("PrincipalClass not found") + } + } catch let error { + log.error("Error loading plugin: %{public}@", String(describing: error)) + } + } + } + return nil + } + + var availableStatefulPluginIdentifiers: [String] { + return pluginBundles.compactMap({ (bundle) -> String? in + return bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.statefulPluginIdentifier.rawValue) as? String + }) + } func getOnboardingTypeByIdentifier(_ identifier: String) -> OnboardingUI.Type? { for bundle in pluginBundles { @@ -201,18 +232,18 @@ class PluginManager { } return nil } - } extension Bundle { var isPumpManagerPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.pumpManagerIdentifier.rawValue) as? String != nil } var isCGMManagerPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.cgmManagerIdentifier.rawValue) as? String != nil } + var isStatefulPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.statefulPluginIdentifier.rawValue) as? String != nil } var isServicePlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.serviceIdentifier.rawValue) as? String != nil } var isOnboardingPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.onboardingIdentifier.rawValue) as? String != nil } var isSupportPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.supportIdentifier.rawValue) as? String != nil } - var isLoopPlugin: Bool { isPumpManagerPlugin || isCGMManagerPlugin || isServicePlugin || isOnboardingPlugin || isSupportPlugin } + var isLoopPlugin: Bool { isPumpManagerPlugin || isCGMManagerPlugin || isStatefulPlugin || isServicePlugin || isOnboardingPlugin || isSupportPlugin } var isLoopExtension: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.extensionIdentifier.rawValue) as? String != nil } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 3e5312dafb..8906a75986 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -2255,7 +2255,7 @@ extension StatusTableViewController: ServicesViewModelDelegate { } func gotoService(withIdentifier identifier: String) { - guard let serviceUI = deviceManager.servicesManager.activeServices.first(where: { $0.serviceIdentifier == identifier }) as? ServiceUI else { + guard let serviceUI = deviceManager.servicesManager.activeServices.first(where: { $0.pluginIdentifier == identifier }) as? ServiceUI else { return } showServiceSettings(serviceUI) diff --git a/Loop/View Models/ServicesViewModel.swift b/Loop/View Models/ServicesViewModel.swift index d59e1e6603..19fb2a7d57 100644 --- a/Loop/View Models/ServicesViewModel.swift +++ b/Loop/View Models/ServicesViewModel.swift @@ -24,7 +24,7 @@ public class ServicesViewModel: ObservableObject { var inactiveServices: () -> [ServiceDescriptor] { return { return self.availableServices().filter { availableService in - !self.activeServices().contains { $0.serviceIdentifier == availableService.identifier } + !self.activeServices().contains { $0.pluginIdentifier == availableService.identifier } } } } @@ -42,7 +42,7 @@ public class ServicesViewModel: ObservableObject { } func didTapService(_ index: Int) { - delegate?.gotoService(withIdentifier: activeServices()[index].serviceIdentifier) + delegate?.gotoService(withIdentifier: activeServices()[index].pluginIdentifier) } func didTapAddService(_ availableService: ServiceDescriptor) { @@ -54,23 +54,25 @@ public class ServicesViewModel: ObservableObject { extension ServicesViewModel { fileprivate class FakeService1: Service { static var localizedTitle: String = "Service 1" - static var serviceIdentifier: String = "FakeService1" + static var pluginIdentifier: String = "FakeService1" + var stateDelegate: StatefulPluggableDelegate? var serviceDelegate: ServiceDelegate? var rawState: RawStateValue = [:] required init() {} required init?(rawState: RawStateValue) {} let isOnboarded = true - var available: ServiceDescriptor { ServiceDescriptor(identifier: serviceIdentifier, localizedTitle: localizedTitle) } + var available: ServiceDescriptor { ServiceDescriptor(identifier: pluginIdentifier, localizedTitle: localizedTitle) } } fileprivate class FakeService2: Service { static var localizedTitle: String = "Service 2" - static var serviceIdentifier: String = "FakeService2" + static var pluginIdentifier: String = "FakeService2" + var stateDelegate: StatefulPluggableDelegate? var serviceDelegate: ServiceDelegate? var rawState: RawStateValue = [:] required init() {} required init?(rawState: RawStateValue) {} let isOnboarded = true - var available: ServiceDescriptor { ServiceDescriptor(identifier: serviceIdentifier, localizedTitle: localizedTitle) } + var available: ServiceDescriptor { ServiceDescriptor(identifier: pluginIdentifier, localizedTitle: localizedTitle) } } static var preview: ServicesViewModel { diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 856db6da11..e9a38e72a0 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -72,6 +72,75 @@ struct AlertManagementView: View { } .navigationTitle(NSLocalizedString("Alert Management", comment: "Title of alert management screen")) } + + private var footerView: some View { + VStack(alignment: .leading, spacing: 24) { + HStack(alignment: .top, spacing: 8) { + Image("phone") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 64, maxHeight: 64) + + VStack(alignment: .leading, spacing: 4) { + Text( + String( + format: NSLocalizedString( + "%1$@ APP SOUNDS", + comment: "App sounds title text (1: app name)" + ), + appName.uppercased() + ) + ) + + Text( + String( + format: NSLocalizedString( + "While mute alerts is on, all alerts from your %1$@ app including Critical and Time Sensitive alerts will temporarily display without sounds and will vibrate only.", + comment: "App sounds descriptive text (1: app name)" + ), + appName + ) + ) + } + } + + HStack(alignment: .top, spacing: 8) { + Image("hardware") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 64, maxHeight: 64) + + VStack(alignment: .leading, spacing: 4) { + Text("HARDWARE SOUNDS") + + Text("While mute alerts is on, your insulin pump and CGM hardware may still sound.") + } + } + + HStack(alignment: .top, spacing: 8) { + Image(systemName: "moon.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 64, maxHeight: 48) + .foregroundColor(.accentColor) + + VStack(alignment: .leading, spacing: 4) { + Text("IOS FOCUS MODES") + + Text( + String( + format: NSLocalizedString( + "If iOS Focus Mode is ON and Mute Alerts is OFF, Critical Alerts will still be delivered and non-Critical Alerts will be silenced until %1$@ is added to each Focus mode as an Allowed App.", + comment: "Focus modes descriptive text (1: app name)" + ), + appName + ) + ) + } + } + } + .padding(.top) + } private var alertPermissionsSection: some View { Section(footer: DescriptiveText(label: String(format: NSLocalizedString("Notifications give you important %1$@ app information without requiring you to open the app.", comment: "Alert Permissions descriptive text (1: app name)"), appName))) { @@ -93,7 +162,7 @@ struct AlertManagementView: View { @ViewBuilder private var muteAlertsSection: some View { - Section(footer: DescriptiveText(label: String(format: NSLocalizedString("When muted, %1$@ alerts will temporarily display without sounds and will vibrate only. Once the mute period ends, your alerts will resume as normal.", comment: "Description of temporary mute alerts (1: app name)"), appName))) { + Section(footer: footerView) { if !alertMuter.configuration.shouldMute { howMuteAlertsWork Button(action: { showMuteAlertOptions = true }) { @@ -142,7 +211,7 @@ struct AlertManagementView: View { private var howMuteAlertsWork: some View { Button(action: { showHowMuteAlertWork = true }) { HStack { - Text(NSLocalizedString("Take a closer look at how mute alerts works", comment: "Label for link to learn how mute alerts work")) + Text(NSLocalizedString("Frequently asked questions about alerts", comment: "Label for link to see frequently asked questions")) .font(.footnote) .foregroundColor(.secondary) Spacer() diff --git a/Loop/Views/HowMuteAlertWorkView.swift b/Loop/Views/HowMuteAlertWorkView.swift index 4f5932d36b..08443a6b80 100644 --- a/Loop/Views/HowMuteAlertWorkView.swift +++ b/Loop/Views/HowMuteAlertWorkView.swift @@ -17,52 +17,109 @@ struct HowMuteAlertWorkView: View { var body: some View { NavigationView { List { - VStack(alignment: .leading) { - Text(NSLocalizedString(""" -Mute Alerts allows you to temporarily silence your alerts and alarms. - -When using Mute Alerts, also consider the impact of using iOS Focus Modes. -""", comment: "Description of how mute alerts work")) - .fixedSize(horizontal: false, vertical: true) + 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:") + } - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 10) { - Image(systemName: "speaker.slash.fill") - .foregroundColor(.white) - .padding(5) - .background(guidanceColors.warning) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + 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(String(format: NSLocalizedString("%1$@ Mute Alerts", comment: "Format string for Section title for description that mute alerts is temporary (1: app name)"), appName)) - .bold() - .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: 4) { + Text("Time Sensitive Alerts") + .bold() + + Text("High Glucose") + .bulleted() + Text("Transmitter Low Battery") + .bulleted() + } } - Text(NSLocalizedString(""" -All Tidepool Loop alerts, including Critical Alerts, will be silenced for up to 4 hours. - -After the mute period ends, your alert sounds will resume. -""", comment: "Description that mute alerts is temporary")) - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom) - - HStack(spacing: 10) { - Image(systemName: "moon.fill") - .foregroundColor(.accentColor) - - Text(NSLocalizedString("iOS Focus Mode", comment: "Section title for description of how mute alerts work with focus mode")) - .bold() - } - Text(String(format: NSLocalizedString("If iOS Focus Mode is ON and Mute Alerts is OFF, Critical Alerts will still be delivered, but non-Critical Alerts will be silenced until %1$@ is added to each Focus mode as an Allowed App.", comment: "Format string for description of how mute alerts works with focus mode (1: app name)"), appName)) - .fixedSize(horizontal: false, vertical: true) + Spacer() } + .font(.footnote) + .foregroundColor(.black.opacity(0.6)) .padding() - .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(Color(.systemFill), lineWidth: 1)) + .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 + ) + ) + .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 + ) + ) + } + + 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 + ) + ) + } + + 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 + ) + ) + } } + .padding(.vertical, 8) } .insetGroupedListStyle() - .navigationTitle(NSLocalizedString("Using Mute Alerts", comment: "View title for how mute alerts work")) + .navigationTitle(NSLocalizedString("Managing Alerts", comment: "View title for how mute alerts work")) .navigationBarItems(trailing: closeButton) } } @@ -74,6 +131,19 @@ After the mute period ends, your alert sounds will resume. } } +private extension Text { + func bulleted(color: Color = .accentColor.opacity(0.5)) -> some View { + HStack(spacing: 16) { + Image(systemName: "circle.fill") + .resizable() + .frame(width: 8, height: 8) + .foregroundColor(color) + + self + } + } +} + struct HowMuteAlertWorkView_Previews: PreviewProvider { static var previews: some View { HowMuteAlertWorkView() diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index af584097e0..c3ec98b8dd 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -24,13 +24,39 @@ public struct SettingsView: View { @ObservedObject var viewModel: SettingsViewModel @ObservedObject var versionUpdateViewModel: VersionUpdateViewModel - @State private var pumpChooserIsPresented: Bool = false - @State private var cgmChooserIsPresented: Bool = false - @State private var favoriteFoodsIsPresented: Bool = false - @State private var serviceChooserIsPresented: Bool = false - @State private var therapySettingsIsPresented: Bool = false - @State private var deletePumpDataAlertIsPresented = false - @State private var deleteCGMDataAlertIsPresented = false + enum Destination { + enum Alert: String, Identifiable { + var id: String { + rawValue + } + + case deleteCGMData + case deletePumpData + } + + enum ActionSheet: String, Identifiable { + var id: String { + rawValue + } + + case cgmPicker + case pumpPicker + case servicePicker + } + + enum Sheet: String, Identifiable { + var id: String { + rawValue + } + + case favoriteFoods + case therapySettings + } + } + + @State private var actionSheet: Destination.ActionSheet? + @State private var alert: Destination.Alert? + @State private var sheet: Destination.Sheet? var localizedAppNameAndVersion: String @@ -82,6 +108,57 @@ public struct SettingsView: View { .insetGroupedListStyle() .navigationBarTitle(Text(NSLocalizedString("Settings", comment: "Settings screen title"))) .navigationBarItems(trailing: dismissButton) + .actionSheet(item: $actionSheet) { actionSheet in + switch actionSheet { + case .cgmPicker: + return ActionSheet( + title: Text("Add CGM", comment: "The title of the CGM chooser in settings"), + buttons: cgmChoices + ) + case .pumpPicker: + return ActionSheet( + title: Text("Add Pump", comment: "The title of the pump chooser in settings"), + buttons: pumpChoices + ) + case .servicePicker: + return ActionSheet( + title: Text("Add Service", comment: "The title of the add service action sheet in settings"), + buttons: serviceChoices + ) + } + } + .alert(item: $alert) { alert in + switch alert { + case .deleteCGMData: + return makeDeleteAlert(for: self.viewModel.cgmManagerSettingsViewModel) + case .deletePumpData: + return makeDeleteAlert(for: self.viewModel.pumpManagerSettingsViewModel) + } + } + .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 + ) + ) + .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() + } + } } .navigationViewStyle(.stack) } @@ -177,53 +254,46 @@ extension SettingsView { } } } + + @ViewBuilder + private var alertWarning: some View { + if viewModel.alertPermissionsChecker.showWarning || viewModel.alertPermissionsChecker.notificationCenterSettings.scheduledDeliveryEnabled { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.critical) + } else if viewModel.alertMuter.configuration.shouldMute { + Image(systemName: "speaker.slash.fill") + .foregroundColor(.white) + .padding(5) + .background(guidanceColors.warning) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + } + } private var alertManagementSection: some View { Section { - NavigationLink(destination: AlertManagementView(checker: viewModel.alertPermissionsChecker, alertMuter: viewModel.alertMuter)) - { - HStack { - Text(NSLocalizedString("Alert Management", comment: "Alert Permissions button text")) - if viewModel.alertPermissionsChecker.showWarning || - viewModel.alertPermissionsChecker.notificationCenterSettings.scheduledDeliveryEnabled { - Spacer() - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.critical) - } else if viewModel.alertMuter.configuration.shouldMute { - Spacer() - Image(systemName: "speaker.slash.fill") - .foregroundColor(.white) - .padding(5) - .background(guidanceColors.warning) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) - } - } + NavigationLink(destination: AlertManagementView(checker: viewModel.alertPermissionsChecker, alertMuter: viewModel.alertMuter)) { + LargeButton( + action: {}, + includeArrow: false, + imageView: Image(systemName: "bell.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .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") + ) } } } private var configurationSection: some View { Section(header: SectionHeader(label: NSLocalizedString("Configuration", comment: "The title of the Configuration section in settings"))) { - LargeButton(action: { self.therapySettingsIsPresented = true }, + 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")) - .sheet(isPresented: $therapySettingsIsPresented) { - TherapySettingsView(mode: .settings, - viewModel: TherapySettingsViewModel(therapySettings: self.viewModel.therapySettings(), - sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, - delegate: self.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) - } ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in item.view @@ -238,7 +308,7 @@ extension SettingsView { private var pluginMenuItems: [PluginMenuItem] { self.viewModel.availableSupports.flatMap { plugin in plugin.configurationMenuItems().enumerated().map { index, item in - PluginMenuItem(section: item.section, view: item.view, pluginIdentifier: plugin.identifier, offset: index) + PluginMenuItem(section: item.section, view: item.view, pluginIdentifier: plugin.pluginIdentifier, offset: index) } } } @@ -259,16 +329,11 @@ extension SettingsView { label: viewModel.pumpManagerSettingsViewModel.name(), descriptiveText: NSLocalizedString("Insulin Pump", comment: "Descriptive text for Insulin Pump")) } else if viewModel.isOnboardingComplete { - LargeButton(action: { self.pumpChooserIsPresented = true }, + LargeButton(action: { actionSheet = .pumpPicker }, includeArrow: false, imageView: plusImage, label: NSLocalizedString("Add Pump", comment: "Title text for button to add pump device"), descriptiveText: NSLocalizedString("Tap here to set up a pump", comment: "Descriptive text for button to add pump device")) - .actionSheet(isPresented: $pumpChooserIsPresented) { - ActionSheet(title: Text("Add Pump", comment: "The title of the pump chooser in settings"), buttons: pumpChoices) - } - } else { - EmptyView() } } @@ -291,28 +356,22 @@ extension SettingsView { label: viewModel.cgmManagerSettingsViewModel.name(), descriptiveText: NSLocalizedString("Continuous Glucose Monitor", comment: "Descriptive text for Continuous Glucose Monitor")) } else { - LargeButton(action: { self.cgmChooserIsPresented = true }, + LargeButton(action: { actionSheet = .cgmPicker }, includeArrow: false, imageView: plusImage, label: NSLocalizedString("Add CGM", comment: "Title text for button to add CGM device"), descriptiveText: NSLocalizedString("Tap here to set up a CGM", comment: "Descriptive text for button to add CGM device")) - .actionSheet(isPresented: $cgmChooserIsPresented) { - ActionSheet(title: Text("Add CGM", comment: "The title of the CGM chooser in settings"), buttons: cgmChoices) - } } } private var favoriteFoodsSection: some View { Section { - LargeButton(action: { self.favoriteFoodsIsPresented = true }, + LargeButton(action: { sheet = .favoriteFoods }, includeArrow: true, imageView: Image("Favorite Foods Icon").renderingMode(.template).foregroundColor(carbTintColor), label: "Favorite Foods", descriptiveText: "Simplify Carb Entry") } - .sheet(isPresented: $favoriteFoodsIsPresented) { - FavoriteFoodsView() - } } private var cgmChoices: [ActionSheet.Button] { @@ -337,14 +396,11 @@ extension SettingsView { descriptiveText: "") } if viewModel.servicesViewModel.inactiveServices().count > 0 { - LargeButton(action: { self.serviceChooserIsPresented = true }, + LargeButton(action: { actionSheet = .servicePicker }, includeArrow: false, imageView: plusImage, label: NSLocalizedString("Add Service", comment: "The title of the add service button in settings"), descriptiveText: NSLocalizedString("Tap here to set up a Service", comment: "The descriptive text of the add service button in settings")) - .actionSheet(isPresented: $serviceChooserIsPresented) { - ActionSheet(title: Text("Add Service", comment: "The title of the add service action sheet in settings"), buttons: serviceChoices) - } } } } @@ -362,28 +418,22 @@ extension SettingsView { private var deleteDataSection: some View { Section { if viewModel.pumpManagerSettingsViewModel.isTestingDevice { - Button(action: { self.deletePumpDataAlertIsPresented.toggle() }) { + Button(action: { alert = .deletePumpData }) { HStack { Spacer() Text("Delete Testing Pump Data").accentColor(.destructive) Spacer() } } - .alert(isPresented: $deletePumpDataAlertIsPresented) { - makeDeleteAlert(for: self.viewModel.pumpManagerSettingsViewModel) - } } if viewModel.cgmManagerSettingsViewModel.isTestingDevice { - Button(action: { self.deleteCGMDataAlertIsPresented.toggle() }) { + Button(action: { alert = .deleteCGMData }) { HStack { Spacer() Text("Delete Testing CGM Data").accentColor(.destructive) Spacer() } } - .alert(isPresented: $deleteCGMDataAlertIsPresented) { - makeDeleteAlert(for: self.viewModel.cgmManagerSettingsViewModel) - } } } } @@ -499,33 +549,60 @@ extension SettingsView { } } -fileprivate struct LargeButton: View { +fileprivate struct LargeButton: View { let action: () -> Void - var includeArrow: Bool = true + var includeArrow: Bool let imageView: Content + let secondaryImageView: SecondaryContent let label: String let descriptiveText: String + + init( + action: @escaping () -> Void, + includeArrow: Bool = true, + imageView: Content, + secondaryImageView: SecondaryContent = EmptyView(), + label: String, + descriptiveText: String + ) { + self.action = action + self.includeArrow = includeArrow + self.imageView = imageView + self.secondaryImageView = secondaryImageView + self.label = label + self.descriptiveText = descriptiveText + } // TODO: The design doesn't show this, but do we need to consider different values here for different size classes? private let spacing: CGFloat = 15 private let imageWidth: CGFloat = 60 private let imageHeight: CGFloat = 60 + private let secondaryImageWidth: CGFloat = 30 + private let secondaryImageHeight: CGFloat = 30 private let topBottomPadding: CGFloat = 10 public var body: some View { Button(action: action) { HStack { HStack(spacing: spacing) { - imageView.frame(width: imageWidth, height: imageHeight) + imageView.frame(maxWidth: imageWidth, maxHeight: imageHeight) VStack(alignment: .leading) { Text(label) .foregroundColor(.primary) DescriptiveText(label: descriptiveText) } } - if includeArrow { + + if !(secondaryImageView is EmptyView) || includeArrow { Spacer() + } + + if !(secondaryImageView is EmptyView) { + secondaryImageView.frame(width: secondaryImageWidth, height: secondaryImageHeight) + } + + if includeArrow { // TODO: Ick. I can't use a NavigationLink because we're not Navigating, but this seems worse somehow. Image(systemName: "chevron.right").foregroundColor(.gray).font(.footnote) } diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index 72359793e6..bf722ec874 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -121,7 +121,7 @@ class MockPumpManager: PumpManager { .minutes(units / deliveryUnitsPerMinute) } - var managerIdentifier: String = "MockPumpManager" + static var pluginIdentifier: String = "MockPumpManager" var localizedTitle: String = "MockPumpManager" diff --git a/LoopTests/Managers/SupportManagerTests.swift b/LoopTests/Managers/SupportManagerTests.swift index ac0d42b512..48fa42e4d8 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 supportIdentifier: String { "SupportManagerTestsMockSupport" } + static var pluginIdentifier: String { "SupportManagerTestsMockSupport" } override init() { super.init() } required init?(rawState: RawStateValue) { super.init() } var rawState: RawStateValue = [:] @@ -42,12 +42,11 @@ class SupportManagerTests: XCTestCase { func getScenarios(from scenarioURLs: [URL]) -> [LoopScenario] { [] } func loopWillReset() {} func loopDidReset() {} - func initializationComplete(for services: [LoopKit.Service]) {} func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { return [] } } class AnotherMockSupport: Mixin, SupportUI { - static var supportIdentifier: String { "SupportManagerTestsAnotherMockSupport" } + static var pluginIdentifier: String { "SupportManagerTestsAnotherMockSupport" } override init() { super.init() } required init?(rawState: RawStateValue) { super.init() } var rawState: RawStateValue = [:] @@ -55,7 +54,6 @@ class SupportManagerTests: XCTestCase { func getScenarios(from scenarioURLs: [URL]) -> [LoopScenario] { [] } func loopWillReset() {} func loopDidReset() {} - func initializationComplete(for services: [LoopKit.Service]) {} func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { return [] } }