diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 52137df99d..fc1dc34c96 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -56,7 +56,6 @@ 1DB1065124467E18005542BD /* AlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB1065024467E18005542BD /* AlertManager.swift */; }; 1DB1CA4D24A55F0000B3B94C /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB1CA4C24A55F0000B3B94C /* Image.swift */; }; 1DB619AC270BAD3D006C9D07 /* VersionUpdateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */; }; - 1DD0B76724EC77AC008A2DC3 /* SupportScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DD0B76624EC77AC008A2DC3 /* SupportScreenView.swift */; }; 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */; }; 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */; }; 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */; }; @@ -270,6 +269,7 @@ 7D7076591FE06EE2004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D70765B1FE06EE2004AC8EA /* Localizable.strings */; }; 7D70765E1FE06EE3004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076601FE06EE3004AC8EA /* Localizable.strings */; }; 7D7076631FE06EE4004AC8EA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7D7076651FE06EE4004AC8EA /* Localizable.strings */; }; + 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */; }; 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 */; }; @@ -821,7 +821,6 @@ 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 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 = ""; }; - 1DD0B76624EC77AC008A2DC3 /* SupportScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportScreenView.swift; sourceTree = ""; }; 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 1DFE9E162447B6270082C280 /* UserNotificationAlertSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationAlertSchedulerTests.swift; sourceTree = ""; }; 4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldTableViewController.swift; sourceTree = ""; }; @@ -1254,6 +1253,7 @@ 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 = ""; }; 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; }; @@ -2396,7 +2396,6 @@ 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */, - 1DD0B76624EC77AC008A2DC3 /* SupportScreenView.swift */, 43F64DD81D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift */, 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */, C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, @@ -2440,6 +2439,7 @@ E9B355232935906B0076AB04 /* Missed Meal Detection */, C1F2075B26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift */, A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, + 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, ); path = Managers; sourceTree = ""; @@ -3964,7 +3964,6 @@ 43DFB62320D4CAE7008A7BAE /* PumpManager.swift in Sources */, A9FB75F1252BE320004C7D3F /* BolusDosingDecision.swift in Sources */, 892A5D59222F0A27008961AB /* Debug.swift in Sources */, - 1DD0B76724EC77AC008A2DC3 /* SupportScreenView.swift in Sources */, 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */, 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, @@ -3982,6 +3981,7 @@ 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 */, ); diff --git a/Loop/AppDelegate.swift b/Loop/AppDelegate.swift index 80d45a1941..ed2a38a850 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -46,7 +46,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, WindowProvider { func applicationWillEnterForeground(_ application: UIApplication) { log.default(#function) - loopAppManager.askUserToConfirmCrashIfNecessary() + loopAppManager.askUserToConfirmLoopReset() } func applicationWillTerminate(_ application: UIApplication) { diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 54d52dc21f..bc8b9f6a2a 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -784,14 +784,16 @@ extension AlertManager: AlertPermissionsCheckerDelegate { } extension AlertManager { - func presentConfirmCrashAlert(confirmAction: @escaping (@escaping () -> Void) -> Void) { - let alert = UIAlertController(title: "New Study Product Detected", message: "We've detected a new study product is selected. In order to show use this study product, Loop will need to restart.", preferredStyle: .alert) + func presentLoopResetConfirmationAlert(confirmAction: @escaping (@escaping () -> Void) -> Void, cancelAction: @escaping () -> Void) { + 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() { fatalError("DEBUG: Resetting Loop") } })) - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { _ in + cancelAction() + })) alertPresenter.present(alert, animated: true) } diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 4cfae7046a..5f9c787c20 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -29,8 +29,6 @@ final class DeviceDataManager { /// Remember the launch date of the app for diagnostic reporting private let launchDate = Date() - private(set) var testingScenariosManager: TestingScenariosManager? - /// The last error recorded by a device manager /// Should be accessed only on the main queue private(set) var lastError: (date: Date, error: Error)? @@ -417,10 +415,6 @@ final class DeviceDataManager { directory: FileManager.default.exportsDirectoryURL, historicalDuration: Bundle.main.localCacheDuration) - if FeatureFlags.scenariosEnabled { - testingScenariosManager = LocalTestingScenariosManager(deviceManager: self) - } - loopManager.delegate = self alertManager.alertStore.delegate = self @@ -658,64 +652,6 @@ final class DeviceDataManager { self.getHealthStoreAuthorization(completion) } } - - func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { - self.loopManager.generateDiagnosticReport { (loopReport) in - - let logDurationHours = 84.0 - - self.alertManager.getStoredEntries(startDate: Date() - .hours(logDurationHours)) { (alertReport) in - self.deviceLog.getLogEntries(startDate: Date() - .hours(logDurationHours)) { (result) in - let deviceLogReport: String - switch result { - case .failure(let error): - deviceLogReport = "Error fetching entries: \(error)" - case .success(let entries): - deviceLogReport = entries.map { "* \($0.timestamp) \($0.managerIdentifier) \($0.deviceIdentifier ?? "") \($0.type) \($0.message)" }.joined(separator: "\n") - } - - let report = [ - "## Build Details", - "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", - "* profileExpiration: \(Bundle.main.profileExpirationString)", - "* gitRevision: \(Bundle.main.gitRevision ?? "N/A")", - "* gitBranch: \(Bundle.main.gitBranch ?? "N/A")", - "* workspaceGitRevision: \(Bundle.main.workspaceGitRevision ?? "N/A")", - "* workspaceGitBranch: \(Bundle.main.workspaceGitBranch ?? "N/A")", - "* sourceRoot: \(Bundle.main.sourceRoot ?? "N/A")", - "* buildDateString: \(Bundle.main.buildDateString ?? "N/A")", - "* xcodeVersion: \(Bundle.main.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) - } - } - } - } } private extension DeviceDataManager { @@ -1286,9 +1222,6 @@ extension DeviceDataManager { } insulinDeliveryStore.purgeAllDoseEntries(healthKitPredicate: devicePredicate) { error in - if error == nil { - insulinDeliveryStore.test_lastImmutableBasalEndDate = nil - } completion?(error) } } @@ -1659,40 +1592,6 @@ fileprivate extension FileManager { extension GlucoseStore : CGMStalenessMonitorDelegate { } -//MARK: - SupportInfoProvider protocol conformance - -extension DeviceDataManager: SupportInfoProvider { - - private var branchNameIfNotReleaseBranch: String? { - return Bundle.main.gitBranch.filter { branch in - return branch != "" && - branch != "main" && - branch != "master" && - !branch.starts(with: "release/") - } - } - - public var localizedAppNameAndVersion: String { - if let branch = branchNameIfNotReleaseBranch { - return Bundle.main.localizedNameAndVersion + " (\(branch))" - } - return Bundle.main.localizedNameAndVersion - } - - public var pumpStatus: PumpManagerStatus? { - return pumpManager?.status - } - - public var cgmStatus: CGMManagerStatus? { - return cgmManager?.cgmManagerStatus - } - - public func generateIssueReport(completion: @escaping (String) -> Void) { - generateDiagnosticReport(completion) - } - -} - //MARK: TherapySettingsViewModelDelegate struct CancelTempBasalFailedError: LocalizedError { let reason: Error? @@ -1793,8 +1692,66 @@ extension DeviceDataManager { } } -extension DeviceDataManager { +extension DeviceDataManager: DeviceSupportDelegate { var availableSupports: [SupportUI] { [cgmManager, pumpManager].compactMap { $0 as? SupportUI } } + + func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { + self.loopManager.generateDiagnosticReport { (loopReport) in + + let logDurationHours = 84.0 + + self.alertManager.getStoredEntries(startDate: Date() - .hours(logDurationHours)) { (alertReport) in + self.deviceLog.getLogEntries(startDate: Date() - .hours(logDurationHours)) { (result) in + let deviceLogReport: String + switch result { + case .failure(let error): + deviceLogReport = "Error fetching entries: \(error)" + case .success(let entries): + deviceLogReport = entries.map { "* \($0.timestamp) \($0.managerIdentifier) \($0.deviceIdentifier ?? "") \($0.type) \($0.message)" }.joined(separator: "\n") + } + + let report = [ + "## Build Details", + "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", + "* profileExpiration: \(Bundle.main.profileExpirationString)", + "* gitRevision: \(Bundle.main.gitRevision ?? "N/A")", + "* gitBranch: \(Bundle.main.gitBranch ?? "N/A")", + "* workspaceGitRevision: \(Bundle.main.workspaceGitRevision ?? "N/A")", + "* workspaceGitBranch: \(Bundle.main.workspaceGitBranch ?? "N/A")", + "* sourceRoot: \(Bundle.main.sourceRoot ?? "N/A")", + "* buildDateString: \(Bundle.main.buildDateString ?? "N/A")", + "* xcodeVersion: \(Bundle.main.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) + } + } + } + } } extension DeviceDataManager: DeviceStatusProvider {} diff --git a/Loop/Managers/LocalTestingScenariosManager.swift b/Loop/Managers/LocalTestingScenariosManager.swift index da75d392c5..bd1e7e087a 100644 --- a/Loop/Managers/LocalTestingScenariosManager.swift +++ b/Loop/Managers/LocalTestingScenariosManager.swift @@ -14,6 +14,7 @@ import OSLog final class LocalTestingScenariosManager: TestingScenariosManagerRequirements, DirectoryObserver { unowned let deviceManager: DeviceDataManager + unowned let supportManager: SupportManager let log = DiagnosticLog(category: "LocalTestingScenariosManager") @@ -35,12 +36,13 @@ final class LocalTestingScenariosManager: TestingScenariosManagerRequirements, D deviceManager.pluginManager } - init(deviceManager: DeviceDataManager) { + 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) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index dca1d430df..276b9255aa 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -81,6 +81,8 @@ class LoopAppManager: NSObject { private var settingsManager: SettingsManager! private var loggingServicesManager = LoggingServicesManager() private var analyticsServicesManager = AnalyticsServicesManager() + private(set) var testingScenariosManager: TestingScenariosManager? + private var resetLoopManager: ResetLoopManager! private var overrideHistory = UserDefaults.appGroup?.overrideHistory ?? TemporaryScheduleOverrideHistory.init() @@ -142,6 +144,8 @@ class LoopAppManager: NSObject { if state == .launchHomeScreen { launchHomeScreen() } + + askUserToConfirmLoopReset() } private func checkProtectedDataAvailable() { @@ -164,16 +168,13 @@ class LoopAppManager: NSObject { OrientationLock.deviceOrientationController = self UNUserNotificationCenter.current().delegate = self + resetLoopManager = ResetLoopManager(delegate: self) + let localCacheDuration = Bundle.main.localCacheDuration let cacheStore = PersistenceController.controllerInAppGroupDirectory() pluginManager = PluginManager() - for support in pluginManager.availableSupports { - if let analyticsService = support as? AnalyticsService { - analyticsServicesManager.addService(analyticsService) - } - } bluetoothStateManager = BluetoothStateManager() alertManager = AlertManager(alertPresenter: self, @@ -214,14 +215,31 @@ class LoopAppManager: NSObject { scheduleBackgroundTasks() + supportManager = SupportManager(pluginManager: pluginManager, + deviceSupportDelegate: deviceDataManager, + servicesManager: deviceDataManager.servicesManager, + alertIssuer: alertManager) + onboardingManager = OnboardingManager(pluginManager: pluginManager, bluetoothProvider: bluetoothStateManager, deviceDataManager: deviceDataManager, servicesManager: deviceDataManager.servicesManager, loopDataManager: deviceDataManager.loopManager, + supportManager: supportManager, windowProvider: windowProvider, userDefaults: UserDefaults.appGroup!) + + for support in supportManager.availableSupports { + if let analyticsService = support as? AnalyticsService { + analyticsServicesManager.addService(analyticsService) + } + } + for support in supportManager.availableSupports { + support.initializationComplete(for: deviceDataManager.servicesManager.activeServices) + } + + deviceDataManager.onboardingManager = onboardingManager analyticsServicesManager.identifyAppName(Bundle.main.bundleDisplayName) @@ -230,12 +248,12 @@ class LoopAppManager: NSObject { analyticsServicesManager.identifyWorkspaceGitRevision(workspaceGitRevision) } + if FeatureFlags.scenariosEnabled { + testingScenariosManager = LocalTestingScenariosManager(deviceManager: deviceDataManager, supportManager: supportManager) + } + analyticsServicesManager.application(didFinishLaunchingWithOptions: launchOptions) - supportManager = SupportManager(pluginManager: pluginManager, - deviceDataManager: deviceDataManager, - servicesManager: deviceDataManager.servicesManager, - alertIssuer: alertManager) automaticDosingStatus.$isAutomaticDosingAllowed .combineLatest(deviceDataManager.loopManager.$dosingEnabled) @@ -398,30 +416,6 @@ class LoopAppManager: NSObject { return false } - func askUserToConfirmCrashIfNecessary() { - deviceDataManager.pluginManager.availableSupports.forEach { supportUI in - if supportUI.loopNeedsReset { - supportUI.loopNeedsReset = false - alertManager.presentConfirmCrashAlert() { [weak self] completion in - guard let pumpManager = self?.deviceDataManager.pumpManager else { - supportUI.resetLoop() - completion() - return - } - - pumpManager.prepareForDeactivation() { [weak self] error in - guard let error = error else { - supportUI.resetLoop() - completion() - return - } - self?.alertManager.presentCouldNotResetLoopAlert(error: error) - } - } - } - } - } - private var rootViewController: UIViewController? { get { windowProvider?.window?.rootViewController } set { windowProvider?.window?.rootViewController = newValue } @@ -432,7 +426,9 @@ class LoopAppManager: NSObject { extension LoopAppManager: AlertPresenter { func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { - rootViewController?.topmostViewController.present(viewControllerToPresent, animated: animated, completion: completion) + DispatchQueue.main.async { + self.rootViewController?.topmostViewController.present(viewControllerToPresent, animated: animated, completion: completion) + } } func dismissTopMost(animated: Bool, completion: (() -> Void)?) { @@ -583,3 +579,33 @@ extension LoopAppManager: TemporaryScheduleOverrideHistoryDelegate { } } +extension LoopAppManager: ResetLoopManagerDelegate { + func askUserToConfirmLoopReset() { + resetLoopManager.askUserToConfirmLoopReset() + } + + 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 + ) + } + + func loopWillReset() { + supportManager.availableSupports.forEach { supportUI in + supportUI.loopWillReset() + } + } + + func loopDidReset() { + supportManager.availableSupports.forEach { supportUI in + supportUI.loopDidReset() + } + } + + func presentCouldNotResetLoopAlert(error: Error) { + alertManager.presentCouldNotResetLoopAlert(error: error) + } +} diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index d82c3e531f..534bd314ad 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -17,6 +17,7 @@ class OnboardingManager { private let deviceDataManager: DeviceDataManager private let servicesManager: ServicesManager private let loopDataManager: LoopDataManager + private let supportManager: SupportManager private weak var windowProvider: WindowProvider? private let userDefaults: UserDefaults @@ -38,12 +39,13 @@ class OnboardingManager { private var onboardingCompletion: (() -> Void)? - init(pluginManager: PluginManager, bluetoothProvider: BluetoothProvider, deviceDataManager: DeviceDataManager, servicesManager: ServicesManager, loopDataManager: LoopDataManager, windowProvider: WindowProvider?, userDefaults: UserDefaults = .standard) { + init(pluginManager: PluginManager, bluetoothProvider: BluetoothProvider, deviceDataManager: DeviceDataManager, servicesManager: ServicesManager, loopDataManager: LoopDataManager, supportManager: SupportManager, windowProvider: WindowProvider?, userDefaults: UserDefaults = .standard) { self.pluginManager = pluginManager self.bluetoothProvider = bluetoothProvider self.deviceDataManager = deviceDataManager self.servicesManager = servicesManager self.loopDataManager = loopDataManager + self.supportManager = supportManager self.windowProvider = windowProvider self.userDefaults = userDefaults @@ -440,7 +442,7 @@ extension OnboardingManager: OnboardingProvider { // MARK: - SupportProvider extension OnboardingManager: SupportProvider { - var availableSupports: [SupportUI] { deviceDataManager.pluginManager.availableSupports } + var availableSupports: [SupportUI] { supportManager.availableSupports } } // MARK: - OnboardingUI diff --git a/Loop/Managers/ResetLoopManager.swift b/Loop/Managers/ResetLoopManager.swift new file mode 100644 index 0000000000..316c16b21b --- /dev/null +++ b/Loop/Managers/ResetLoopManager.swift @@ -0,0 +1,120 @@ +// +// ResetLoopManager.swift +// Loop +// +// Created by Cameron Ingham on 5/18/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import LoopKit + +protocol ResetLoopManagerDelegate: AnyObject { + func loopWillReset() + func loopDidReset() + + func presentConfirmationAlert( + confirmAction: @escaping (_ pumpManager: PumpManager?, _ completion: @escaping () -> Void) -> Void, + cancelAction: @escaping () -> Void + ) + + func presentCouldNotResetLoopAlert(error: Error) +} + +class ResetLoopManager { + + private weak var delegate: ResetLoopManagerDelegate? + + private var loopIsAlreadyReset: Bool = false + private var resetAlertPresented: Bool = false + + init(delegate: ResetLoopManagerDelegate?) { + self.delegate = delegate + + checkIfLoopIsAlreadyReset() + } + + func askUserToConfirmLoopReset() { + if loopIsAlreadyReset { + UserDefaults.appGroup?.userRequestedLoopReset = false + } + + 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() + completion() + return + } + + self?.delegate?.presentCouldNotResetLoopAlert(error: error) + } + }, cancelAction: { [weak self] in + self?.resetAlertPresented = false + UserDefaults.appGroup?.userRequestedLoopReset = false + } + ) + } + + checkIfLoopIsAlreadyReset() + } + + private func checkIfLoopIsAlreadyReset() { + let fileManager = FileManager.default + + guard let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + return + } + + guard let hasReset = try? fileManager.contentsOfDirectory(atPath: url.path).count <= 1 else { + return + } + + loopIsAlreadyReset = hasReset + } + + private func resetLoop() { + delegate?.loopWillReset() + + resetLoopDocuments() + resetLoopUserDefaults() + + delegate?.loopDidReset() + } + + private func resetLoopUserDefaults() { + // Store values to persist + let allowDebugFeatures = UserDefaults.appGroup?.allowDebugFeatures + + // Wipe away whole domain + UserDefaults.appGroup?.removePersistentDomain(forName: Bundle.main.appGroupSuiteName) + + // Restore values to persist + UserDefaults.appGroup?.allowDebugFeatures = allowDebugFeatures ?? false + } + + private func resetLoopDocuments() { + guard let directoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Bundle.main.appGroupSuiteName) else { + preconditionFailure("Could not get a container directory URL. Please ensure App Groups are set up correctly in entitlements.") + } + + let documents: URL = directoryURL.appendingPathComponent("com.loopkit.LoopKit", isDirectory: true) + try? FileManager.default.removeItem(at: documents) + + guard let localDocuments = try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { + preconditionFailure("Could not get a documents directory URL.") + } + try? FileManager.default.removeItem(at: localDocuments) + } +} diff --git a/Loop/Managers/SupportManager.swift b/Loop/Managers/SupportManager.swift index 37250a24ad..88d517ba22 100644 --- a/Loop/Managers/SupportManager.swift +++ b/Loop/Managers/SupportManager.swift @@ -12,6 +12,14 @@ import LoopKit import LoopKitUI import SwiftUI +public protocol DeviceSupportDelegate { + var availableSupports: [SupportUI] { get } + var pumpManagerStatus: LoopKit.PumpManagerStatus? { get } + var cgmManagerStatus: LoopKit.CGMManagerStatus? { get } + + func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) +} + public final class SupportManager { private lazy var log = DiagnosticLog(category: "SupportManager") @@ -28,19 +36,21 @@ public final class SupportManager { } private let alertIssuer: AlertIssuer - private let pluginManager: PluginManager? + 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? = nil, - deviceDataManager: DeviceDataManager? = nil, + 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 @@ -49,8 +59,32 @@ public final class SupportManager { restoreState() - let availablePluginSupports = pluginManager?.availableSupports ?? [SupportUI]() - let availableDeviceSupports = deviceDataManager?.availableSupports ?? [SupportUI]() + // Any supports that we don't have state for, we still initialize. + let existingIds = supports.value.keys + let remainingSupportBundles = pluginManager.pluginBundles.filter { bundle in + guard bundle.isSupportPlugin || bundle.isAppExtension else { + return false + } + guard let identifier = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.supportIdentifier.rawValue) as? String else { + return false + } + return !existingIds.contains(identifier) + } + + + for bundle in remainingSupportBundles { + do { + if let support = try bundle.loadAndInstantiateSupport() { + log.debug("Loaded support plugin: %{public}@", support.identifier) + addSupport(support) + } + } catch { + log.error("Error loading plugin: %{public}@", String(describing: error)) + } + } + + 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 @@ -180,6 +214,38 @@ extension SupportManager { // MARK: SupportUIDelegate extension SupportManager: SupportUIDelegate { + public func openURL(url: URL) { + UIApplication.shared.open(url) + } + + public var pumpStatus: LoopKit.PumpManagerStatus? { + deviceSupportDelegate.pumpManagerStatus + } + + public var cgmStatus: LoopKit.CGMManagerStatus? { + deviceSupportDelegate.cgmManagerStatus + } + + private var branchNameIfNotReleaseBranch: String? { + return Bundle.main.gitBranch.filter { branch in + return branch != "" && + branch != "main" && + branch != "master" && + !branch.starts(with: "release/") + } + } + + public var localizedAppNameAndVersion: String { + if let branch = branchNameIfNotReleaseBranch { + return Bundle.main.localizedNameAndVersion + " (\(branch))" + } + return Bundle.main.localizedNameAndVersion + } + + public func generateIssueReport(completion: @escaping (String) -> Void) { + deviceSupportDelegate.generateDiagnosticReport(completion) + } + public func issueAlert(_ alert: LoopKit.Alert) { alertIssuer.issueAlert(alert) } @@ -217,7 +283,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) ?? staticSupportTypesByIdentifier[supportIdentifier] else { return nil } @@ -263,7 +329,6 @@ fileprivate extension UserDefaults { } extension SupportUI { - var rawValue: RawStateValue { return [ "supportIdentifier": Self.supportIdentifier, @@ -272,3 +337,16 @@ extension SupportUI { } } + +extension Bundle { + fileprivate func loadAndInstantiateSupport() throws -> SupportUI? { + try loadAndReturnError() + + guard let principalClass = principalClass as? NSObject.Type, + let supportUIPlugin = principalClass.init() as? SupportUIPlugin else { + return nil + } + + return supportUIPlugin.support + } +} diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index 51a8c3bfbf..eabf1b060a 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -18,7 +18,7 @@ protocol TestingScenariosManager: AnyObject { var delegate: TestingScenariosManagerDelegate? { get set } var activeScenarioURL: URL? { get } var scenarioURLs: [URL] { get } - var pluginManager: PluginManager { 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) diff --git a/Loop/Plugins/PluginManager.swift b/Loop/Plugins/PluginManager.swift index e7b9d3d8d2..fbc112b5b6 100644 --- a/Loop/Plugins/PluginManager.swift +++ b/Loop/Plugins/PluginManager.swift @@ -12,15 +12,12 @@ import LoopKit import LoopKitUI class PluginManager { - private let pluginBundles: [Bundle] - - public let availableSupports: [SupportUI] + let pluginBundles: [Bundle] private let log = OSLog(category: "PluginManager") public init(pluginsURL: URL? = Bundle.main.privateFrameworksURL) { var bundles = [Bundle]() - var availableSupports = [SupportUI]() if let pluginsURL = pluginsURL { do { @@ -29,17 +26,6 @@ class PluginManager { if bundle.isLoopPlugin && (!bundle.isSimulator || FeatureFlags.allowSimulators) { log.debug("Found loop plugin: %{public}@", pluginURL.absoluteString) bundles.append(bundle) - if bundle.isSupportPlugin { - if let support = try bundle.loadAndInstantiateSupport() { - availableSupports.append(support) - } - } - } - if bundle.isLoopExtension { - log.debug("Found loop extension: %{public}@", pluginURL.absoluteString) - if let support = try bundle.loadAndInstantiateSupport() { - availableSupports.append(support) - } } } } @@ -48,9 +34,10 @@ class PluginManager { } } self.pluginBundles = bundles - self.availableSupports = availableSupports } + + func getPumpManagerTypeByIdentifier(_ identifier: String) -> PumpManagerUI.Type? { for bundle in pluginBundles { if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.pumpManagerIdentifier.rawValue) as? String, name == identifier { @@ -230,15 +217,4 @@ 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 loadAndInstantiateSupport() throws -> SupportUI? { - try loadAndReturnError() - - guard let principalClass = principalClass as? NSObject.Type, - let supportUIPlugin = principalClass.init() as? SupportUIPlugin else { - return nil - } - - return supportUIPlugin.support - } } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 0af4bcea5a..da7072498b 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -33,6 +33,8 @@ final class StatusTableViewController: LoopChartsTableViewController { var onboardingManager: OnboardingManager! + var testingScenariosManager: TestingScenariosManager! + var automaticDosingStatus: AutomaticDosingStatus! var alertPermissionsChecker: AlertPermissionsChecker! @@ -1544,14 +1546,13 @@ final class StatusTableViewController: LoopChartsTableViewController { sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, initialDosingEnabled: deviceManager.loopManager.settings.dosingEnabled, isClosedLoopAllowed: automaticDosingStatus.$isAutomaticDosingAllowed, - supportInfoProvider: deviceManager, automaticDosingStrategy: deviceManager.loopManager.settings.automaticDosingStrategy, availableSupports: supportManager.availableSupports, isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceManager, delegate: self) let hostingController = DismissibleHostingController( - rootView: SettingsView(viewModel: viewModel) + rootView: SettingsView(viewModel: viewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) .environmentObject(deviceManager.displayGlucoseUnitObservable) .environment(\.appName, Bundle.main.bundleDisplayName), isModalInPresentation: false) @@ -1850,10 +1851,6 @@ final class StatusTableViewController: LoopChartsTableViewController { fatalError("\(#function) should be invoked only when scenarios are enabled") } - guard let testingScenariosManager = deviceManager.testingScenariosManager else { - return - } - let vc = TestingScenariosTableViewController(scenariosManager: testingScenariosManager) present(UINavigationController(rootViewController: vc), animated: true) } @@ -1956,11 +1953,11 @@ final class StatusTableViewController: LoopChartsTableViewController { } @objc private func stepActiveScenarioForward() { - deviceManager.testingScenariosManager?.stepActiveScenarioForward { _ in } + testingScenariosManager.stepActiveScenarioForward { _ in } } @objc private func stepActiveScenarioBackward() { - deviceManager.testingScenariosManager?.stepActiveScenarioBackward { _ in } + testingScenariosManager.stepActiveScenarioBackward { _ in } } } @@ -2148,12 +2145,12 @@ extension StatusTableViewController: SettingsViewModelDelegate { } } - func didTapIssueReport(title: String) { + func didTapIssueReport() { // 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) - vc.title = title + vc.title = NSLocalizedString("Issue Report", comment: "The view controller title for the issue report screen") self.show(vc, sender: nil) } } diff --git a/Loop/View Controllers/TestingScenariosTableViewController.swift b/Loop/View Controllers/TestingScenariosTableViewController.swift index d6ad2bb733..0d1ea136f7 100644 --- a/Loop/View Controllers/TestingScenariosTableViewController.swift +++ b/Loop/View Controllers/TestingScenariosTableViewController.swift @@ -165,7 +165,7 @@ final class TestingScenariosTableViewController: RadioSelectionTableViewControll extension TestingScenariosTableViewController: TestingScenariosManagerDelegate { func testingScenariosManager(_ manager: TestingScenariosManager, didUpdateScenarioURLs scenarioURLs: [URL]) { var filteredScenarios = Set() - manager.pluginManager.availableSupports.forEach { supportUI in + manager.supportManager.availableSupports.forEach { supportUI in supportUI.getScenarios(from: scenarioURLs).forEach { scenario in filteredScenarios.insert(scenario) } diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index c24876e260..3e3c20be24 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -52,7 +52,7 @@ public typealias PumpManagerViewModel = DeviceViewModel public protocol SettingsViewModelDelegate: AnyObject { func dosingEnabledChanged(_: Bool) func dosingStrategyChanged(_: AutomaticDosingStrategy) - func didTapIssueReport(title: String) + func didTapIssueReport() var closedLoopDescriptiveText: String? { get } } @@ -66,8 +66,8 @@ public class SettingsViewModel: ObservableObject { private weak var delegate: SettingsViewModelDelegate? - var didTapIssueReport: ((String) -> Void)? { - delegate?.didTapIssueReport + func didTapIssueReport() { + delegate?.didTapIssueReport() } var availableSupports: [SupportUI] @@ -77,7 +77,6 @@ public class SettingsViewModel: ObservableObject { let criticalEventLogExportViewModel: CriticalEventLogExportViewModel let therapySettings: () -> TherapySettings let sensitivityOverridesEnabled: Bool - let supportInfoProvider: SupportInfoProvider let isOnboardingComplete: Bool let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? @@ -113,7 +112,6 @@ public class SettingsViewModel: ObservableObject { sensitivityOverridesEnabled: Bool, initialDosingEnabled: Bool, isClosedLoopAllowed: Published.Publisher, - supportInfoProvider: SupportInfoProvider, automaticDosingStrategy: AutomaticDosingStrategy, availableSupports: [SupportUI], isOnboardingComplete: Bool, @@ -132,7 +130,6 @@ public class SettingsViewModel: ObservableObject { self.closedLoopPreference = initialDosingEnabled self.isClosedLoopAllowed = false self.automaticDosingStrategy = automaticDosingStrategy - self.supportInfoProvider = supportInfoProvider self.availableSupports = availableSupports self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate @@ -164,22 +161,6 @@ public class SettingsViewModel: ObservableObject { // For previews only extension SettingsViewModel { - fileprivate class MockSupportInfoProvider: SupportInfoProvider { - var localizedAppNameAndVersion = "Loop v1.2" - - var pumpStatus: PumpManagerStatus? { - return nil - } - - var cgmStatus: CGMManagerStatus? { - return nil - } - - func generateIssueReport(completion: (String) -> Void) { - completion("Mock Issue Report") - } - } - fileprivate class FakeClosedLoopAllowedPublisher { @Published var mockIsClosedLoopAllowed: Bool = false } @@ -196,7 +177,6 @@ extension SettingsViewModel { sensitivityOverridesEnabled: false, initialDosingEnabled: true, isClosedLoopAllowed: FakeClosedLoopAllowedPublisher().$mockIsClosedLoopAllowed, - supportInfoProvider: MockSupportInfoProvider(), automaticDosingStrategy: .automaticBolus, availableSupports: [], isOnboardingComplete: false, diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 22c10d48dd..dc3f6ec3c8 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -31,41 +31,73 @@ public struct SettingsView: View { @State private var deletePumpDataAlertIsPresented = false @State private var deleteCGMDataAlertIsPresented = false - public init(viewModel: SettingsViewModel) { + var localizedAppNameAndVersion: String + + public init(viewModel: SettingsViewModel, localizedAppNameAndVersion: String) { self.viewModel = viewModel self.versionUpdateViewModel = viewModel.versionUpdateViewModel + self.localizedAppNameAndVersion = localizedAppNameAndVersion } public var body: some View { NavigationView { List { - loopSection - if versionUpdateViewModel.softwareUpdateAvailable { - softwareUpdateSection - } - if FeatureFlags.automaticBolusEnabled { - dosingStrategySection - } - alertManagementSection - if viewModel.pumpManagerSettingsViewModel.isSetUp() { - configurationSection - } - deviceSettingsSection - if viewModel.pumpManagerSettingsViewModel.isTestingDevice || viewModel.cgmManagerSettingsViewModel.isTestingDevice { - deleteDataSection - } - if viewModel.servicesViewModel.showServices { - servicesSection + Group { + loopSection + if versionUpdateViewModel.softwareUpdateAvailable { + softwareUpdateSection + } + if FeatureFlags.automaticBolusEnabled { + dosingStrategySection + } + alertManagementSection + if viewModel.pumpManagerSettingsViewModel.isSetUp() { + configurationSection + } + deviceSettingsSection + if viewModel.pumpManagerSettingsViewModel.isTestingDevice || viewModel.cgmManagerSettingsViewModel.isTestingDevice { + deleteDataSection + } } - supportSection - if let profileExpiration = Bundle.main.profileExpiration, FeatureFlags.profileExpirationSettingsViewEnabled { - profileExpirationSection(profileExpiration: profileExpiration) + Group { + if viewModel.servicesViewModel.showServices { + servicesSection + } + + ForEach(customSections) { customSectionName in + menuItemsForSection(name: customSectionName) + } + + supportSection + + if let profileExpiration = Bundle.main.profileExpiration, FeatureFlags.profileExpirationSettingsViewEnabled { + profileExpirationSection(profileExpiration: profileExpiration) + } } } .insetGroupedListStyle() .navigationBarTitle(Text(NSLocalizedString("Settings", comment: "Settings screen title"))) .navigationBarItems(trailing: dismissButton) } + .navigationViewStyle(.stack) + } + + private func menuItemsForSection(name: String) -> some View { + Section(header: SectionHeader(label: name)) { + ForEach(pluginMenuItems.filter {$0.section.customLocalizedTitle == name}) { item in + item.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 { @@ -76,11 +108,19 @@ public struct SettingsView: View { } } -struct ConfigurationMenuItem: Identifiable { +extension String: Identifiable { + public typealias ID = Int + public var id: Int { + return hash + } +} + +struct PluginMenuItem: Identifiable { var id: String { return pluginIdentifier + String(describing: offset) } + let section: SettingsMenuSection let view: AnyView let pluginIdentifier: String let offset: Int @@ -95,7 +135,7 @@ extension SettingsView { } private var loopSection: some View { - Section(header: SectionHeader(label: viewModel.supportInfoProvider.localizedAppNameAndVersion)) { + Section(header: SectionHeader(label: localizedAppNameAndVersion)) { Toggle(isOn: closedLoopToggleState) { VStack(alignment: .leading) { Text("Closed Loop", comment: "The title text for the looping enabled switch cell") @@ -181,16 +221,16 @@ extension SettingsView { .environment(\.insulinTintColor, self.insulinTintColor) } - ForEach(configurationMenuItemsForSupportPlugins) { item in + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in item.view } } } - private var configurationMenuItemsForSupportPlugins: [ConfigurationMenuItem] { + private var pluginMenuItems: [PluginMenuItem] { self.viewModel.availableSupports.flatMap { plugin in - plugin.configurationMenuItems().enumerated().map { index, view in - ConfigurationMenuItem(view: view, pluginIdentifier: plugin.identifier, offset: index) + plugin.configurationMenuItems().enumerated().map { index, item in + PluginMenuItem(section: item.section, view: item.view, pluginIdentifier: plugin.identifier, offset: index) } } } @@ -336,12 +376,18 @@ extension SettingsView { private var supportSection: some View { Section(header: SectionHeader(label: NSLocalizedString("Support", comment: "The title of the support section in settings"))) { - NavigationLink(destination: SupportScreenView(didTapIssueReport: viewModel.didTapIssueReport, - criticalEventLogExportViewModel: viewModel.criticalEventLogExportViewModel, - availableSupports: self.viewModel.availableSupports, - supportInfoProvider: self.viewModel.supportInfoProvider)) - { - Text(NSLocalizedString("Support", comment: "The title of the support item in settings")) + Button(action: { + self.viewModel.didTapIssueReport() + }) { + Text("Issue Report", comment: "The title text for the issue report menu item") + } + + ForEach(pluginMenuItems.filter( { $0.section == .support })) { + $0.view + } + + NavigationLink(destination: CriticalEventLogExportView(viewModel: viewModel.criticalEventLogExportViewModel)) { + Text(NSLocalizedString("Export Critical Event Logs", comment: "The title of the export critical event logs in support")) } } } @@ -446,13 +492,13 @@ public struct SettingsView_Previews: PreviewProvider { let displayGlucoseUnitObservable = DisplayGlucoseUnitObservable(displayGlucoseUnit: .milligramsPerDeciliter) let viewModel = SettingsViewModel.preview return Group { - SettingsView(viewModel: viewModel) + SettingsView(viewModel: viewModel, localizedAppNameAndVersion: "Loop Demo V1") .colorScheme(.light) .previewDevice(PreviewDevice(rawValue: "iPhone SE 2")) .previewDisplayName("SE light") .environmentObject(displayGlucoseUnitObservable) - SettingsView(viewModel: viewModel) + SettingsView(viewModel: viewModel, localizedAppNameAndVersion: "Loop Demo V1") .colorScheme(.dark) .previewDevice(PreviewDevice(rawValue: "iPhone 11 Pro Max")) .previewDisplayName("11 Pro dark") diff --git a/Loop/Views/SupportScreenView.swift b/Loop/Views/SupportScreenView.swift deleted file mode 100644 index 1f66837b10..0000000000 --- a/Loop/Views/SupportScreenView.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// SupportScreenView.swift -// Loop -// -// Created by Rick Pasetto on 8/18/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import LoopKit -import LoopKitUI -import SwiftUI -import HealthKit - -struct SupportMenuItem: Identifiable { - var id: String - var menuItemView: AnyView -} - -struct SupportScreenView: View { - @Environment(\.dismissAction) private var dismiss - - var didTapIssueReport: ((_ title: String) -> Void)? - var criticalEventLogExportViewModel: CriticalEventLogExportViewModel - let availableSupports: [SupportUI] - let supportInfoProvider: SupportInfoProvider - - @State private var adverseEventReportURLInvalid = false - - var body: some View { - List { - Section { - Button(action: { - self.didTapIssueReport?(NSLocalizedString("Issue Report", comment: "The title text for the issue report menu item")) - }) { - Text("Issue Report", comment: "The title text for the issue report menu item") - } - - ForEach(supportMenuItems) { - $0.menuItemView - } - - NavigationLink(destination: CriticalEventLogExportView(viewModel: self.criticalEventLogExportViewModel)) { - Text(NSLocalizedString("Export Critical Event Logs", comment: "The title of the export critical event logs in support")) - } - } - } - .insetGroupedListStyle() - .navigationBarTitle(Text("Support", comment: "Support screen title")) - } - - func openURL(_ url: URL) { - UIApplication.shared.open(url) - } - - var supportMenuItems: [SupportMenuItem] { - return availableSupports.compactMap { (support) -> SupportMenuItem? in - if let view = support.supportMenuItem(supportInfoProvider: supportInfoProvider, urlHandler: openURL) { - return SupportMenuItem(id: support.identifier, menuItemView: view) - } else { - return nil - } - } - } -} - -struct SupportScreenView_Previews: PreviewProvider { - class MockSupportInfoProvider: SupportInfoProvider { - - var localizedAppNameAndVersion = "Loop v1.2" - - var pumpStatus: PumpManagerStatus? = nil - - var cgmStatus: CGMManagerStatus? = nil - - func generateIssueReport(completion: (String) -> Void) { - completion("Mock Issue Report") - } - } - - static var previews: some View { - SupportScreenView(criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: MockCriticalEventLogExporterFactory()), - availableSupports: [], supportInfoProvider: MockSupportInfoProvider()) - } -} diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift index 31e4cc80fc..40e45612a2 100644 --- a/LoopCore/NSUserDefaults.swift +++ b/LoopCore/NSUserDefaults.swift @@ -21,6 +21,7 @@ extension UserDefaults { case allowDebugFeatures = "com.loopkit.Loop.allowDebugFeatures" case allowSimulators = "com.loopkit.Loop.allowSimulators" case LastMissedMealNotification = "com.loopkit.Loop.lastMissedMealNotification" + case userRequestedLoopReset = "com.loopkit.Loop.userRequestedLoopReset" } public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) @@ -150,6 +151,15 @@ extension UserDefaults { public var allowSimulators: Bool { return bool(forKey: Key.allowSimulators.rawValue) } + + public var userRequestedLoopReset: Bool { + get { + bool(forKey: Key.userRequestedLoopReset.rawValue) + } + set { + setValue(newValue, forKey: Key.userRequestedLoopReset.rawValue) + } + } public func removeLegacyLoopSettings() { removeObject(forKey: "com.loudnate.Naterade.BasalRateSchedule") diff --git a/LoopTests/Managers/SupportManagerTests.swift b/LoopTests/Managers/SupportManagerTests.swift index d1fd0d5163..f245549573 100644 --- a/LoopTests/Managers/SupportManagerTests.swift +++ b/LoopTests/Managers/SupportManagerTests.swift @@ -34,28 +34,29 @@ class SupportManagerTests: XCTestCase { weak var delegate: SupportUIDelegate? } class MockSupport: Mixin, SupportUI { - func configurationMenuItems() -> [AnyView] { return [] } static var supportIdentifier: String { "SupportManagerTestsMockSupport" } override init() { super.init() } required init?(rawState: RawStateValue) { super.init() } var rawState: RawStateValue = [:] - var loopNeedsReset: Bool = false - var studyProductSelection: String? = nil func getScenarios(from scenarioURLs: [URL]) -> [LoopScenario] { [] } - func resetLoop() {} + func loopWillReset() {} + func loopDidReset() {} + func initializationComplete(for services: [LoopKit.Service]) {} + func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { return [] } } + class AnotherMockSupport: Mixin, SupportUI { - func configurationMenuItems() -> [AnyView] { return [] } static var supportIdentifier: String { "SupportManagerTestsAnotherMockSupport" } override init() { super.init() } required init?(rawState: RawStateValue) { super.init() } var rawState: RawStateValue = [:] - var loopNeedsReset: Bool = false - var studyProductSelection: String? = nil func getScenarios(from scenarioURLs: [URL]) -> [LoopScenario] { [] } - func resetLoop() {} + func loopWillReset() {} + func loopDidReset() {} + func initializationComplete(for services: [LoopKit.Service]) {} + func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { return [] } } class MockAlertIssuer: AlertIssuer { @@ -65,14 +66,29 @@ class SupportManagerTests: XCTestCase { func retractAlert(identifier: LoopKit.Alert.Identifier) { } } + + 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") + } + } var supportManager: SupportManager! var mockSupport: SupportManagerTests.MockSupport! var mockAlertIssuer: MockAlertIssuer! + var pluginManager = PluginManager() + var mocKDeviceSupportDelegate = MockDeviceSupportDelegate() + override func setUp() { mockAlertIssuer = MockAlertIssuer() - supportManager = SupportManager(staticSupportTypes: [], alertIssuer: mockAlertIssuer) + supportManager = SupportManager(pluginManager: pluginManager, deviceSupportDelegate: mocKDeviceSupportDelegate, staticSupportTypes: [], alertIssuer: mockAlertIssuer) mockSupport = SupportManagerTests.MockSupport() supportManager.addSupport(mockSupport) }