From 11bad1f13ebb874a540bf9c92aaed2d598139585 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 4 Aug 2022 13:25:56 -0300 Subject: [PATCH 01/14] [LOOP-4205] always display time changed alert when detected. retract when needed (#523) --- Loop/Managers/TrustedTimeChecker.swift | 33 ++++++++++---------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/Loop/Managers/TrustedTimeChecker.swift b/Loop/Managers/TrustedTimeChecker.swift index 5a506a48f7..f06054eb8c 100644 --- a/Loop/Managers/TrustedTimeChecker.swift +++ b/Loop/Managers/TrustedTimeChecker.swift @@ -12,19 +12,9 @@ import UIKit fileprivate extension UserDefaults { private enum Key: String { - case lastSignificantTimeChangeAlert = "com.loopkit.Loop.LastSignificantTimeChangeAlert" case detectedSystemTimeOffset = "com.loopkit.Loop.DetectedSystemTimeOffset" } - var lastSignificantTimeChangeAlert: Date? { - get { - return object(forKey: Key.lastSignificantTimeChangeAlert.rawValue) as? Date - } - set { - set(newValue, forKey: Key.lastSignificantTimeChangeAlert.rawValue) - } - } - var detectedSystemTimeOffset: TimeInterval? { get { return object(forKey: Key.detectedSystemTimeOffset.rawValue) as? TimeInterval @@ -37,7 +27,6 @@ fileprivate extension UserDefaults { class TrustedTimeChecker { private let acceptableTimeDelta = TimeInterval.seconds(120) - private let minimumAlertFrequency = TimeInterval.minutes(30) // For NTP time checking private var ntpClient: TrueTimeClient @@ -60,10 +49,11 @@ class TrustedTimeChecker { ntpClient.start() self.alertManager = alertManager self.detectedSystemTimeOffset = UserDefaults.standard.detectedSystemTimeOffset ?? 0 - NotificationCenter.default.addObserver(forName: UIApplication.significantTimeChangeNotification, + NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in self?.checkTrustedTime() } NotificationCenter.default.addObserver(forName: .LoopRunning, object: nil, queue: nil) { [weak self] _ in self?.checkTrustedTime() } + checkTrustedTime() } private func checkTrustedTime() { @@ -78,16 +68,10 @@ class TrustedTimeChecker { if abs(timeDelta) > self.acceptableTimeDelta { self.log.default("applicationSignificantTimeChange: ntpNow = %@, deviceNow = %@", ntpNow.debugDescription, deviceNow.debugDescription) self.detectedSystemTimeOffset = timeDelta - let timeSinceLastAlert = abs(ntpNow.timeIntervalSince(UserDefaults.standard.lastSignificantTimeChangeAlert ?? Date.distantPast)) - - if timeSinceLastAlert > self.minimumAlertFrequency { - self.issueTimeChangedAlert() - UserDefaults.standard.lastSignificantTimeChangeAlert = ntpNow - } + self.issueTimeChangedAlert() } else { self.detectedSystemTimeOffset = 0 - // reset the last time the alert was issued, since the device time is now considered aligned. - UserDefaults.standard.lastSignificantTimeChangeAlert = nil + self.retractTimeChangedAlert() } case let .failure(error): self.log.error("applicationSignificantTimeChange: Error getting NTP time: %@", error.localizedDescription) @@ -95,11 +79,18 @@ class TrustedTimeChecker { }) } + private var alertIdentifier: Alert.Identifier { + Alert.Identifier(managerIdentifier: "Loop", alertIdentifier: "significantTimeChange") + } + private func issueTimeChangedAlert() { - let alertIdentifier = Alert.Identifier(managerIdentifier: "Loop", alertIdentifier: "significantTimeChange") let alertTitle = NSLocalizedString("Time Change Detected", comment: "Time change alert title") let alertBody = String(format: NSLocalizedString("Your phone’s time has been changed. %1$@ needs accurate time records to make predictions about your glucose and adjust your insulin accordingly.\n\nCheck in your iPhone Settings (General / Date & Time) and verify that Set Automatically is enabled. Failure to resolve could lead to serious under-delivery or over-delivery of insulin.", comment: "Time change alert body. (1: app name)"), Bundle.main.bundleDisplayName) let content = Alert.Content(title: alertTitle, body: alertBody, acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Alert acknowledgment OK button")) alertManager?.issueAlert(Alert(identifier: alertIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate)) } + + private func retractTimeChangedAlert() { + alertManager?.retractAlert(identifier: alertIdentifier) + } } From e0cf42ec642861caca16f3194089eea1627bfdd0 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 5 Aug 2022 09:16:34 -0500 Subject: [PATCH 02/14] Adjust method to match updated protocol (#524) --- Loop/Managers/DeviceDataManager.swift | 40 +++++++++------------------ 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 662bb67c25..051efb828d 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1614,33 +1614,19 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { } } - func saveCompletion(for therapySetting: TherapySetting, therapySettings: TherapySettings) { - switch therapySetting { - case .glucoseTargetRange: - loopManager.mutateSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule } - case .preMealCorrectionRangeOverride: - loopManager.mutateSettings { settings in settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal } - case .workoutCorrectionRangeOverride: - loopManager.mutateSettings { settings in settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout } - case .suspendThreshold: - loopManager.mutateSettings { settings in settings.suspendThreshold = therapySettings.suspendThreshold } - case .basalRate: - loopManager.mutateSettings { settings in settings.basalRateSchedule = therapySettings.basalRateSchedule } - case .deliveryLimits: - loopManager.mutateSettings { settings in - settings.maximumBasalRatePerHour = therapySettings.maximumBasalRatePerHour - settings.maximumBolus = therapySettings.maximumBolus - } - case .insulinModel: - if let defaultRapidActingModel = therapySettings.defaultRapidActingModel { - loopManager.mutateSettings { settings in settings.defaultRapidActingModel = defaultRapidActingModel } - } - case .carbRatio: - loopManager.mutateSettings { settings in settings.carbRatioSchedule = therapySettings.carbRatioSchedule } - case .insulinSensitivity: - loopManager.mutateSettings { settings in settings.insulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule } - case .none: - break // NO-OP + func saveCompletion(therapySettings: TherapySettings) { + + loopManager.mutateSettings { settings in + settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule + settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal + settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout + settings.suspendThreshold = therapySettings.suspendThreshold + settings.basalRateSchedule = therapySettings.basalRateSchedule + settings.maximumBasalRatePerHour = therapySettings.maximumBasalRatePerHour + settings.maximumBolus = therapySettings.maximumBolus + settings.defaultRapidActingModel = therapySettings.defaultRapidActingModel + settings.carbRatioSchedule = therapySettings.carbRatioSchedule + settings.insulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule } } From e0f08efa8443ab269885a0e6a190afa9c73afee1 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Wed, 14 Sep 2022 05:11:49 -0300 Subject: [PATCH 03/14] [LOOP-4289-4348] Critical alert modal and banner update (#525) * initial pass at adding go to settings button in the critical alert modal * remove foreground content from the riskMitigationAlert, since it is handle by the AlertManager * added placeholder text for the alert premissions disabled banner * add dismiss button to alert permissions disabled warning * acknowledge alert with close option * response to PR comments * clean up * more cleanup * updated notification permissions banner * updated style of alert permission disabled alert * copy update --- Loop/DefaultAssets.xcassets/Contents.json | 6 +- .../Contents.json | 12 ++ .../notification-permissions-on.png | Bin 0 -> 90433 bytes Loop/Managers/AlertPermissionsChecker.swift | 165 ++++++++---------- Loop/Managers/Alerts/AlertManager.swift | 83 ++++++++- Loop/Managers/LoopAppManager.swift | 4 +- .../StatusTableViewController.swift | 31 +++- ...icationsCriticalAlertPermissionsView.swift | 2 +- LoopUI/Extensions/UIColor.swift | 2 +- 9 files changed, 197 insertions(+), 108 deletions(-) create mode 100644 Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/notification-permissions-on.png diff --git a/Loop/DefaultAssets.xcassets/Contents.json b/Loop/DefaultAssets.xcassets/Contents.json index da4a164c91..73c00596a7 100644 --- a/Loop/DefaultAssets.xcassets/Contents.json +++ b/Loop/DefaultAssets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/Contents.json b/Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/Contents.json new file mode 100644 index 0000000000..af599e5617 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "notification-permissions-on.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/notification-permissions-on.png b/Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/notification-permissions-on.png new file mode 100644 index 0000000000000000000000000000000000000000..9d9804f4a96a4a9807b59f8ba8a075364cc1812b GIT binary patch literal 90433 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 literal 0 HcmV?d00001 diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index 3ea5c0e065..508aa90ef9 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -11,26 +11,28 @@ import Combine import LoopKit import SwiftUI +protocol AlertPermissionsCheckerDelegate: AnyObject { + func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool) +} + public class AlertPermissionsChecker: ObservableObject { - private weak var alertManager: AlertManager? - private var isAppInBackground: Bool { return UIApplication.shared.applicationState == UIApplication.State.background } - + private lazy var cancellables = Set() private var listeningToNotificationCenter = false @Published var notificationCenterSettings: NotificationCenterSettingsFlags = .none - + var showWarning: Bool { notificationCenterSettings.requiresRiskMitigation } - - init(alertManager: AlertManager? = nil) { - self.alertManager = alertManager - + + weak var delegate: AlertPermissionsCheckerDelegate? + + init() { // Check on loop complete, but only while in the background. NotificationCenter.default.publisher(for: .LoopCompleted) .receive(on: RunLoop.main) @@ -41,7 +43,7 @@ public class AlertPermissionsChecker: ObservableObject { } } .store(in: &cancellables) - + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) .sink { [weak self] _ in self?.check() @@ -54,7 +56,7 @@ public class AlertPermissionsChecker: ObservableObject { } .store(in: &cancellables) } - + func checkNow() { check { // Note: we do this, instead of calling notificationCenterSettingsChanged directly, so that we only @@ -62,7 +64,7 @@ public class AlertPermissionsChecker: ObservableObject { self.listenToNotificationCenter() } } - + private func check(then completion: (() -> Void)? = nil) { UNUserNotificationCenter.current().getNotificationSettings { settings in DispatchQueue.main.async { @@ -81,13 +83,17 @@ public class AlertPermissionsChecker: ObservableObject { } } - func gotoSettings() { - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + static func gotoSettings() { + // TODO with iOS 16 this API changes to UIApplication.openNotificationSettingsURLString + if #available(iOS 15.4, *) { + UIApplication.shared.open(URL(string: UIApplicationOpenNotificationSettingsURLString)!) + } else { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } } } -fileprivate extension AlertPermissionsChecker { - +extension AlertPermissionsChecker { private func listenToNotificationCenter() { if !listeningToNotificationCenter { $notificationCenterSettings @@ -98,53 +104,59 @@ fileprivate extension AlertPermissionsChecker { listeningToNotificationCenter = true } } - - private func notificationCenterSettingsChanged(_ newValue: NotificationCenterSettingsFlags) { - if !issueOrRetract(alert: Self.riskMitigatingAlert, - condition: newValue.requiresRiskMitigation, - alreadyIssued: UserDefaults.standard.hasIssuedRiskMitigatingAlert, - setAlreadyIssued: {UserDefaults.standard.hasIssuedRiskMitigatingAlert = $0}) { - _ = issueOrRetract(alert: Self.scheduledDeliveryEnabledAlert, - condition: newValue.scheduledDeliveryEnabled, - alreadyIssued: UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert, - setAlreadyIssued: {UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0}) - } - } - - private func issueOrRetract(alert: LoopKit.Alert, condition: Bool, alreadyIssued: Bool, setAlreadyIssued: (Bool) -> Void) -> Bool { - if condition { - if !alreadyIssued { - alertManager?.issueAlert(alert) - setAlreadyIssued(true) - } - return true - } else { - if alreadyIssued { - setAlreadyIssued(false) - alertManager?.retractAlert(identifier: alert.identifier) - } - return false - } - } -} -fileprivate extension AlertPermissionsChecker { - // MARK: Risk Mitigating Alert - private static let riskMitigatingAlertIdentifier = Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "riskMitigatingAlert") - private static let riskMitigatingAlertContent = Alert.Content( - title: NSLocalizedString("Alert Permissions Need Attention", + static let unsafeNotificationPermissionsAlertIdentifier = Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeNotificationPermissionsAlert") + + private static let unsafeNotificationPermissionsAlertContent = Alert.Content( + title: NSLocalizedString("Warning! Safety notifications are turned OFF", comment: "Alert Permissions Need Attention alert title"), - body: String(format: NSLocalizedString("It is important that you always keep %1$@ Notifications, Critical Alerts, and Time Sensitive Notifications turned ON in your phone’s settings to ensure that you get notified by the app.", + body: String(format: NSLocalizedString("You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Notifications, Critical Alerts and Time Sensitive Notifications are turned ON.", comment: "Format for Notifications permissions disabled alert body. (1: app name)"), Bundle.main.bundleDisplayName), acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Notifications permissions disabled alert button") ) - private static let riskMitigatingAlert = Alert(identifier: riskMitigatingAlertIdentifier, - foregroundContent: riskMitigatingAlertContent, - backgroundContent: riskMitigatingAlertContent, - trigger: .immediate) - + + static let unsafeNotificationPermissionsAlert = Alert(identifier: unsafeNotificationPermissionsAlertIdentifier, + foregroundContent: nil, + backgroundContent: unsafeNotificationPermissionsAlertContent, + trigger: .immediate) + + static func constructUnsafeNotificationPermissionsInAppAlert(acknowledgementCompletion: @escaping () -> Void ) -> UIAlertController { + dispatchPrecondition(condition: .onQueue(.main)) + let alertController = UIAlertController(title: Self.unsafeNotificationPermissionsAlertContent.title, + message: Self.unsafeNotificationPermissionsAlertContent.body, + preferredStyle: .alert) + let titleImageAttachment = NSTextAttachment() + titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.critical) + titleImageAttachment.bounds = CGRect(x: titleImageAttachment.bounds.origin.x, y: -10, width: 40, height: 35) + let titleWithImage = NSMutableAttributedString(attachment: titleImageAttachment) + titleWithImage.append(NSMutableAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: 8)])) + titleWithImage.append(NSMutableAttributedString(string: Self.unsafeNotificationPermissionsAlertContent.title, attributes: [.font: UIFont.preferredFont(forTextStyle: .headline)])) + alertController.setValue(titleWithImage, forKey: "attributedTitle") + + let messageImageAttachment = NSTextAttachment() + messageImageAttachment.image = UIImage(named: "notification-permissions-on") + messageImageAttachment.bounds = CGRect(x: 0, y: -12, width: 228, height: 126) + let messageWithImageAttributed = NSMutableAttributedString(string: "\n", attributes: [.font: UIFont.systemFont(ofSize: 8)]) + messageWithImageAttributed.append(NSMutableAttributedString(string: Self.unsafeNotificationPermissionsAlertContent.body, attributes: [.font: UIFont.preferredFont(forTextStyle: .footnote)])) + messageWithImageAttributed.append(NSMutableAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: 12)])) + messageWithImageAttributed.append(NSMutableAttributedString(attachment: messageImageAttachment)) + alertController.setValue(messageWithImageAttributed, forKey: "attributedMessage") + + alertController.addAction(UIAlertAction(title: NSLocalizedString("Settings", comment: "Label of button that navigation user to iOS Settings"), + style: .default, + handler: { _ in + AlertPermissionsChecker.gotoSettings() + acknowledgementCompletion() + })) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Close", comment: "The button label of the action used to dismiss the risk mitigation alert"), + style: .cancel, + handler: { _ in acknowledgementCompletion() + })) + return alertController + } + // MARK: Scheduled Delivery Enabled Alert private static let scheduledDeliveryEnabledAlertIdentifier = Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "scheduledDeliveryEnabledAlert") @@ -152,43 +164,21 @@ fileprivate extension AlertPermissionsChecker { title: NSLocalizedString("Notifications Delayed", comment: "Scheduled Delivery Enabled alert title"), body: String(format: NSLocalizedString(""" - Notification delivery is set to Scheduled Summary in your phone’s settings. - - To avoid delay in receiving notifications from %1$@, we recommend notification delivery be set to Immediate Delivery. - """, + Notification delivery is set to Scheduled Summary in your phone’s settings. + + To avoid delay in receiving notifications from %1$@, we recommend notification delivery be set to Immediate Delivery. + """, comment: "Format for Critical Alerts permissions disabled alert body. (1: app name)"), Bundle.main.bundleDisplayName), acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Critical Alert permissions disabled alert button") ) - private static let scheduledDeliveryEnabledAlert = Alert(identifier: scheduledDeliveryEnabledAlertIdentifier, - foregroundContent: scheduledDeliveryEnabledAlertContent, - backgroundContent: scheduledDeliveryEnabledAlertContent, - trigger: .immediate) -} - -fileprivate extension UserDefaults { - - private enum Key: String { - case hasIssuedRiskMitigatingAlert = "com.loopkit.Loop.HasIssuedRiskMitigatingAlert" - case hasIssuedScheduledDeliveryEnabledAlert = "com.loopkit.Loop.HasIssuedScheduledDeliveryEnabledAlert" - } - - var hasIssuedRiskMitigatingAlert: Bool { - get { - return object(forKey: Key.hasIssuedRiskMitigatingAlert.rawValue) as? Bool ?? false - } - set { - set(newValue, forKey: Key.hasIssuedRiskMitigatingAlert.rawValue) - } - } + static let scheduledDeliveryEnabledAlert = Alert(identifier: scheduledDeliveryEnabledAlertIdentifier, + foregroundContent: scheduledDeliveryEnabledAlertContent, + backgroundContent: scheduledDeliveryEnabledAlertContent, + trigger: .immediate) - var hasIssuedScheduledDeliveryEnabledAlert: Bool { - get { - return object(forKey: Key.hasIssuedScheduledDeliveryEnabledAlert.rawValue) as? Bool ?? false - } - set { - set(newValue, forKey: Key.hasIssuedScheduledDeliveryEnabledAlert.rawValue) - } + private func notificationCenterSettingsChanged(_ newValue: NotificationCenterSettingsFlags) { + delegate?.notificationsPermissions(requiresRiskMitigation: newValue.requiresRiskMitigation, scheduledDeliveryEnabled: newValue.scheduledDeliveryEnabled) } } @@ -251,4 +241,3 @@ fileprivate extension OptionSet { } } } - diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index d3cdb7383f..0b769bf6cc 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -82,7 +82,6 @@ public final class AlertManager { self?.loopDidComplete() } .store(in: &cancellables) - } public func addAlertResponder(managerIdentifier: String, alertResponder: AlertResponder) { @@ -131,10 +130,10 @@ public final class AlertManager { body: fgBody, acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal")) issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, - foregroundContent: fgcontent, - backgroundContent: bgcontent, - trigger: .immediate, - interruptionLevel: .critical)) + foregroundContent: fgcontent, + backgroundContent: bgcontent, + trigger: .immediate, + interruptionLevel: .critical)) } // MARK: - Loop Not Running alerts @@ -584,3 +583,77 @@ extension AlertManager: PresetActivationObserver { } } } + +// MARK: - Issue/Retract Alert Permissions Warning +extension AlertManager: AlertPermissionsCheckerDelegate { + func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool) { + if !issueOrRetract(alert: AlertPermissionsChecker.unsafeNotificationPermissionsAlert, + condition: requiresRiskMitigation, + alreadyIssued: UserDefaults.standard.hasIssuedNotificationPermissionsAlert, + setAlreadyIssued: { UserDefaults.standard.hasIssuedNotificationPermissionsAlert = $0 }, + issueHandler: { alert in + // the risk mitigation in-app alert is presented with a button to navigate to settings + self.recordIssued(alert: alert) + let alertController = AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert() { [weak self] in + self?.acknowledgeAlert(identifier: AlertPermissionsChecker.unsafeNotificationPermissionsAlertIdentifier) + } + self.alertPresenter.present(alertController, animated: true) + }) { + _ = issueOrRetract(alert: AlertPermissionsChecker.scheduledDeliveryEnabledAlert, + condition: scheduledDeliveryEnabled, + alreadyIssued: UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert, + setAlreadyIssued: { UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0 }, issueHandler: { alert in self.issueAlert(alert) }) + } + } + + private func issueOrRetract(alert: LoopKit.Alert, + condition: Bool, + alreadyIssued: Bool, + setAlreadyIssued: (Bool) -> Void, + issueHandler: @escaping (LoopKit.Alert) -> Void) -> Bool { + if condition { + if !alreadyIssued { + issueHandler(alert) + setAlreadyIssued(true) + } + return true + } else { + if alreadyIssued { + setAlreadyIssued(false) + retractAlert(identifier: alert.identifier) + } + return false + } + } +} + +fileprivate extension AlertManager { + private var isAppInBackground: Bool { + return UIApplication.shared.applicationState == UIApplication.State.background + } +} + +fileprivate extension UserDefaults { + private enum Key: String { + case hasIssuedNotificationPermissionsAlert = "com.loopkit.Loop.HasIssuedNotificationPermissionsAlert" + case hasIssuedScheduledDeliveryEnabledAlert = "com.loopkit.Loop.HasIssuedScheduledDeliveryEnabledAlert" + } + + var hasIssuedNotificationPermissionsAlert: Bool { + get { + return object(forKey: Key.hasIssuedNotificationPermissionsAlert.rawValue) as? Bool ?? false + } + set { + set(newValue, forKey: Key.hasIssuedNotificationPermissionsAlert.rawValue) + } + } + + var hasIssuedScheduledDeliveryEnabledAlert: Bool { + get { + return object(forKey: Key.hasIssuedScheduledDeliveryEnabledAlert.rawValue) as? Bool ?? false + } + set { + set(newValue, forKey: Key.hasIssuedScheduledDeliveryEnabledAlert.rawValue) + } + } +} diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 5833477ebb..ce82dfbb42 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -160,7 +160,9 @@ class LoopAppManager: NSObject { expireAfter: Bundle.main.localCacheDuration, bluetoothProvider: bluetoothStateManager) - self.alertPermissionsChecker = AlertPermissionsChecker(alertManager: alertManager) + self.alertPermissionsChecker = AlertPermissionsChecker() + self.alertPermissionsChecker.delegate = alertManager + self.trustedTimeChecker = TrustedTimeChecker(alertManager: alertManager) self.settingsManager = SettingsManager(cacheStore: cacheStore, diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 144dcbda81..4d6b499dac 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -827,19 +827,32 @@ final class StatusTableViewController: LoopChartsTableViewController { override func updateConfiguration(using state: UICellConfigurationState) { super.updateConfiguration(using: state) - let content = NSLocalizedString("Review Alert Permissions", comment: "Warning text for when Notifications or Critical Alerts Permissions is disabled") + var contentConfig = defaultContentConfiguration().updated(for: state) - contentConfig.text = content - contentConfig.textProperties.color = .red - contentConfig.textProperties.font = .systemFont(ofSize: 15, weight: .semibold) + let titleImageAttachment = NSTextAttachment() + titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.white) + let title = NSMutableAttributedString(string: NSLocalizedString(" Safety Notifications are OFF", comment: "Warning text for when Notifications or Critical Alerts Permissions is disabled")) + let titleWithImage = NSMutableAttributedString(attachment: titleImageAttachment) + titleWithImage.append(title) + contentConfig.attributedText = titleWithImage + contentConfig.textProperties.color = .white + contentConfig.textProperties.font = .systemFont(ofSize: 18, weight: .bold) contentConfig.textProperties.adjustsFontSizeToFitWidth = true - contentConfig.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.red) - contentConfig.imageProperties.tintColor = .red + contentConfig.secondaryText = "Fix now by turning Notifications, Critical Alerts and Time Sensitive Notifications ON." + contentConfig.secondaryTextProperties.color = .white + contentConfig.secondaryTextProperties.font = .systemFont(ofSize: 15) contentConfiguration = contentConfig + var backgroundConfig = backgroundConfiguration?.updated(for: state) - backgroundConfig?.backgroundColor = .secondarySystemBackground + backgroundConfig?.backgroundColor = .critical backgroundConfiguration = backgroundConfig - accessoryType = .disclosureIndicator + backgroundConfiguration?.backgroundInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10) + backgroundConfiguration?.cornerRadius = 10 + + let disclosureIndicator = UIImage(systemName: "chevron.right")?.withTintColor(.white) + let imageView = UIImageView(image: disclosureIndicator) + imageView.tintColor = .white + accessoryView = imageView } } @@ -1071,7 +1084,7 @@ final class StatusTableViewController: LoopChartsTableViewController { switch Section(rawValue: indexPath.section)! { case .alertPermissionsDisabledWarning: tableView.deselectRow(at: indexPath, animated: true) - presentSettings() + AlertPermissionsChecker.gotoSettings() case .hud: break case .status: diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index ea56d5a53b..79eb9bd4e8 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -87,7 +87,7 @@ extension NotificationsCriticalAlertPermissionsView { } private var manageNotifications: some View { - Button( action: { self.checker.gotoSettings() } ) { + Button( action: { AlertPermissionsChecker.gotoSettings() } ) { HStack { Text(NSLocalizedString("Manage Permissions in Settings", comment: "Manage Permissions in Settings button text")) Spacer() diff --git a/LoopUI/Extensions/UIColor.swift b/LoopUI/Extensions/UIColor.swift index e22c66ce87..db638084f8 100644 --- a/LoopUI/Extensions/UIColor.swift +++ b/LoopUI/Extensions/UIColor.swift @@ -36,7 +36,7 @@ extension UIColor { @nonobjc public static let carbTintColor = carbs - @nonobjc static let critical = systemRed + @nonobjc public static let critical = systemRed @nonobjc public static let destructive = critical From a1f73a04973ec1fcf7a5feac7290d4a4962b93a9 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 20 Sep 2022 11:24:55 -0300 Subject: [PATCH 04/14] [LOOP-4348] adjusted layout of notification permissions disabled banner (#527) --- Loop/View Controllers/StatusTableViewController.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 4d6b499dac..9f02789b69 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -853,6 +853,8 @@ final class StatusTableViewController: LoopChartsTableViewController { let imageView = UIImageView(image: disclosureIndicator) imageView.tintColor = .white accessoryView = imageView + + contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 13, trailing: 0) } } From aee88482c6abdda9d78a9b82331cc2e5cdbd035f Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 22 Sep 2022 07:57:40 -0300 Subject: [PATCH 05/14] [LOOP-4289] Also issue iOS notification (#528) * issue notification permission alert when in background and retract in-app alert as needed * remove unused code * response to PR comments --- Loop/Managers/AlertPermissionsChecker.swift | 4 +- Loop/Managers/Alerts/AlertManager.swift | 51 ++++++++++++++++----- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index 508aa90ef9..bae4512e6a 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -105,7 +105,7 @@ extension AlertPermissionsChecker { } } - // MARK: Risk Mitigating Alert + // MARK: Unsafe Notification Permissions Alert static let unsafeNotificationPermissionsAlertIdentifier = Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeNotificationPermissionsAlert") private static let unsafeNotificationPermissionsAlertContent = Alert.Content( @@ -150,7 +150,7 @@ extension AlertPermissionsChecker { AlertPermissionsChecker.gotoSettings() acknowledgementCompletion() })) - alertController.addAction(UIAlertAction(title: NSLocalizedString("Close", comment: "The button label of the action used to dismiss the risk mitigation alert"), + alertController.addAction(UIAlertAction(title: NSLocalizedString("Close", comment: "The button label of the action used to dismiss the unsafe notification permission alert"), style: .cancel, handler: { _ in acknowledgementCompletion() })) diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 0b769bf6cc..0c131451b6 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -40,6 +40,7 @@ public final class AlertManager { private var modalAlertIssuer: AlertIssuer! private var userNotificationAlertIssuer: AlertIssuer? + private var unsafeNotificationPermissionsAlertController: UIAlertController? let alertStore: AlertStore @@ -313,6 +314,12 @@ extension AlertManager: AlertIssuer { } private func replayAlert(_ alert: Alert) { + guard alert.identifier != AlertPermissionsChecker.unsafeNotificationPermissionsAlertIdentifier else { + // this alert does not replay through the alert system, since it provides a button to navigate to settings + presentUnsafeNotificationPermissionsInAppAlert() + return + } + // Only alerts with foreground content are replayed if alert.foregroundContent != nil { modalAlertIssuer?.issueAlert(alert) @@ -592,17 +599,22 @@ extension AlertManager: AlertPermissionsCheckerDelegate { alreadyIssued: UserDefaults.standard.hasIssuedNotificationPermissionsAlert, setAlreadyIssued: { UserDefaults.standard.hasIssuedNotificationPermissionsAlert = $0 }, issueHandler: { alert in - // the risk mitigation in-app alert is presented with a button to navigate to settings + // in-app modal is presented with a button to navigate to settings + self.presentUnsafeNotificationPermissionsInAppAlert() + self.userNotificationAlertIssuer?.issueAlert(alert) self.recordIssued(alert: alert) - let alertController = AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert() { [weak self] in - self?.acknowledgeAlert(identifier: AlertPermissionsChecker.unsafeNotificationPermissionsAlertIdentifier) - } - self.alertPresenter.present(alertController, animated: true) + }, + retractionHandler: { alert in + // need to dismiss the in-app alert outside of the alert system + self.recordRetractedAlert(alert, at: Date()) + self.dismissUnsafeNotificationPermissionsInAppAlert() }) { _ = issueOrRetract(alert: AlertPermissionsChecker.scheduledDeliveryEnabledAlert, condition: scheduledDeliveryEnabled, alreadyIssued: UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert, - setAlreadyIssued: { UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0 }, issueHandler: { alert in self.issueAlert(alert) }) + setAlreadyIssued: { UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0 }, + issueHandler: { alert in self.issueAlert(alert) }, + retractionHandler: { alert in self.retractAlert(identifier: alert.identifier) }) } } @@ -610,7 +622,9 @@ extension AlertManager: AlertPermissionsCheckerDelegate { condition: Bool, alreadyIssued: Bool, setAlreadyIssued: (Bool) -> Void, - issueHandler: @escaping (LoopKit.Alert) -> Void) -> Bool { + issueHandler: @escaping (LoopKit.Alert) -> Void, + retractionHandler: @escaping (LoopKit.Alert) -> Void) -> Bool { + if condition { if !alreadyIssued { issueHandler(alert) @@ -620,16 +634,29 @@ extension AlertManager: AlertPermissionsCheckerDelegate { } else { if alreadyIssued { setAlreadyIssued(false) - retractAlert(identifier: alert.identifier) + retractionHandler(alert) } return false } } -} -fileprivate extension AlertManager { - private var isAppInBackground: Bool { - return UIApplication.shared.applicationState == UIApplication.State.background + private func presentUnsafeNotificationPermissionsInAppAlert() { + DispatchQueue.main.async { + let alertController = AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert() { [weak self] in + self?.acknowledgeAlert(identifier: AlertPermissionsChecker.unsafeNotificationPermissionsAlertIdentifier) + } + self.alertPresenter.present(alertController, animated: true) { [weak self] in + // the completion is called after the alert is presented + self?.unsafeNotificationPermissionsAlertController = alertController + } + } + } + + private func dismissUnsafeNotificationPermissionsInAppAlert() { + guard let alertController = unsafeNotificationPermissionsAlertController else { return } + alertPresenter.dismissAlert(alertController, animated: true) { [weak self] in + self?.unsafeNotificationPermissionsAlertController = nil + } } } From 5f385b3eb4cdbfb170a333ebb985a2686f3ad501 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 26 Sep 2022 08:39:11 -0300 Subject: [PATCH 06/14] [LOOP-4348] notification permission banner for narrow display (#529) --- Loop/View Controllers/StatusTableViewController.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 9f02789b69..e774b2da8f 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -828,6 +828,8 @@ final class StatusTableViewController: LoopChartsTableViewController { override func updateConfiguration(using state: UICellConfigurationState) { super.updateConfiguration(using: state) + let adjustViewsForNarrowDisplay: Bool = bounds.width < 350 + var contentConfig = defaultContentConfiguration().updated(for: state) let titleImageAttachment = NSTextAttachment() titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.white) @@ -836,11 +838,11 @@ final class StatusTableViewController: LoopChartsTableViewController { titleWithImage.append(title) contentConfig.attributedText = titleWithImage contentConfig.textProperties.color = .white - contentConfig.textProperties.font = .systemFont(ofSize: 18, weight: .bold) + contentConfig.textProperties.font = .systemFont(ofSize: adjustViewsForNarrowDisplay ? 16 : 18, weight: .bold) contentConfig.textProperties.adjustsFontSizeToFitWidth = true contentConfig.secondaryText = "Fix now by turning Notifications, Critical Alerts and Time Sensitive Notifications ON." contentConfig.secondaryTextProperties.color = .white - contentConfig.secondaryTextProperties.font = .systemFont(ofSize: 15) + contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewsForNarrowDisplay ? 13 : 15) contentConfiguration = contentConfig var backgroundConfig = backgroundConfiguration?.updated(for: state) From 2056757469c0ab58f2dad17865f8f9c89f146f92 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 29 Sep 2022 06:09:21 -0700 Subject: [PATCH 07/14] Fix crash in ZipArchiveTests (#530) --- LoopTests/Managers/ZipArchiveTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/LoopTests/Managers/ZipArchiveTests.swift b/LoopTests/Managers/ZipArchiveTests.swift index 69ebc9ffda..3e6a950f62 100644 --- a/LoopTests/Managers/ZipArchiveTests.swift +++ b/LoopTests/Managers/ZipArchiveTests.swift @@ -20,7 +20,9 @@ class ZipArchiveTests: XCTestCase { } override func tearDown() { - outputStream?.close() + if outputStream?.streamStatus == .open { + outputStream?.close() + } archive.close() try? FileManager.default.removeItem(at: url) } From a44e5e7c8b075586d050bc5be35c62946b8041bd Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 27 Oct 2022 13:41:03 -0300 Subject: [PATCH 08/14] [LOOP-4400] made status highlight container for CGM and pump pill the same size (#531) --- LoopUI/StatusBarHUDView.xib | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/LoopUI/StatusBarHUDView.xib b/LoopUI/StatusBarHUDView.xib index 2d567bc184..bed3936e79 100644 --- a/LoopUI/StatusBarHUDView.xib +++ b/LoopUI/StatusBarHUDView.xib @@ -1,9 +1,9 @@ - + - + @@ -190,20 +190,20 @@ - + - + - + - + @@ -281,7 +281,7 @@ - + @@ -329,7 +329,7 @@ - + @@ -351,8 +351,8 @@ - - + + From 635ade6db692a410eb2c6b660fbacaeedd20a57f Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Fri, 28 Oct 2022 08:41:59 -0300 Subject: [PATCH 09/14] [LOOP-4349] Temporary mute alerts (#526) * added placeholder UI to configure mute alerts duration * checkpoint * checkpoint * initial commit at the alert muter * minor clean-up * removed unused file from project * corrected predicate syntax * all alerts at least vibrate * checkpoint * insteadOf = https://github.com/ updating unit tests * checkpoint * tighter coupling between alert manager and in-app/user notification alert issuers * corrected rescheduling loop not running notifications and use timers for UI * make the mute end period timer internval to the alert muter * corrected typo * updated unit tests * added duration to temp mute alerts setting * minor clean-up * using source of truth for last loop date * renamed Issuer -> Scheduler for in-app and user notification --- Loop.xcodeproj/project.pbxproj | 46 ++-- .../SettingsStore+SimulatedCoreData.swift | 3 +- Loop/Managers/AlertMuter.swift | 128 +++++++++++ Loop/Managers/Alerts/AlertManager.swift | 214 +++++++++++++----- Loop/Managers/Alerts/AlertStore.swift | 10 +- ...r.swift => InAppModalAlertScheduler.swift} | 31 +-- Loop/Managers/Alerts/StoredAlert.swift | 12 +- ...t => UserNotificationAlertScheduler.swift} | 78 +++---- Loop/Managers/DeviceDataManager.swift | 2 +- Loop/Managers/ExtensionDataManager.swift | 34 ++- Loop/Managers/LoopAppManager.swift | 10 +- Loop/Managers/SettingsManager.swift | 68 +++--- .../StatusTableViewController.swift | 31 ++- Loop/View Models/SettingsViewModel.swift | 5 + Loop/Views/AlertManagementView.swift | 115 ++++++++++ Loop/Views/SettingsView.swift | 9 +- .../Managers/Alerts/AlertManagerTests.swift | 146 +++++++----- .../Managers/Alerts/AlertMuterTests.swift | 176 ++++++++++++++ .../Managers/Alerts/AlertStoreTests.swift | 23 +- ...ft => InAppModalAlertSchedulerTests.swift} | 92 +++----- .../Managers/Alerts/StoredAlertTests.swift | 5 +- ...UserNotificationAlertSchedulerTests.swift} | 65 ++++-- WatchApp/DerivedAssets.xcassets/Contents.json | 6 +- 23 files changed, 957 insertions(+), 352 deletions(-) create mode 100644 Loop/Managers/AlertMuter.swift rename Loop/Managers/Alerts/{InAppModalAlertIssuer.swift => InAppModalAlertScheduler.swift} (85%) rename Loop/Managers/Alerts/{UserNotificationAlertIssuer.swift => UserNotificationAlertScheduler.swift} (59%) create mode 100644 Loop/Views/AlertManagementView.swift create mode 100644 LoopTests/Managers/Alerts/AlertMuterTests.swift rename LoopTests/Managers/Alerts/{InAppModalAlertIssuerTests.swift => InAppModalAlertSchedulerTests.swift} (75%) rename LoopTests/Managers/Alerts/{UserNotificationAlertIssuerTests.swift => UserNotificationAlertSchedulerTests.swift} (65%) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 043b6a85f0..8391ecb425 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -25,10 +25,10 @@ 1D82E6A025377C6B009131FB /* TrustedTimeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */; }; 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8D55BB252274650044DBB6 /* BolusEntryViewModelTests.swift */; }; 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9650C72523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift */; }; - 1DA649A7244126CD00F61E75 /* UserNotificationAlertIssuer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA649A6244126CD00F61E75 /* UserNotificationAlertIssuer.swift */; }; - 1DA649A9244126DA00F61E75 /* InAppModalAlertIssuer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA649A8244126DA00F61E75 /* InAppModalAlertIssuer.swift */; }; + 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA649A6244126CD00F61E75 /* UserNotificationAlertScheduler.swift */; }; + 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA649A8244126DA00F61E75 /* InAppModalAlertScheduler.swift */; }; 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA7A84124476EAD008257F0 /* AlertManagerTests.swift */; }; - 1DA7A84424477698008257F0 /* InAppModalAlertIssuerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA7A84324477698008257F0 /* InAppModalAlertIssuerTests.swift */; }; + 1DA7A84424477698008257F0 /* InAppModalAlertSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA7A84324477698008257F0 /* InAppModalAlertSchedulerTests.swift */; }; 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 */; }; @@ -36,7 +36,7 @@ 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 */; }; - 1DFE9E172447B6270082C280 /* UserNotificationAlertIssuerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DFE9E162447B6270082C280 /* UserNotificationAlertIssuerTests.swift */; }; + 1DFE9E172447B6270082C280 /* UserNotificationAlertSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DFE9E162447B6270082C280 /* UserNotificationAlertSchedulerTests.swift */; }; 43027F0F1DFE0EC900C51989 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */; }; 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4302F4E21D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift */; }; @@ -392,11 +392,13 @@ A9F703752489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F703742489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift */; }; A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F703762489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift */; }; A9FB75F1252BE320004C7D3F /* BolusDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */; }; + B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4001CED28CBBC82002FB414 /* AlertManagementView.swift */; }; B405E35924D2A75B00DD058D /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */; }; B405E35A24D2B1A400DD058D /* HUDAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */; }; B405E35B24D2E05600DD058D /* HUDAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */; }; B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */; }; B42C951424A3C76000857C73 /* CGMStatusHUDViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */; }; + B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42D124228D371C400E43D22 /* AlertMuter.swift */; }; B43DA44124D9C12100CAFF4E /* DismissibleHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */; }; B44251B3252350CE00605937 /* ChartAxisValuesStaticGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44251B2252350CE00605937 /* ChartAxisValuesStaticGeneratorTests.swift */; }; B44251B62523578300605937 /* PredictedGlucoseChartTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44251B52523578300605937 /* PredictedGlucoseChartTests.swift */; }; @@ -412,6 +414,7 @@ B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */; }; B4C9859425D5A3BB009FD9CA /* StatusBadgeHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */; }; B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */; }; + B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */; }; B4D620D424D9EDB900043B3C /* GuidanceColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D620D324D9EDB900043B3C /* GuidanceColors.swift */; }; B4E202302661063E009421B5 /* ClosedLoopStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E2022F2661063E009421B5 /* ClosedLoopStatus.swift */; }; B4E96D4B248A6B6E002DABAD /* DeviceStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */; }; @@ -440,7 +443,6 @@ C13BAD941E8009B000050CB5 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; C13DA2B024F6C7690098BB29 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13DA2AF24F6C7690098BB29 /* UIViewController.swift */; }; C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */; }; - C1549B782837DF4E002B190C /* LoopAlertManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1549B772837DF4E002B190C /* LoopAlertManagerTests.swift */; }; C159C825286785E000A86EC0 /* LoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; }; C159C828286785E100A86EC0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C8192867857000A86EC0 /* LoopKitUI.framework */; }; C159C82A286785E300A86EC0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C8212867859800A86EC0 /* MockKitUI.framework */; }; @@ -791,10 +793,10 @@ 1D8D55BB252274650044DBB6 /* BolusEntryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryViewModelTests.swift; sourceTree = ""; }; 1D9650C72523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+BolusEntryViewModelDelegate.swift"; sourceTree = ""; }; 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCriticalAlertPermissionsView.swift; sourceTree = ""; }; - 1DA649A6244126CD00F61E75 /* UserNotificationAlertIssuer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserNotificationAlertIssuer.swift; sourceTree = ""; }; - 1DA649A8244126DA00F61E75 /* InAppModalAlertIssuer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppModalAlertIssuer.swift; sourceTree = ""; }; + 1DA649A6244126CD00F61E75 /* UserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserNotificationAlertScheduler.swift; sourceTree = ""; }; + 1DA649A8244126DA00F61E75 /* InAppModalAlertScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppModalAlertScheduler.swift; sourceTree = ""; }; 1DA7A84124476EAD008257F0 /* AlertManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertManagerTests.swift; sourceTree = ""; }; - 1DA7A84324477698008257F0 /* InAppModalAlertIssuerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppModalAlertIssuerTests.swift; sourceTree = ""; }; + 1DA7A84324477698008257F0 /* InAppModalAlertSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppModalAlertSchedulerTests.swift; sourceTree = ""; }; 1DB1065024467E18005542BD /* AlertManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertManager.swift; sourceTree = ""; }; 1DB1CA4C24A55F0000B3B94C /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; @@ -802,7 +804,7 @@ 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 /* UserNotificationAlertIssuerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationAlertIssuerTests.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 = ""; }; 4302F4E21D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryTableViewController.swift; sourceTree = ""; }; 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSUserDefaults.swift; sourceTree = ""; }; @@ -1346,8 +1348,10 @@ A9F703742489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStore+SimulatedCoreData.swift"; sourceTree = ""; }; A9F703762489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistentDeviceLog+SimulatedCoreData.swift"; sourceTree = ""; }; A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDosingDecision.swift; sourceTree = ""; }; + B4001CED28CBBC82002FB414 /* AlertManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertManagementView.swift; sourceTree = ""; }; B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseDisplay.swift; sourceTree = ""; }; B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDViewModel.swift; sourceTree = ""; }; + B42D124228D371C400E43D22 /* AlertMuter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertMuter.swift; sourceTree = ""; }; B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissibleHostingController.swift; sourceTree = ""; }; B44251B2252350CE00605937 /* ChartAxisValuesStaticGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartAxisValuesStaticGeneratorTests.swift; sourceTree = ""; }; B44251B52523578300605937 /* PredictedGlucoseChartTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictedGlucoseChartTests.swift; sourceTree = ""; }; @@ -1360,6 +1364,7 @@ B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDViewModelTests.swift; sourceTree = ""; }; B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadgeHUDView.swift; sourceTree = ""; }; B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCompletionFreshnessTests.swift; sourceTree = ""; }; + B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = ""; }; B4D620D324D9EDB900043B3C /* GuidanceColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidanceColors.swift; sourceTree = ""; }; B4E2022F2661063E009421B5 /* ClosedLoopStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedLoopStatus.swift; sourceTree = ""; }; B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusHUDView.swift; sourceTree = ""; }; @@ -1390,7 +1395,6 @@ C12F21A61DFA79CB00748193 /* recommend_temp_basal_very_low_end_in_range.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_very_low_end_in_range.json; sourceTree = ""; }; C13DA2AF24F6C7690098BB29 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryUncertaintyAlertManager.swift; sourceTree = ""; }; - C1549B772837DF4E002B190C /* LoopAlertManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAlertManagerTests.swift; sourceTree = ""; }; C159C8192867857000A86EC0 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C159C8212867859800A86EC0 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C159C82E286787EF00A86EC0 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1664,11 +1668,11 @@ 1DB1065024467E18005542BD /* AlertManager.swift */, 1D05219C2469F1F5000EBBDE /* AlertStore.swift */, 1D080CBB2473214A00356610 /* AlertStore.xcdatamodeld */, - 1DA649A8244126DA00F61E75 /* InAppModalAlertIssuer.swift */, + 1DA649A8244126DA00F61E75 /* InAppModalAlertScheduler.swift */, 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */, 1D4A3E2B2478628500FD601B /* StoredAlert+CoreDataClass.swift */, 1D4A3E2C2478628500FD601B /* StoredAlert+CoreDataProperties.swift */, - 1DA649A6244126CD00F61E75 /* UserNotificationAlertIssuer.swift */, + 1DA649A6244126CD00F61E75 /* UserNotificationAlertScheduler.swift */, ); path = Alerts; sourceTree = ""; @@ -1692,9 +1696,10 @@ children = ( 1D80313C24746274002810DF /* AlertStoreTests.swift */, 1DA7A84124476EAD008257F0 /* AlertManagerTests.swift */, - 1DA7A84324477698008257F0 /* InAppModalAlertIssuerTests.swift */, - 1DFE9E162447B6270082C280 /* UserNotificationAlertIssuerTests.swift */, + 1DA7A84324477698008257F0 /* InAppModalAlertSchedulerTests.swift */, + 1DFE9E162447B6270082C280 /* UserNotificationAlertSchedulerTests.swift */, A91E4C2024F867A700BE9213 /* StoredAlertTests.swift */, + B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */, ); path = Alerts; sourceTree = ""; @@ -2094,6 +2099,7 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( + B4001CED28CBBC82002FB414 /* AlertManagementView.swift */, 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */, C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */, C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */, @@ -2123,6 +2129,7 @@ 43F5C2E41B93C5D4003EB13D /* Managers */ = { isa = PBXGroup; children = ( + B42D124228D371C400E43D22 /* AlertMuter.swift */, 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */, 439897361CD2F80600223065 /* AnalyticsServicesManager.swift */, B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */, @@ -3420,12 +3427,12 @@ C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, - 1DA649A7244126CD00F61E75 /* UserNotificationAlertIssuer.swift in Sources */, + 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */, 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */, - 1DA649A9244126DA00F61E75 /* InAppModalAlertIssuer.swift in Sources */, + 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */, 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */, A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, @@ -3436,6 +3443,7 @@ 43511CE321FD80E400566C63 /* StandardRetrospectiveCorrection.swift in Sources */, E95D380524EADF78005E2F50 /* GlucoseStoreProtocol.swift in Sources */, 43E3449F1B9D68E900C85C07 /* StatusTableViewController.swift in Sources */, + B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */, A96DAC242838325900D94E38 /* DiagnosticLog.swift in Sources */, A9CBE45A248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift in Sources */, A9C62D8A2331703100535612 /* ServicesManager.swift in Sources */, @@ -3469,6 +3477,7 @@ C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */, E98A55EF24EDD6E60008715D /* DosingDecisionStoreProtocol.swift in Sources */, C165B8CE23302C5D0004112E /* RemoteCommand.swift in Sources */, + B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */, E9C00EF524C623EF00628F35 /* LoopSettings+Loop.swift in Sources */, 4389916B1E91B689000EEF90 /* ChartSettings+Loop.swift in Sources */, C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, @@ -3756,9 +3765,10 @@ B44251B62523578300605937 /* PredictedGlucoseChartTests.swift in Sources */, A9DFAFB524F048A000950D1E /* WatchHistoricalCarbsTests.swift in Sources */, C16575732538AFF6004AE16E /* CGMStalenessMonitorTests.swift in Sources */, - 1DA7A84424477698008257F0 /* InAppModalAlertIssuerTests.swift in Sources */, + 1DA7A84424477698008257F0 /* InAppModalAlertSchedulerTests.swift in Sources */, 1D70C40126EC0F9D00C62570 /* SupportManagerTests.swift in Sources */, E93E86A824DDCC4400FF40C8 /* MockDoseStore.swift in Sources */, + B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */, E98A55F124EDD85E0008715D /* MockDosingDecisionStore.swift in Sources */, 8968B114240C55F10074BB48 /* LoopSettingsTests.swift in Sources */, A9BD28E7272226B40071DF15 /* TestLocalizedError.swift in Sources */, @@ -3774,7 +3784,7 @@ A96DAC2A2838EF8A00D94E38 /* DiagnosticLogTests.swift in Sources */, A9DAE7D02332D77F006AE942 /* LoopTests.swift in Sources */, E93E86B024DDE1BD00FF40C8 /* MockGlucoseStore.swift in Sources */, - 1DFE9E172447B6270082C280 /* UserNotificationAlertIssuerTests.swift in Sources */, + 1DFE9E172447B6270082C280 /* UserNotificationAlertSchedulerTests.swift in Sources */, B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */, C1900900252271BB00721625 /* SimpleBolusCalculatorTests.swift in Sources */, A9C1719725366F780053BCBD /* WatchHistoricalGlucoseTest.swift in Sources */, diff --git a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift index 0fbe0a6393..80c990bb38 100644 --- a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift +++ b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift @@ -125,7 +125,8 @@ fileprivate extension StoredSettings { providesAppNotificationSettings: true, announcementSetting: .enabled, timeSensitiveSetting: .enabled, - scheduledDeliverySetting: .disabled) + scheduledDeliverySetting: .disabled, + temporaryMuteAlertsSetting: .disabled) let controllerDevice = StoredSettings.ControllerDevice(name: "Controller Name", systemName: "Controller System Name", systemVersion: "Controller System Version", diff --git a/Loop/Managers/AlertMuter.swift b/Loop/Managers/AlertMuter.swift new file mode 100644 index 0000000000..78c4ec8770 --- /dev/null +++ b/Loop/Managers/AlertMuter.swift @@ -0,0 +1,128 @@ +// +// AlertMuter.swift +// Loop +// +// Created by Nathaniel Hamming on 2022-09-14. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation +import Combine +import SwiftUI +import LoopKit + +public class AlertMuter: ObservableObject { + struct Configuration: Equatable, RawRepresentable { + typealias RawValue = [String: Any] + + enum ConfigurationKey: String { + case duration + case startTime + } + + init?(rawValue: [String : Any]) { + guard let duration = rawValue[ConfigurationKey.duration.rawValue] as? TimeInterval + else { return nil } + + self.duration = duration + self.startTime = rawValue[ConfigurationKey.startTime.rawValue] as? Date + } + + var rawValue: [String : Any] { + var rawValue: [String : Any] = [:] + rawValue[ConfigurationKey.duration.rawValue] = duration + rawValue[ConfigurationKey.startTime.rawValue] = startTime + return rawValue + } + + var duration: TimeInterval + + var startTime: Date? + + var shouldMute: Bool { + guard let mutingEndTime = mutingEndTime else { return false } + return mutingEndTime >= Date() + } + + var mutingEndTime: Date? { + startTime?.addingTimeInterval(duration) + } + + init(startTime: Date? = nil, duration: TimeInterval = AlertMuter.allowedDurations[0]) { + self.duration = duration + self.startTime = startTime + } + + func shouldMuteAlert(scheduledAt timeFromNow: TimeInterval = 0, now: Date = Date()) -> Bool { + guard timeFromNow >= 0 else { return false } + + guard let mutingEndTime = mutingEndTime else { return false } + + let alertTriggerTime = now.advanced(by: timeFromNow) + guard alertTriggerTime < mutingEndTime + else { return false } + + return true + } + } + + @Published var configuration: Configuration { + didSet { + if oldValue != configuration { + updateMutePeriodEndingWatcher() + } + } + } + + private var mutePeriodEndingTimer: Timer? + + private lazy var cancellables = Set() + + static var allowedDurations: [TimeInterval] { [.minutes(30), .hours(1), .hours(2), .hours(4)] } + + init(configuration: Configuration = Configuration()) { + self.configuration = configuration + + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .sink { [weak self] _ in + self?.updateMutePeriodEndingWatcher() + } + .store(in: &cancellables) + + updateMutePeriodEndingWatcher() + } + + convenience init(startTime: Date? = nil, duration: TimeInterval = AlertMuter.allowedDurations[0]) { + self.init(configuration: Configuration(startTime: startTime, duration: duration)) + } + + private func updateMutePeriodEndingWatcher(_ now: Date = Date()) { + mutePeriodEndingTimer?.invalidate() + + guard let mutingEndTime = configuration.mutingEndTime else { return } + + guard mutingEndTime > now else { + configuration.startTime = nil + return + } + + let timeInterval = mutingEndTime.timeIntervalSince(now) + mutePeriodEndingTimer = Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false) { [weak self] _ in + self?.configuration.startTime = nil + } + } + + func shouldMuteAlert(scheduledAt timeFromNow: TimeInterval = 0) -> Bool { + return configuration.shouldMuteAlert(scheduledAt: timeFromNow) + } + + func shouldMuteAlert(_ alert: LoopKit.Alert, issuedDate: Date? = nil, now: Date = Date()) -> Bool { + switch alert.trigger { + case .immediate: + return shouldMuteAlert(scheduledAt: (issuedDate ?? now).timeIntervalSince(now)) + case .delayed(let interval), .repeating(let interval): + let triggerInterval = ((issuedDate ?? now) + interval).timeIntervalSince(now) + return shouldMuteAlert(scheduledAt: triggerInterval) + } + } +} diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 0c131451b6..112d77cc3f 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -31,16 +31,16 @@ public final class AlertManager { static let managerIdentifier = "Loop" - private var handlers: [AlertIssuer] = [] private var responders: [String: Weak] = [:] private var soundVendors: [String: Weak] = [:] private let fileManager: FileManager private let alertPresenter: AlertPresenter - private var modalAlertIssuer: AlertIssuer! - private var userNotificationAlertIssuer: AlertIssuer? + private var modalAlertScheduler: InAppModalAlertScheduler! + private var userNotificationAlertScheduler: UserNotificationAlertScheduler private var unsafeNotificationPermissionsAlertController: UIAlertController? + var alertMuter: AlertMuter let alertStore: AlertStore @@ -52,8 +52,8 @@ public final class AlertManager { var getCurrentDate = { return Date() } public init(alertPresenter: AlertPresenter, - modalAlertIssuer: AlertIssuer? = nil, - userNotificationAlertIssuer: AlertIssuer, + modalAlertScheduler: InAppModalAlertScheduler? = nil, + userNotificationAlertScheduler: UserNotificationAlertScheduler, fileManager: FileManager = FileManager.default, alertStore: AlertStore? = nil, expireAfter: TimeInterval = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */, @@ -72,17 +72,24 @@ public final class AlertManager { } self.alertStore = alertStore ?? AlertStore(storageDirectoryURL: alertStoreDirectory, expireAfter: expireAfter) self.alertPresenter = alertPresenter - self.modalAlertIssuer = modalAlertIssuer ?? InAppModalAlertIssuer(alertPresenter: alertPresenter, alertManagerResponder: self) - self.userNotificationAlertIssuer = userNotificationAlertIssuer - handlers = [self.modalAlertIssuer, userNotificationAlertIssuer] + self.alertMuter = AlertMuter(configuration: UserDefaults.standard.alertMuterConfiguration) + self.userNotificationAlertScheduler = userNotificationAlertScheduler + self.modalAlertScheduler = modalAlertScheduler ?? InAppModalAlertScheduler(alertPresenter: alertPresenter, alertManagerResponder: self) bluetoothProvider.addBluetoothObserver(self, queue: .main) NotificationCenter.default.publisher(for: .LoopCompleted) .sink { [weak self] _ in - self?.loopDidComplete() + self?.loopDidComplete(self?.getLastLoopDate()) } .store(in: &cancellables) + + alertMuter.$configuration + .removeDuplicates() + .receive(on: RunLoop.main) + .dropFirst() + .sink(receiveValue: rescheduleMutedAlerts) + .store(in: &cancellables) } public func addAlertResponder(managerIdentifier: String, alertResponder: AlertResponder) { @@ -139,44 +146,56 @@ public final class AlertManager { // MARK: - Loop Not Running alerts - func loopDidComplete() { + func loopDidComplete(_ lastLoopDate: Date? = nil) { + // use now if there is no lastLoopDate + rescheduleLoopNotRunningNotifications(lastLoopDate ?? Date()) + } + + private func rescheduleLoopNotRunningNotifications() { + guard let lastLoopDate = getLastLoopDate() else { return } + rescheduleLoopNotRunningNotifications(lastLoopDate) + } + + func rescheduleLoopNotRunningNotifications(_ lastLoopDate: Date) { clearLoopNotRunningNotifications() - scheduleLoopNotRunningNotifications() + scheduleLoopNotRunningNotifications(lastLoopDate) } - func scheduleLoopNotRunningNotifications() { + func scheduleLoopNotRunningNotifications(_ lastLoopDate: Date) { // Give a little extra time for a loop-in-progress to complete let gracePeriod = TimeInterval(minutes: 0.5) var scheduledNotifications: [StoredLoopNotRunningNotification] = [] for (minutes, isCritical) in [(20.0, false), (40.0, false), (60.0, true), (120.0, true)] { - let notification = UNMutableNotificationContent() - let failureInterval = TimeInterval(minutes: minutes) + let failureInterval = lastLoopDate.addingTimeInterval(.minutes(minutes)).timeIntervalSinceNow + guard failureInterval >= 0 else { break } let formatter = DateComponentsFormatter() formatter.maximumUnitCount = 1 formatter.allowedUnits = [.hour, .minute] formatter.unitsStyle = .full + let notificationContent = UNMutableNotificationContent() if let failureIntervalString = formatter.string(from: failureInterval)?.localizedLowercase { - notification.body = String(format: NSLocalizedString("Loop has not completed successfully in %@", comment: "The notification alert describing a long-lasting loop failure. The substitution parameter is the time interval since the last loop"), failureIntervalString) + notificationContent.body = String(format: NSLocalizedString("Loop has not completed successfully in %@", comment: "The notification alert describing a long-lasting loop failure. The substitution parameter is the time interval since the last loop"), failureIntervalString) } - notification.title = NSLocalizedString("Loop Failure", comment: "The notification title for a loop failure") + notificationContent.title = NSLocalizedString("Loop Failure", comment: "The notification title for a loop failure") + let shouldMuteAlert = alertMuter.shouldMuteAlert(scheduledAt: failureInterval) if isCritical, FeatureFlags.criticalAlertsEnabled { if #available(iOS 15.0, *) { - notification.interruptionLevel = .critical + notificationContent.interruptionLevel = .critical } - notification.sound = .defaultCritical + notificationContent.sound = shouldMuteAlert ? .defaultCriticalSound(withAudioVolume: 0.0) : .defaultCritical } else { if #available(iOS 15.0, *) { - notification.interruptionLevel = .timeSensitive + notificationContent.interruptionLevel = .timeSensitive } - notification.sound = .default + notificationContent.sound = shouldMuteAlert ? nil : .default } - notification.categoryIdentifier = LoopNotificationCategory.loopNotRunning.rawValue - notification.threadIdentifier = LoopNotificationCategory.loopNotRunning.rawValue + notificationContent.categoryIdentifier = LoopNotificationCategory.loopNotRunning.rawValue + notificationContent.threadIdentifier = LoopNotificationCategory.loopNotRunning.rawValue let trigger = UNTimeIntervalNotificationTrigger( timeInterval: failureInterval + gracePeriod, @@ -185,15 +204,15 @@ public final class AlertManager { let request = UNNotificationRequest( identifier: "\(LoopNotificationCategory.loopNotRunning.rawValue)\(failureInterval)", - content: notification, + content: notificationContent, trigger: trigger ) if let nextTriggerDate = trigger.nextTriggerDate() { let scheduledNotification = StoredLoopNotRunningNotification( alertAt: nextTriggerDate, - title: notification.title, - body: notification.body, + title: notificationContent.title, + body: notificationContent.body, timeInterval: failureInterval, isCritical: isCritical) scheduledNotifications.append(scheduledNotification) @@ -236,6 +255,10 @@ public final class AlertManager { } } + private func getLastLoopDate() -> Date? { + ExtensionDataManager.lastLoopCompleted + } + // MARK: - Workout reminder private func scheduleWorkoutOverrideReminder() { issueAlert(workoutOverrideReminderAlert) @@ -245,11 +268,11 @@ public final class AlertManager { retractAlert(identifier: AlertManager.workoutOverrideReminderAlertIdentifier) } - public static var workoutOverrideReminderAlertIdentifier: Alert.Identifier { + static var workoutOverrideReminderAlertIdentifier: Alert.Identifier { return Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: "WorkoutOverrideReminder") } - public var workoutOverrideReminderAlert: Alert { + private var workoutOverrideReminderAlert: Alert { let title = NSLocalizedString("Workout Temp Adjust Still On", comment: "Workout override still on reminder alert title") let body = NSLocalizedString("Workout Temp Adjust has been turned on for more than 24 hours. Make sure you still want it enabled, or turn it off in the app.", comment: "Workout override still on reminder alert body.") let content = Alert.Content(title: title, @@ -261,6 +284,23 @@ public final class AlertManager { trigger: .delayed(interval: .hours(24))) } + // MARK: - Rescheduling Muted Alerts + + func rescheduleMutedAlerts(_ newValue: AlertMuter.Configuration) { + UserDefaults.standard.alertMuterConfiguration = newValue + rescheduleLoopNotRunningNotifications() + + lookupAllPendingDelayedOrRepeatingAlerts() { [weak self] result in + switch result { + case .success(let persistedAlerts): + for persistedAlert in persistedAlerts { + self?.rescheduleAlertWithSchedulers(persistedAlert.alert, issuedDate: persistedAlert.issuedDate) + } + case .failure(let error): + self?.log.error("error looking up all delayed or repeating alerts: %{public}@", String(describing: error)) + } + } + } } // MARK: AlertManagerResponder implementation @@ -274,7 +314,7 @@ extension AlertManager: AlertManagerResponder { } } } - handlers.map { $0 as? AlertManagerResponder }.forEach { $0?.acknowledgeAlert(identifier: identifier) } + userNotificationAlertScheduler.acknowledgeAlert(identifier: identifier) alertStore.recordAcknowledgement(of: identifier) } @@ -304,12 +344,12 @@ extension AlertManager: AlertManagerResponder { extension AlertManager: AlertIssuer { public func issueAlert(_ alert: Alert) { - handlers.forEach { $0.issueAlert(alert) } + scheduleAlertWithSchedulers(alert) alertStore.recordIssued(alert: alert) } public func retractAlert(identifier: Alert.Identifier) { - handlers.forEach { $0.retractAlert(identifier: identifier) } + unscheduleAlertWithSchedulers(identifier: identifier) alertStore.recordRetraction(of: identifier) } @@ -322,9 +362,24 @@ extension AlertManager: AlertIssuer { // Only alerts with foreground content are replayed if alert.foregroundContent != nil { - modalAlertIssuer?.issueAlert(alert) + modalAlertScheduler.scheduleAlert(alert) } } + + private func scheduleAlertWithSchedulers(_ alert: Alert, issuedDate: Date = Date()) { + modalAlertScheduler.scheduleAlert(alert) + userNotificationAlertScheduler.scheduleAlert(alert, muted: alertMuter.shouldMuteAlert(alert, issuedDate: issuedDate)) + } + + private func unscheduleAlertWithSchedulers(identifier: Alert.Identifier) { + modalAlertScheduler.unscheduleAlert(identifier: identifier) + userNotificationAlertScheduler.unscheduleAlert(identifier: identifier) + } + + private func rescheduleAlertWithSchedulers(_ alert: Alert, issuedDate: Date) { + unscheduleAlertWithSchedulers(identifier: alert.identifier) + scheduleAlertWithSchedulers(alert, issuedDate: issuedDate) + } } // MARK: Sound Support @@ -332,12 +387,11 @@ extension AlertManager: AlertIssuer { extension AlertManager { public static func soundURL(for alert: Alert) -> URL? { - guard let sound = alert.sound else { return nil } - return soundURL(managerIdentifier: alert.identifier.managerIdentifier, sound: sound) + return soundURL(managerIdentifier: alert.identifier.managerIdentifier, sound: alert.sound) } - private static func soundURL(managerIdentifier: String, sound: Alert.Sound) -> URL? { - guard let soundFileName = sound.filename else { return nil } + private static func soundURL(managerIdentifier: String, sound: Alert.Sound?) -> URL? { + guard let soundFileName = sound?.filename else { return nil } // Seems all the sound files need to be in the sounds directory, so we namespace the filenames return soundsDirectoryURL.appendingPathComponent("\(managerIdentifier)-\(soundFileName)") @@ -379,7 +433,9 @@ extension AlertManager { case .success(let alerts): alerts.forEach { alert in do { - self.replayAlert(try Alert(from: alert, adjustedForStorageTime: true)) + if let alert = try Alert(from: alert, adjustedForStorageTime: true) { + self.replayAlert(alert) + } } catch { self.log.error("Error decoding alert from persistent storage: %@", error.localizedDescription) } @@ -393,7 +449,9 @@ extension AlertManager { case .success(let alerts): alerts.forEach { alert in do { - self.replayAlert(try Alert(from: alert, adjustedForStorageTime: true)) + if let alert = try Alert(from: alert, adjustedForStorageTime: true) { + self.replayAlert(alert) + } } catch { self.log.error("Error decoding alert from persistent storage: %@", error.localizedDescription) } @@ -455,13 +513,17 @@ extension AlertManager: PersistedAlertStore { switch $0 { case .success(let alerts): do { - let result = try alerts.map { - PersistedAlert( - alert: try Alert(from: $0, adjustedForStorageTime: false), - issuedDate: $0.issuedDate, - retractedDate: $0.retractedDate, - acknowledgedDate: $0.acknowledgedDate - ) + let result = try alerts.compactMap { + if let alert = try Alert(from: $0, adjustedForStorageTime: false) { + return PersistedAlert( + alert: alert, + issuedDate: $0.issuedDate, + retractedDate: $0.retractedDate, + acknowledgedDate: $0.acknowledgedDate + ) + } else { + return nil + } } completion(.success(result)) } catch { @@ -478,13 +540,45 @@ extension AlertManager: PersistedAlertStore { switch $0 { case .success(let alerts): do { - let result = try alerts.map { - PersistedAlert( - alert: try Alert(from: $0, adjustedForStorageTime: false), - issuedDate: $0.issuedDate, - retractedDate: $0.retractedDate, - acknowledgedDate: $0.acknowledgedDate - ) + let result = try alerts.compactMap { + if let alert = try Alert(from: $0, adjustedForStorageTime: false) { + return PersistedAlert( + alert: alert, + issuedDate: $0.issuedDate, + retractedDate: $0.retractedDate, + acknowledgedDate: $0.acknowledgedDate + ) + } else { + return nil + } + } + completion(.success(result)) + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + private func lookupAllPendingDelayedOrRepeatingAlerts(completion: @escaping (Result<[PersistedAlert], Error>) -> Void) { + // the interval provided is not used in the search. Just the trigger stored type value + alertStore.lookupAllUnacknowledgedUnretracted(filteredByTriggers: [Alert.Trigger.delayed(interval: 0).storedType, Alert.Trigger.repeating(repeatInterval: 0).storedType]) { + switch $0 { + case .success(let alerts): + do { + let result = try alerts.compactMap { + if let alert = try Alert(from: $0, adjustedForStorageTime: false) { + return PersistedAlert( + alert: alert, + issuedDate: $0.issuedDate, + retractedDate: $0.retractedDate, + acknowledgedDate: $0.acknowledgedDate + ) + } else { + return nil + } } completion(.success(result)) } catch { @@ -601,7 +695,7 @@ extension AlertManager: AlertPermissionsCheckerDelegate { issueHandler: { alert in // in-app modal is presented with a button to navigate to settings self.presentUnsafeNotificationPermissionsInAppAlert() - self.userNotificationAlertIssuer?.issueAlert(alert) + self.userNotificationAlertScheduler.scheduleAlert(alert, muted: self.alertMuter.shouldMuteAlert(alert)) self.recordIssued(alert: alert) }, retractionHandler: { alert in @@ -664,6 +758,7 @@ fileprivate extension UserDefaults { private enum Key: String { case hasIssuedNotificationPermissionsAlert = "com.loopkit.Loop.HasIssuedNotificationPermissionsAlert" case hasIssuedScheduledDeliveryEnabledAlert = "com.loopkit.Loop.HasIssuedScheduledDeliveryEnabledAlert" + case alertMuterConfiguration = "com.loopkit.Loop.alertMuterConfiguration" } var hasIssuedNotificationPermissionsAlert: Bool { @@ -683,4 +778,19 @@ fileprivate extension UserDefaults { set(newValue, forKey: Key.hasIssuedScheduledDeliveryEnabledAlert.rawValue) } } + + var alertMuterConfiguration: AlertMuter.Configuration { + get { + if let alertMuterConfigurationRawValue = object(forKey: Key.alertMuterConfiguration.rawValue) as? AlertMuter.Configuration.RawValue, + let alertMuterConfiguration = AlertMuter.Configuration(rawValue: alertMuterConfigurationRawValue) + { + return alertMuterConfiguration + } else { + return AlertMuter().configuration + } + } + set { + set(newValue.rawValue, forKey: Key.alertMuterConfiguration.rawValue) + } + } } diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift index ec911bd1b7..4bf90385b0 100644 --- a/Loop/Managers/Alerts/AlertStore.swift +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -181,7 +181,7 @@ public class AlertStore { } } - public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { managedObjectContext.perform { do { let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() @@ -192,6 +192,14 @@ public class AlertStore { if let managerIdentifier = managerIdentifier { predicates.insert(NSPredicate(format: "managerIdentifier = %@", managerIdentifier), at: 0) } + if let triggersStoredType = triggersStoredType { + var triggerPredicates: [NSPredicate] = [] + for triggerStoredType in triggersStoredType { + triggerPredicates.append(NSPredicate(format: "triggerType == %d", triggerStoredType)) + } + let triggerFilterPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: triggerPredicates) + predicates.append(triggerFilterPredicate) + } fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] let result = try self.managedObjectContext.fetch(fetchRequest) diff --git a/Loop/Managers/Alerts/InAppModalAlertIssuer.swift b/Loop/Managers/Alerts/InAppModalAlertScheduler.swift similarity index 85% rename from Loop/Managers/Alerts/InAppModalAlertIssuer.swift rename to Loop/Managers/Alerts/InAppModalAlertScheduler.swift index 7003ab1503..b00c809f7a 100644 --- a/Loop/Managers/Alerts/InAppModalAlertIssuer.swift +++ b/Loop/Managers/Alerts/InAppModalAlertScheduler.swift @@ -1,5 +1,5 @@ // -// InAppModalAlertIssuer.swift +// InAppModalAlertScheduler.swift // LoopKit // // Created by Rick Pasetto on 4/9/20. @@ -9,7 +9,7 @@ import UIKit import LoopKit -public class InAppModalAlertIssuer: AlertIssuer { +public class InAppModalAlertScheduler { private weak var alertPresenter: AlertPresenter? private weak var alertManagerResponder: AlertManagerResponder? @@ -23,24 +23,20 @@ public class InAppModalAlertIssuer: AlertIssuer { typealias TimerFactoryFunction = (TimeInterval, Bool, (() -> Void)?) -> Timer private let newTimerFunc: TimerFactoryFunction - private let soundPlayer: AlertSoundPlayer - init(alertPresenter: AlertPresenter?, alertManagerResponder: AlertManagerResponder, - soundPlayer: AlertSoundPlayer = DeviceAVSoundPlayer(), newActionFunc: @escaping ActionFactoryFunction = UIAlertAction.init, newTimerFunc: TimerFactoryFunction? = nil) { self.alertPresenter = alertPresenter self.alertManagerResponder = alertManagerResponder - self.soundPlayer = soundPlayer self.newActionFunc = newActionFunc self.newTimerFunc = newTimerFunc ?? { timeInterval, repeats, block in return Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: repeats) { _ in block?() } } } - public func issueAlert(_ alert: Alert) { + public func scheduleAlert(_ alert: Alert) { switch alert.trigger { case .immediate: show(alert: alert) @@ -51,7 +47,7 @@ public class InAppModalAlertIssuer: AlertIssuer { } } - public func retractAlert(identifier: Alert.Identifier) { + public func unscheduleAlert(identifier: Alert.Identifier) { DispatchQueue.main.async { self.removePendingAlert(identifier: identifier) self.removePresentedAlert(identifier: identifier) @@ -75,7 +71,7 @@ public class InAppModalAlertIssuer: AlertIssuer { } /// Private functions -extension InAppModalAlertIssuer { +extension InAppModalAlertScheduler { private func schedule(alert: Alert, interval: TimeInterval, repeats: Bool) { guard alert.foregroundContent != nil else { @@ -113,7 +109,6 @@ extension InAppModalAlertIssuer { } self.alertPresenter?.present(alertController, animated: true) { [weak self] in // the completion is called after the alert is presented - self?.playSound(for: alert) self?.addPresentedAlert(alert: alert, controller: alertController) } } @@ -156,20 +151,4 @@ extension InAppModalAlertIssuer { alertController.addAction(newActionFunc(action, .default, { _ in acknowledgeCompletion() })) return alertController } - - private func playSound(for alert: Alert) { - guard let sound = alert.sound else { return } - switch sound { - case .vibrate: - soundPlayer.vibrate() - case .silence: - break - default: - // Assuming in-app alerts should also vibrate. That way, if the user has "silent mode" on, they still get - // some kind of haptic feedback - soundPlayer.vibrate() - guard let url = AlertManager.soundURL(for: alert) else { return } - soundPlayer.play(url: url) - } - } } diff --git a/Loop/Managers/Alerts/StoredAlert.swift b/Loop/Managers/Alerts/StoredAlert.swift index 3ae5cdd53f..fb5b431074 100644 --- a/Loop/Managers/Alerts/StoredAlert.swift +++ b/Loop/Managers/Alerts/StoredAlert.swift @@ -61,9 +61,13 @@ extension StoredAlert { } extension Alert { - init(from storedAlert: StoredAlert, adjustedForStorageTime: Bool) throws { + init?(from storedAlert: StoredAlert, adjustedForStorageTime: Bool) throws { + guard let bgContent = try Alert.Content(contentString: storedAlert.backgroundContent) else { + // all alerts must have background content + return nil + } + let fgContent = try Alert.Content(contentString: storedAlert.foregroundContent) - let bgContent = try Alert.Content(contentString: storedAlert.backgroundContent) let sound = try Alert.Sound(soundString: storedAlert.sound) let metadata = try Alert.Metadata(metadataString: storedAlert.metadata) let trigger = try Alert.Trigger(storedType: storedAlert.triggerType, @@ -115,12 +119,14 @@ extension Alert.Metadata { } } +public typealias AlertTriggerStoredType = Int16 + extension Alert.Trigger { enum StorageError: Error { case invalidStoredInterval, invalidStoredType } - var storedType: Int16 { + var storedType: AlertTriggerStoredType { switch self { case .immediate: return 0 case .delayed: return 1 diff --git a/Loop/Managers/Alerts/UserNotificationAlertIssuer.swift b/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift similarity index 59% rename from Loop/Managers/Alerts/UserNotificationAlertIssuer.swift rename to Loop/Managers/Alerts/UserNotificationAlertScheduler.swift index 0bd2b59555..a1eb654209 100644 --- a/Loop/Managers/Alerts/UserNotificationAlertIssuer.swift +++ b/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift @@ -1,5 +1,5 @@ // -// UserNotificationAlertIssuer.swift +// UserNotificationAlertScheduler.swift // LoopKit // // Created by Rick Pasetto on 4/9/20. @@ -18,36 +18,32 @@ public protocol UserNotificationCenter { } extension UNUserNotificationCenter: UserNotificationCenter {} -class UserNotificationAlertIssuer: AlertIssuer { +public class UserNotificationAlertScheduler { let userNotificationCenter: UserNotificationCenter - let log = DiagnosticLog(category: "UserNotificationAlertIssuer") + let log = DiagnosticLog(category: "UserNotificationAlertScheduler") init(userNotificationCenter: UserNotificationCenter) { self.userNotificationCenter = userNotificationCenter } - func issueAlert(_ alert: Alert) { - issueAlert(alert, timestamp: Date()) + func scheduleAlert(_ alert: Alert, muted: Bool = false) { + scheduleAlert(alert, timestamp: Date(), muted: muted) } - func issueAlert(_ alert: Alert, timestamp: Date) { + func scheduleAlert(_ alert: Alert, timestamp: Date, muted: Bool = false) { DispatchQueue.main.async { - do { - let request = try UNNotificationRequest(from: alert, timestamp: timestamp) - self.userNotificationCenter.add(request) { error in - if let error = error { - self.log.error("Something went wrong posting the user notification: %@", error.localizedDescription) - } + let request = UNNotificationRequest(from: alert, timestamp: timestamp, muted: muted) + self.userNotificationCenter.add(request) { error in + if let error = error { + self.log.error("Something went wrong posting the user notification: %@", error.localizedDescription) } - // For now, UserNotifications do not not acknowledge...not yet at least - } catch { - self.log.error("Error issuing alert: %@", error.localizedDescription) } + // For now, UserNotifications do not not acknowledge...not yet at least } } - func retractAlert(identifier: Alert.Identifier) { + func unscheduleAlert(identifier: Alert.Identifier) { DispatchQueue.main.async { self.userNotificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier.value]) self.userNotificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier.value]) @@ -55,7 +51,7 @@ class UserNotificationAlertIssuer: AlertIssuer { } } -extension UserNotificationAlertIssuer: AlertManagerResponder { +extension UserNotificationAlertScheduler: AlertManagerResponder { func acknowledgeAlert(identifier: Alert.Identifier) { DispatchQueue.main.async { self.log.debug("Removing notification %@ from delivered notifications", identifier.value) @@ -65,19 +61,11 @@ extension UserNotificationAlertIssuer: AlertManagerResponder { } fileprivate extension Alert { - - enum Error: String, Swift.Error { - case noBackgroundContent - } - - func getUserNotificationContent(timestamp: Date) throws -> UNNotificationContent { - guard let content = backgroundContent else { - throw Error.noBackgroundContent - } + func getUserNotificationContent(timestamp: Date, muted: Bool) -> UNNotificationContent { let userNotificationContent = UNMutableNotificationContent() - userNotificationContent.title = content.title - userNotificationContent.body = content.body - userNotificationContent.sound = userNotificationSound + userNotificationContent.title = backgroundContent.title + userNotificationContent.body = backgroundContent.body + userNotificationContent.sound = userNotificationSound(muted: muted) if #available(iOS 15.0, *) { userNotificationContent.interruptionLevel = interruptionLevel.userNotificationInterruptLevel } @@ -91,23 +79,17 @@ fileprivate extension Alert { return userNotificationContent } - private var userNotificationSound: UNNotificationSound? { - guard backgroundContent != nil else { - return nil - } - if let sound = sound { - switch sound { - case .vibrate: - // TODO: Not sure how to "force" UNNotificationSound to "vibrate only"...so for now we just do the default - break - case .silence: - // TODO: Not sure how to "force" UNNotificationSound to "silence"...so for now we just do the default - break - default: - if let actualFileName = AlertManager.soundURL(for: self)?.lastPathComponent { - let unname = UNNotificationSoundName(rawValue: actualFileName) - return interruptionLevel == .critical ? UNNotificationSound.criticalSoundNamed(unname) : UNNotificationSound(named: unname) - } + private func userNotificationSound(muted: Bool) -> UNNotificationSound? { + guard !muted else { return interruptionLevel == .critical ? .defaultCriticalSound(withAudioVolume: 0) : nil } + + switch sound { + case .vibrate: + // setting the audio volume of critical alert to 0 only vibrates + return interruptionLevel == .critical ? .defaultCriticalSound(withAudioVolume: 0) : nil + default: + if let actualFileName = AlertManager.soundURL(for: self)?.lastPathComponent { + let unname = UNNotificationSoundName(rawValue: actualFileName) + return interruptionLevel == .critical ? UNNotificationSound.criticalSoundNamed(unname) : UNNotificationSound(named: unname) } } @@ -130,8 +112,8 @@ fileprivate extension Alert.InterruptionLevel { } fileprivate extension UNNotificationRequest { - convenience init(from alert: Alert, timestamp: Date) throws { - let content = try alert.getUserNotificationContent(timestamp: timestamp) + convenience init(from alert: Alert, timestamp: Date, muted: Bool) { + let content = alert.getUserNotificationContent(timestamp: timestamp, muted: muted) self.init(identifier: alert.identifier.value, content: content, trigger: UNTimeIntervalNotificationTrigger(from: alert.trigger)) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 051efb828d..faf0182b27 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -341,7 +341,7 @@ final class DeviceDataManager { statusExtensionManager = ExtensionDataManager(deviceDataManager: self, closedLoopStatus: closedLoopStatus) loopManager = LoopDataManager( - lastLoopCompleted: statusExtensionManager.context?.lastLoopCompleted, + lastLoopCompleted: ExtensionDataManager.lastLoopCompleted, basalDeliveryState: pumpManager?.status.basalDeliveryState, settings: settingsManager.loopSettings, overrideHistory: overrideHistory, diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift index 404c8c59e9..520c5808f8 100644 --- a/Loop/Managers/ExtensionDataManager.swift +++ b/Loop/Managers/ExtensionDataManager.swift @@ -30,12 +30,30 @@ final class ExtensionDataManager { } } - fileprivate var defaults: UserDefaults? { + fileprivate static var defaults: UserDefaults? { return UserDefaults.appGroup } - var context: StatusExtensionContext? { - return defaults?.statusExtensionContext + static var context: StatusExtensionContext? { + get { + return defaults?.statusExtensionContext + } + set { + defaults?.statusExtensionContext = newValue + } + } + + static var intentExtensionInfo: IntentExtensionInfo? { + get { + return defaults?.intentExtensionInfo + } + set { + defaults?.intentExtensionInfo = newValue + } + } + + static var lastLoopCompleted: Date? { + context?.lastLoopCompleted } @objc private func notificationReceived(_ notification: Notification) { @@ -43,19 +61,19 @@ final class ExtensionDataManager { } private func update() { - guard let unit = (deviceManager.glucoseStore.preferredUnit ?? context?.predictedGlucose?.unit) else { + guard let unit = (deviceManager.glucoseStore.preferredUnit ?? ExtensionDataManager.context?.predictedGlucose?.unit) else { return } createStatusContext(glucoseUnit: unit) { (context) in if let context = context { - self.defaults?.statusExtensionContext = context + ExtensionDataManager.context = context } } createIntentsContext { (info) in - if let info = info, self.defaults?.intentExtensionInfo?.overridePresetNames != info.overridePresetNames { - self.defaults?.intentExtensionInfo = info + if let info = info, ExtensionDataManager.intentExtensionInfo?.overridePresetNames != info.overridePresetNames { + ExtensionDataManager.intentExtensionInfo = info } } } @@ -158,7 +176,7 @@ extension ExtensionDataManager: CustomDebugStringConvertible { return [ "## StatusExtensionDataManager", "appGroupName: \(Bundle.main.appGroupSuiteName)", - "statusExtensionContext: \(String(reflecting: defaults?.statusExtensionContext))", + "statusExtensionContext: \(String(reflecting: ExtensionDataManager.context))", "" ].joined(separator: "\n") } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index ce82dfbb42..d85c36a964 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -156,7 +156,7 @@ class LoopAppManager: NSObject { self.pluginManager = PluginManager() self.bluetoothStateManager = BluetoothStateManager() self.alertManager = AlertManager(alertPresenter: self, - userNotificationAlertIssuer: UserNotificationAlertIssuer(userNotificationCenter: UNUserNotificationCenter.current()), + userNotificationAlertScheduler: UserNotificationAlertScheduler(userNotificationCenter: UNUserNotificationCenter.current()), expireAfter: Bundle.main.localCacheDuration, bluetoothProvider: bluetoothStateManager) @@ -166,7 +166,8 @@ class LoopAppManager: NSObject { self.trustedTimeChecker = TrustedTimeChecker(alertManager: alertManager) self.settingsManager = SettingsManager(cacheStore: cacheStore, - expireAfter: localCacheDuration) + expireAfter: localCacheDuration, + alertMuter: alertManager.alertMuter) self.deviceDataManager = DeviceDataManager(pluginManager: pluginManager, alertManager: alertManager, @@ -233,6 +234,7 @@ class LoopAppManager: NSObject { let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: Self.self)) let statusTableViewController = storyboard.instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController statusTableViewController.alertPermissionsChecker = alertPermissionsChecker + statusTableViewController.alertMuter = alertManager.alertMuter statusTableViewController.closedLoopStatus = closedLoopStatus statusTableViewController.deviceManager = deviceDataManager statusTableViewController.onboardingManager = onboardingManager @@ -442,8 +444,8 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { LoopNotificationCategory.remoteCarbsFailure.rawValue: completionHandler([.badge, .sound, .list, .banner]) default: - // All other userNotifications are not to be displayed while in the foreground - completionHandler([]) + // For all others, banners are not to be displayed while in the foreground + completionHandler([.badge, .sound, .list]) } } diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index a29d9d92b5..caf864a7db 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -30,6 +30,8 @@ class SettingsManager { var deviceStatusProvider: DeviceStatusProvider? + var alertMuter: AlertMuter + var displayGlucoseUnitObservable: DisplayGlucoseUnitObservable? public var latestSettings: StoredSettings @@ -40,9 +42,10 @@ class SettingsManager { private let log = OSLog(category: "SettingsManager") - init(cacheStore: PersistenceController, expireAfter: TimeInterval) + init(cacheStore: PersistenceController, expireAfter: TimeInterval, alertMuter: AlertMuter) { settingsStore = SettingsStore(store: cacheStore, expireAfter: expireAfter) + self.alertMuter = alertMuter if let storedSettings = settingsStore.latestSettings { latestSettings = storedSettings @@ -76,6 +79,17 @@ class SettingsManager { } } .store(in: &cancellables) + + self.alertMuter.$configuration + .sink { [weak self] alertMuterConfiguration in + guard var notificationSettings = self?.latestSettings.notificationSettings else { return } + let newTemporaryMuteAlertsSetting = NotificationSettings.TemporaryMuteAlertSetting(enabled: alertMuterConfiguration.shouldMute, duration: alertMuterConfiguration.duration) + if notificationSettings.temporaryMuteAlertsSetting != newTemporaryMuteAlertsSetting { + notificationSettings.temporaryMuteAlertsSetting = newTemporaryMuteAlertsSetting + self?.storeSettings(notificationSettings: notificationSettings) + } + } + .store(in: &cancellables) } var loopSettings: LoopSettings { @@ -105,28 +119,28 @@ class SettingsManager { let newNotificationSettings = notificationSettings ?? settingsStore.latestSettings?.notificationSettings return StoredSettings(date: Date(), - dosingEnabled: newLoopSettings.dosingEnabled, - glucoseTargetRangeSchedule: newLoopSettings.glucoseTargetRangeSchedule, - preMealTargetRange: newLoopSettings.preMealTargetRange, - workoutTargetRange: newLoopSettings.legacyWorkoutTargetRange, - overridePresets: newLoopSettings.overridePresets, - scheduleOverride: newLoopSettings.scheduleOverride, - preMealOverride: newLoopSettings.preMealOverride, - maximumBasalRatePerHour: newLoopSettings.maximumBasalRatePerHour, - maximumBolus: newLoopSettings.maximumBolus, - suspendThreshold: newLoopSettings.suspendThreshold, - deviceToken: deviceToken, - insulinType: deviceStatusProvider?.pumpManagerStatus?.insulinType, - defaultRapidActingModel: newLoopSettings.defaultRapidActingModel.map(StoredInsulinModel.init), - basalRateSchedule: newLoopSettings.basalRateSchedule, - insulinSensitivitySchedule: newLoopSettings.insulinSensitivitySchedule, - carbRatioSchedule: newLoopSettings.carbRatioSchedule, - notificationSettings: newNotificationSettings, - controllerDevice: UIDevice.current.controllerDevice, - cgmDevice: deviceStatusProvider?.cgmManagerStatus?.device, - pumpDevice: deviceStatusProvider?.pumpManagerStatus?.device, - bloodGlucoseUnit: displayGlucoseUnitObservable?.displayGlucoseUnit, - automaticDosingStrategy: newLoopSettings.automaticDosingStrategy) + dosingEnabled: newLoopSettings.dosingEnabled, + glucoseTargetRangeSchedule: newLoopSettings.glucoseTargetRangeSchedule, + preMealTargetRange: newLoopSettings.preMealTargetRange, + workoutTargetRange: newLoopSettings.legacyWorkoutTargetRange, + overridePresets: newLoopSettings.overridePresets, + scheduleOverride: newLoopSettings.scheduleOverride, + preMealOverride: newLoopSettings.preMealOverride, + maximumBasalRatePerHour: newLoopSettings.maximumBasalRatePerHour, + maximumBolus: newLoopSettings.maximumBolus, + suspendThreshold: newLoopSettings.suspendThreshold, + deviceToken: deviceToken, + insulinType: deviceStatusProvider?.pumpManagerStatus?.insulinType, + defaultRapidActingModel: newLoopSettings.defaultRapidActingModel.map(StoredInsulinModel.init), + basalRateSchedule: newLoopSettings.basalRateSchedule, + insulinSensitivitySchedule: newLoopSettings.insulinSensitivitySchedule, + carbRatioSchedule: newLoopSettings.carbRatioSchedule, + notificationSettings: newNotificationSettings, + controllerDevice: UIDevice.current.controllerDevice, + cgmDevice: deviceStatusProvider?.cgmManagerStatus?.device, + pumpDevice: deviceStatusProvider?.pumpManagerStatus?.device, + bloodGlucoseUnit: displayGlucoseUnitObservable?.displayGlucoseUnit, + automaticDosingStrategy: newLoopSettings.automaticDosingStrategy) } func storeSettings(newLoopSettings: LoopSettings? = nil, notificationSettings: NotificationSettings? = nil) { @@ -169,7 +183,8 @@ class SettingsManager { return } - let notificationSettings = NotificationSettings(notificationSettings) + let temporaryMuteAlertSetting = NotificationSettings.TemporaryMuteAlertSetting(enabled: self.alertMuter.configuration.shouldMute, duration: self.alertMuter.configuration.duration) + let notificationSettings = NotificationSettings(notificationSettings, temporaryMuteAlertsSetting: temporaryMuteAlertSetting) if notificationSettings != latestSettings.notificationSettings { @@ -202,7 +217,7 @@ extension SettingsManager: SettingsStoreDelegate { private extension NotificationSettings { - init(_ notificationSettings: UNNotificationSettings) { + init(_ notificationSettings: UNNotificationSettings, temporaryMuteAlertsSetting: TemporaryMuteAlertSetting) { let timeSensitiveSetting: NotificationSettings.NotificationSetting let scheduledDeliverySetting: NotificationSettings.NotificationSetting @@ -227,7 +242,8 @@ private extension NotificationSettings { providesAppNotificationSettings: notificationSettings.providesAppNotificationSettings, announcementSetting: NotificationSettings.NotificationSetting(notificationSettings.announcementSetting), timeSensitiveSetting: timeSensitiveSetting, - scheduledDeliverySetting: scheduledDeliverySetting + scheduledDeliverySetting: scheduledDeliverySetting, + temporaryMuteAlertsSetting: temporaryMuteAlertsSetting ) } } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index e774b2da8f..d293e2c187 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -35,7 +35,9 @@ final class StatusTableViewController: LoopChartsTableViewController { var closedLoopStatus: ClosedLoopStatus! var alertPermissionsChecker: AlertPermissionsChecker! - + + var alertMuter: AlertMuter! + var supportManager: SupportManager! lazy private var cancellables = Set() @@ -111,6 +113,16 @@ final class StatusTableViewController: LoopChartsTableViewController { .sink { self.closedLoopStatusChanged($0) } .store(in: &cancellables) + alertMuter.$configuration + .removeDuplicates() + .receive(on: RunLoop.main) + .dropFirst() + .sink { _ in + self.refreshContext.update(with: .status) + self.reloadData(animated: true) + } + .store(in: &cancellables) + if let gestureRecognizer = charts.gestureRecognizer { tableView.addGestureRecognizer(gestureRecognizer) } @@ -151,7 +163,7 @@ final class StatusTableViewController: LoopChartsTableViewController { navigationController?.setToolbarHidden(false, animated: animated) alertPermissionsChecker.checkNow() - + updateBolusProgress() onboardingManager.$isComplete @@ -644,6 +656,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case pumpSuspended(resuming: Bool) case onboardingSuspended case recommendManualGlucoseEntry + case tempMuteAlerts var hasRow: Bool { switch self { @@ -682,6 +695,8 @@ final class StatusTableViewController: LoopChartsTableViewController { !premealOverride.hasFinished() { statusRowMode = .scheduleOverrideEnabled(premealOverride) + } else if alertMuter.shouldMuteAlert() { + statusRowMode = .tempMuteAlerts } else { statusRowMode = .hidden } @@ -828,7 +843,7 @@ final class StatusTableViewController: LoopChartsTableViewController { override func updateConfiguration(using state: UICellConfigurationState) { super.updateConfiguration(using: state) - let adjustViewsForNarrowDisplay: Bool = bounds.width < 350 + let adjustViewForNarrowDisplay = bounds.width < 350 var contentConfig = defaultContentConfiguration().updated(for: state) let titleImageAttachment = NSTextAttachment() @@ -838,11 +853,11 @@ final class StatusTableViewController: LoopChartsTableViewController { titleWithImage.append(title) contentConfig.attributedText = titleWithImage contentConfig.textProperties.color = .white - contentConfig.textProperties.font = .systemFont(ofSize: adjustViewsForNarrowDisplay ? 16 : 18, weight: .bold) + contentConfig.textProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 16 : 18, weight: .bold) contentConfig.textProperties.adjustsFontSizeToFitWidth = true contentConfig.secondaryText = "Fix now by turning Notifications, Critical Alerts and Time Sensitive Notifications ON." contentConfig.secondaryTextProperties.color = .white - contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewsForNarrowDisplay ? 13 : 15) + contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 13 : 15) contentConfiguration = contentConfig var backgroundConfig = backgroundConfiguration?.updated(for: state) @@ -920,6 +935,11 @@ final class StatusTableViewController: LoopChartsTableViewController { switch StatusRow(rawValue: indexPath.row)! { case .status: switch statusRowMode { + case .tempMuteAlerts: + //TODO testing (need design to make the correct status row) + let cell = getTitleSubtitleCell() + cell.titleLabel.text = NSLocalizedString("Temp Mute Alerts", comment: "The title of the cell indicating alerts are temporarily muted") + return cell case .hidden: let cell = getTitleSubtitleCell() return cell @@ -1422,6 +1442,7 @@ final class StatusTableViewController: LoopChartsTableViewController { delegate: self) let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, + alertMuter: alertMuter, versionUpdateViewModel: versionUpdateViewModel, pumpManagerSettingsViewModel: pumpViewModel, cgmManagerSettingsViewModel: cgmViewModel, diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 96f705476b..16e58743b9 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -60,6 +60,8 @@ public class SettingsViewModel: ObservableObject { let alertPermissionsChecker: AlertPermissionsChecker + let alertMuter: AlertMuter + let versionUpdateViewModel: VersionUpdateViewModel private weak var delegate: SettingsViewModelDelegate? @@ -101,6 +103,7 @@ public class SettingsViewModel: ObservableObject { lazy private var cancellables = Set() public init(alertPermissionsChecker: AlertPermissionsChecker, + alertMuter: AlertMuter, versionUpdateViewModel: VersionUpdateViewModel, pumpManagerSettingsViewModel: PumpManagerViewModel, cgmManagerSettingsViewModel: CGMManagerViewModel, @@ -118,6 +121,7 @@ public class SettingsViewModel: ObservableObject { delegate: SettingsViewModelDelegate? ) { self.alertPermissionsChecker = alertPermissionsChecker + self.alertMuter = alertMuter self.versionUpdateViewModel = versionUpdateViewModel self.pumpManagerSettingsViewModel = pumpManagerSettingsViewModel self.cgmManagerSettingsViewModel = cgmManagerSettingsViewModel @@ -178,6 +182,7 @@ extension SettingsViewModel { static var preview: SettingsViewModel { return SettingsViewModel(alertPermissionsChecker: AlertPermissionsChecker(), + alertMuter: AlertMuter(), versionUpdateViewModel: VersionUpdateViewModel(supportManager: nil, guidanceColors: GuidanceColors()), pumpManagerSettingsViewModel: DeviceViewModel(), cgmManagerSettingsViewModel: DeviceViewModel(), diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift new file mode 100644 index 0000000000..004884ecfe --- /dev/null +++ b/Loop/Views/AlertManagementView.swift @@ -0,0 +1,115 @@ +// +// AlertManagementView.swift +// Loop +// +// Created by Nathaniel Hamming on 2022-09-09. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct AlertManagementView: View { + @Environment(\.appName) private var appName + + @ObservedObject private var checker: AlertPermissionsChecker + @ObservedObject private var alertMuter: AlertMuter + + private var formatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .full + formatter.allowedUnits = [.hour, .minute] + return formatter + }() + + private var enabled: Binding { + Binding( + get: { alertMuter.configuration.shouldMute }, + set: { enabled in + alertMuter.configuration.startTime = enabled ? Date() : nil + } + ) + } + + private var formattedSelectedDuration: Binding { + Binding( + get: { formatter.string(from: alertMuter.configuration.duration)! }, + set: { newValue in + guard let selectedDurationIndex = formatterDurations.firstIndex(of: newValue) + else { return } + DispatchQueue.main.async { + // avoid publishing during view update + alertMuter.configuration.duration = AlertMuter.allowedDurations[selectedDurationIndex] + } + } + ) + } + + private var formatterDurations: [String] { + AlertMuter.allowedDurations.compactMap { formatter.string(from: $0) } + } + + public init(checker: AlertPermissionsChecker, alertMuter: AlertMuter = AlertMuter()) { + self.checker = checker + self.alertMuter = alertMuter + } + + var body: some View { + List { + alertPermissionsSection + muteAlertsSection + + if alertMuter.configuration.shouldMute { + mutePeriodSection + } + } + .navigationTitle(NSLocalizedString("Alert Management", comment: "Title of alert management screen")) + } + + 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))) { + NavigationLink(destination: + NotificationsCriticalAlertPermissionsView(mode: .flow, checker: checker)) + { + HStack { + Text(NSLocalizedString("Alert Permissions", comment: "Alert Permissions button text")) + if checker.showWarning || + checker.notificationCenterSettings.scheduledDeliveryEnabled { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.critical) + } + } + } + } + } + + @ViewBuilder + private var muteAlertsSection: some View { + Section(footer: muteAlertsSectionFooter) { + Toggle(NSLocalizedString("Mute All Alerts", comment: "Label for toggle to mute all alerts"), isOn: enabled) + } + } + + private var mutePeriodSection: some View { + SingleSelectionCheckList(header: NSLocalizedString("Select Mute Period", comment: "List header for mute all alerts period"), footer: muteAlertsFooterString, items: formatterDurations, selectedItem: formattedSelectedDuration) + } + + @ViewBuilder + private var muteAlertsSectionFooter: some View { + if !alertMuter.configuration.shouldMute { + DescriptiveText(label: muteAlertsFooterString) + } + } + + private var muteAlertsFooterString: String { + NSLocalizedString("No alerts will sound while muted. Once this period ends, your alerts and alarms will resume as normal.", comment: "Description of temporary mute alerts") + } +} + +struct AlertManagementView_Previews: PreviewProvider { + static var previews: some View { + AlertManagementView(checker: AlertPermissionsChecker(), alertMuter: AlertMuter()) + } +} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index d25ef7d835..c5f7ec8e05 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -46,7 +46,7 @@ public struct SettingsView: View { if FeatureFlags.automaticBolusEnabled { dosingStrategySection } - alertPermissionsSection + alertManagementSection if viewModel.pumpManagerSettingsViewModel.isSetUp() { therapySettingsSection } @@ -121,13 +121,12 @@ extension SettingsView { } } - private var alertPermissionsSection: some View { + private var alertManagementSection: some View { Section { - NavigationLink(destination: - NotificationsCriticalAlertPermissionsView(mode: .flow, checker: viewModel.alertPermissionsChecker)) + NavigationLink(destination: AlertManagementView(checker: viewModel.alertPermissionsChecker, alertMuter: viewModel.alertMuter)) { HStack { - Text(NSLocalizedString("Alert Permissions", comment: "Alert Permissions button text")) + Text(NSLocalizedString("Alert Management", comment: "Alert Permissions button text")) if viewModel.alertPermissionsChecker.showWarning || viewModel.alertPermissionsChecker.notificationCenterSettings.scheduledDeliveryEnabled { Spacer() diff --git a/LoopTests/Managers/Alerts/AlertManagerTests.swift b/LoopTests/Managers/Alerts/AlertManagerTests.swift index 2bfc39a74b..921d03d0a8 100644 --- a/LoopTests/Managers/Alerts/AlertManagerTests.swift +++ b/LoopTests/Managers/Alerts/AlertManagerTests.swift @@ -29,14 +29,28 @@ class AlertManagerTests: XCTestCase { } } - class MockIssuer: AlertIssuer { - var issuedAlert: Alert? - func issueAlert(_ alert: Alert) { - issuedAlert = alert + class MockModalAlertScheduler: InAppModalAlertScheduler { + var scheduledAlert: Alert? + override func scheduleAlert(_ alert: Alert) { + scheduledAlert = alert + } + var unscheduledAlertIdentifier: Alert.Identifier? + override func unscheduleAlert(identifier: Alert.Identifier) { + unscheduledAlertIdentifier = identifier + } + } + + class MockUserNotificationAlertScheduler: UserNotificationAlertScheduler { + var scheduledAlert: Alert? + var muted: Bool? + + override func scheduleAlert(_ alert: Alert, muted: Bool) { + scheduledAlert = alert + self.muted = muted } - var retractedAlertIdentifier: Alert.Identifier? - func retractAlert(identifier: Alert.Identifier) { - retractedAlertIdentifier = identifier + var unscheduledAlertIdentifier: Alert.Identifier? + override func unscheduleAlert(identifier: Alert.Identifier) { + unscheduledAlertIdentifier = identifier } } @@ -86,6 +100,10 @@ class AlertManagerTests: XCTestCase { func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { completion?() } } + class MockAlertManagerResponder: AlertManagerResponder { + func acknowledgeAlert(identifier: LoopKit.Alert.Identifier) { } + } + class MockSoundVendor: AlertSoundVendor { func getSoundBaseURL() -> URL? { // Hm. It's not easy to make a "fake" URL, so we'll use this one: @@ -131,7 +149,7 @@ class AlertManagerTests: XCTestCase { } var storedAlerts = [StoredAlert]() - override public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String?, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + override public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { completion(.success(storedAlerts)) } @@ -143,30 +161,28 @@ class AlertManagerTests: XCTestCase { static let mockManagerIdentifier = "mockManagerIdentifier" static let mockTypeIdentifier = "mockTypeIdentifier" static let mockIdentifier = Alert.Identifier(managerIdentifier: mockManagerIdentifier, alertIdentifier: mockTypeIdentifier) - let mockAlert = Alert(identifier: mockIdentifier, foregroundContent: nil, backgroundContent: nil, trigger: .immediate) + static let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "") + let mockAlert = Alert(identifier: mockIdentifier, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .immediate) var mockFileManager: MockFileManager! var mockPresenter: MockPresenter! - var mockModalIssuer: MockIssuer! - var mockUserNotificationIssuer: MockIssuer! + var mockModalScheduler: MockModalAlertScheduler! + var mockUserNotificationScheduler: MockUserNotificationAlertScheduler! var mockAlertStore: MockAlertStore! var alertManager: AlertManager! var isInBackground = true - override class func setUp() { + override func setUp() { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() UNUserNotificationCenter.current().removeAllDeliveredNotifications() - } - - override func setUp() { mockFileManager = MockFileManager() mockPresenter = MockPresenter() - mockModalIssuer = MockIssuer() - mockUserNotificationIssuer = MockIssuer() + mockModalScheduler = MockModalAlertScheduler(alertPresenter: mockPresenter, alertManagerResponder: MockAlertManagerResponder()) + mockUserNotificationScheduler = MockUserNotificationAlertScheduler(userNotificationCenter: MockUserNotificationCenter()) mockAlertStore = MockAlertStore() alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertIssuer: mockModalIssuer, - userNotificationAlertIssuer: mockUserNotificationIssuer, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, fileManager: mockFileManager, alertStore: mockAlertStore, bluetoothProvider: MockBluetoothProvider()) @@ -178,18 +194,18 @@ class AlertManagerTests: XCTestCase { func testIssueAlertOnHandlerCalled() { alertManager.issueAlert(mockAlert) - XCTAssertEqual(mockAlert.identifier, mockModalIssuer.issuedAlert?.identifier) - XCTAssertEqual(mockAlert.identifier, mockUserNotificationIssuer.issuedAlert?.identifier) - XCTAssertNil(mockModalIssuer.retractedAlertIdentifier) - XCTAssertNil(mockUserNotificationIssuer.retractedAlertIdentifier) + XCTAssertEqual(mockAlert.identifier, mockModalScheduler.scheduledAlert?.identifier) + XCTAssertEqual(mockAlert.identifier, mockUserNotificationScheduler.scheduledAlert?.identifier) + XCTAssertNil(mockModalScheduler.unscheduledAlertIdentifier) + XCTAssertNil(mockUserNotificationScheduler.unscheduledAlertIdentifier) } func testRetractAlertOnHandlerCalled() { alertManager.retractAlert(identifier: mockAlert.identifier) - XCTAssertNil(mockModalIssuer.issuedAlert) - XCTAssertNil(mockUserNotificationIssuer.issuedAlert) - XCTAssertEqual(mockAlert.identifier, mockModalIssuer.retractedAlertIdentifier) - XCTAssertEqual(mockAlert.identifier, mockUserNotificationIssuer.retractedAlertIdentifier) + XCTAssertNil(mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) + XCTAssertEqual(mockAlert.identifier, mockModalScheduler.unscheduledAlertIdentifier) + XCTAssertEqual(mockAlert.identifier, mockUserNotificationScheduler.unscheduledAlertIdentifier) } func testAlertResponderAcknowledged() { @@ -242,14 +258,14 @@ class AlertManagerTests: XCTestCase { mockAlertStore.storedAlerts = [StoredAlert(from: alert, context: mockAlertStore.managedObjectContext)] alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertIssuer: mockModalIssuer, - userNotificationAlertIssuer: mockUserNotificationIssuer, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, fileManager: mockFileManager, alertStore: mockAlertStore, bluetoothProvider: MockBluetoothProvider()) alertManager.playbackAlertsFromPersistence() - XCTAssertEqual(alert, mockModalIssuer.issuedAlert) - XCTAssertNil(mockUserNotificationIssuer.issuedAlert) + XCTAssertEqual(alert, mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) } } @@ -263,15 +279,15 @@ class AlertManagerTests: XCTestCase { storedAlert.issuedDate = date mockAlertStore.storedAlerts = [storedAlert] alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertIssuer: mockModalIssuer, - userNotificationAlertIssuer: mockUserNotificationIssuer, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, fileManager: mockFileManager, alertStore: mockAlertStore, bluetoothProvider: MockBluetoothProvider()) alertManager.playbackAlertsFromPersistence() let expected = Alert(identifier: Self.mockIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate) - XCTAssertEqual(expected, mockModalIssuer.issuedAlert) - XCTAssertNil(mockUserNotificationIssuer.issuedAlert) + XCTAssertEqual(expected, mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) } } @@ -285,8 +301,8 @@ class AlertManagerTests: XCTestCase { storedAlert.issuedDate = date mockAlertStore.storedAlerts = [storedAlert] alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertIssuer: mockModalIssuer, - userNotificationAlertIssuer: mockUserNotificationIssuer, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, fileManager: mockFileManager, alertStore: mockAlertStore, bluetoothProvider: MockBluetoothProvider()) @@ -295,12 +311,12 @@ class AlertManagerTests: XCTestCase { // The trigger for this should be `.delayed` by "something less than 15 seconds", // but the exact value depends on the speed of executing this test. // As long as it is <= 15 seconds, we call it good. - XCTAssertNotNil(mockModalIssuer.issuedAlert) - switch mockModalIssuer.issuedAlert?.trigger { + XCTAssertNotNil(mockModalScheduler.scheduledAlert) + switch mockModalScheduler.scheduledAlert?.trigger { case .some(.delayed(let interval)): XCTAssertLessThanOrEqual(interval, 15.0) default: - XCTFail("Wrong trigger \(String(describing: mockModalIssuer.issuedAlert?.trigger))") + XCTFail("Wrong trigger \(String(describing: mockModalScheduler.scheduledAlert?.trigger))") } } } @@ -315,15 +331,15 @@ class AlertManagerTests: XCTestCase { storedAlert.issuedDate = date mockAlertStore.storedAlerts = [storedAlert] alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertIssuer: mockModalIssuer, - userNotificationAlertIssuer: mockUserNotificationIssuer, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, fileManager: mockFileManager, alertStore: mockAlertStore, bluetoothProvider: MockBluetoothProvider()) alertManager.playbackAlertsFromPersistence() - XCTAssertEqual(alert, mockModalIssuer.issuedAlert) - XCTAssertNil(mockUserNotificationIssuer.issuedAlert) + XCTAssertEqual(alert, mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) } } @@ -337,8 +353,8 @@ class AlertManagerTests: XCTestCase { storedAlert.issuedDate = date mockAlertStore.storedAlerts = [storedAlert] alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertIssuer: mockModalIssuer, - userNotificationAlertIssuer: mockUserNotificationIssuer, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, fileManager: mockFileManager, alertStore: mockAlertStore, bluetoothProvider: MockBluetoothProvider()) @@ -359,8 +375,8 @@ class AlertManagerTests: XCTestCase { storedAlert.issuedDate = date mockAlertStore.storedAlerts = [storedAlert] alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertIssuer: mockModalIssuer, - userNotificationAlertIssuer: mockUserNotificationIssuer, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, fileManager: mockFileManager, alertStore: mockAlertStore, bluetoothProvider: MockBluetoothProvider()) @@ -381,8 +397,8 @@ class AlertManagerTests: XCTestCase { storedAlert.issuedDate = date mockAlertStore.storedAlerts = [storedAlert] alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertIssuer: mockModalIssuer, - userNotificationAlertIssuer: mockUserNotificationIssuer, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, fileManager: mockFileManager, alertStore: mockAlertStore, bluetoothProvider: MockBluetoothProvider()) @@ -404,8 +420,8 @@ class AlertManagerTests: XCTestCase { foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) mockAlertStore.storedAlerts = [] alertManager = AlertManager(alertPresenter: mockPresenter, - modalAlertIssuer: mockModalIssuer, - userNotificationAlertIssuer: mockUserNotificationIssuer, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, fileManager: mockFileManager, alertStore: mockAlertStore, bluetoothProvider: MockBluetoothProvider()) @@ -418,13 +434,13 @@ class AlertManagerTests: XCTestCase { func testScheduleAlertForWorkoutReminder() { alertManager.presetActivated(context: .legacyWorkout, duration: .indefinite) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockModalIssuer.issuedAlert?.identifier) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockUserNotificationIssuer.issuedAlert?.identifier) + XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockModalScheduler.scheduledAlert?.identifier) + XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockUserNotificationScheduler.scheduledAlert?.identifier) XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockAlertStore.issuedAlert?.identifier) alertManager.presetDeactivated(context: .legacyWorkout) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockModalIssuer.retractedAlertIdentifier) - XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockUserNotificationIssuer.retractedAlertIdentifier) + XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockModalScheduler.unscheduledAlertIdentifier) + XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockUserNotificationScheduler.unscheduledAlertIdentifier) XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockAlertStore.retractededAlertIdentifier) } @@ -460,7 +476,25 @@ class AlertManagerTests: XCTestCase { XCTAssertEqual(.critical, mockAlertStore.issuedAlert!.interruptionLevel) } + func testRescheduleMutedLoopNotLoopingAlerts() { + let lastLoopDate = Date() + alertManager.loopDidComplete(lastLoopDate) + alertManager.alertMuter.configuration.startTime = Date() + alertManager.alertMuter.configuration.duration = .hours(4) + + let testExpectation = expectation(description: #function) + var loopNotRunningRequests: [UNNotificationRequest] = [] + UNUserNotificationCenter.current().getPendingNotificationRequests() { notificationRequests in + loopNotRunningRequests = notificationRequests.filter({ + $0.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue + }) + testExpectation.fulfill() + } + wait(for: [testExpectation], timeout: 1) + XCTAssertNil(loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .timeSensitive })!.content.sound) + XCTAssertEqual(loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .critical })!.content.sound, .defaultCriticalSound(withAudioVolume: 0)) + } } extension Swift.Result { diff --git a/LoopTests/Managers/Alerts/AlertMuterTests.swift b/LoopTests/Managers/Alerts/AlertMuterTests.swift new file mode 100644 index 0000000000..bcabfb33da --- /dev/null +++ b/LoopTests/Managers/Alerts/AlertMuterTests.swift @@ -0,0 +1,176 @@ +// +// AlertMuterTests.swift +// LoopTests +// +// Created by Nathaniel Hamming on 2022-09-29. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import XCTest +import Combine +import LoopKit +@testable import Loop + +final class AlertMuterTests: XCTestCase { + + func testInitialization() { + var alertMuter = AlertMuter(duration: AlertMuter.allowedDurations[1]) + XCTAssertFalse(alertMuter.configuration.shouldMute) + XCTAssertEqual(alertMuter.configuration.duration, AlertMuter.allowedDurations[1]) + XCTAssertNil(alertMuter.configuration.startTime) + + let now = Date() + alertMuter = AlertMuter(startTime: now) + XCTAssertTrue(alertMuter.configuration.shouldMute) + XCTAssertEqual(alertMuter.configuration.duration, AlertMuter.allowedDurations[0]) + XCTAssertEqual(alertMuter.configuration.startTime, now) + } + + func testPublishingUpdateDuration() { + var cancellables: Set = [] + let alertMuter = AlertMuter() + var receivedConfiguration: AlertMuter.Configuration? + let testExpection = expectation(description: #function) + testExpection.assertForOverFulfill = false + alertMuter.$configuration + .sink { configuration in + receivedConfiguration = configuration + testExpection.fulfill() + } + .store(in: &cancellables) + + alertMuter.configuration.duration = .minutes(30) + wait(for: [testExpection], timeout: 1) + XCTAssertEqual(receivedConfiguration, alertMuter.configuration) + } + + func testPublishingUpdateStartTime() { + var cancellables: Set = [] + let alertMuter = AlertMuter() + var receivedConfiguration: AlertMuter.Configuration? + let testExpection = expectation(description: #function) + testExpection.assertForOverFulfill = false + alertMuter.$configuration + .sink { configuration in + receivedConfiguration = configuration + testExpection.fulfill() + } + .store(in: &cancellables) + + alertMuter.configuration.startTime = Date() + wait(for: [testExpection], timeout: 1) + XCTAssertEqual(receivedConfiguration, alertMuter.configuration) + } + + func testPublishingMutePeriodEnded() { + var cancellables: Set = [] + let alertMuter = AlertMuter() + var receivedConfiguration: AlertMuter.Configuration? + let testExpection = expectation(description: #function) + testExpection.assertForOverFulfill = false + alertMuter.configuration.startTime = Date() + alertMuter.configuration.duration = .seconds(0.5) + + alertMuter.$configuration + .sink { configuration in + receivedConfiguration = configuration + testExpection.fulfill() + } + .store(in: &cancellables) + + wait(for: [testExpection], timeout: 1) + XCTAssertEqual(receivedConfiguration, alertMuter.configuration) + } + + func testShouldMuteAlertIssuedFromNow() { + let alertMuter = AlertMuter() + XCTAssertFalse(alertMuter.shouldMuteAlert()) + XCTAssertFalse(alertMuter.shouldMuteAlert(scheduledAt: -1)) + + let duration = TimeInterval.minutes(45) + alertMuter.configuration.duration = duration + alertMuter.configuration.startTime = Date() + XCTAssertTrue(alertMuter.shouldMuteAlert()) + XCTAssertFalse(alertMuter.shouldMuteAlert(scheduledAt: duration)) + } + + func testShouldMuteAlert() { + let duration = TimeInterval.seconds(10) + let now = Date() + let durationExpired = now.addingTimeInterval(duration) + let alertMuter = AlertMuter(startTime: now, duration: duration) + let immediateAlert = LoopKit.Alert(identifier: Alert.Identifier(managerIdentifier: "test", alertIdentifier: "test"), foregroundContent: nil, backgroundContent: Alert.Content(title: "test", body: "test", acknowledgeActionButtonLabel: "OK"), trigger: .immediate) + XCTAssertTrue(alertMuter.shouldMuteAlert(immediateAlert)) + XCTAssertTrue(alertMuter.shouldMuteAlert(immediateAlert, issuedDate: now, now: now)) + XCTAssertFalse(alertMuter.shouldMuteAlert(immediateAlert, issuedDate: durationExpired, now: now)) + + let delayedAlert = LoopKit.Alert(identifier: Alert.Identifier(managerIdentifier: "test", alertIdentifier: "test"), foregroundContent: nil, backgroundContent: Alert.Content(title: "test", body: "test", acknowledgeActionButtonLabel: "OK"), trigger: .delayed(interval: duration/5)) + XCTAssertTrue(alertMuter.shouldMuteAlert(delayedAlert, issuedDate: now, now: now)) + XCTAssertFalse(alertMuter.shouldMuteAlert(delayedAlert, issuedDate: durationExpired, now: now)) + + let repeatedAlert = LoopKit.Alert(identifier: Alert.Identifier(managerIdentifier: "test", alertIdentifier: "test"), foregroundContent: nil, backgroundContent: Alert.Content(title: "test", body: "test", acknowledgeActionButtonLabel: "OK"), trigger: .repeating(repeatInterval: duration/2)) + XCTAssertTrue(alertMuter.shouldMuteAlert(repeatedAlert, issuedDate: now, now: now)) + XCTAssertFalse(alertMuter.shouldMuteAlert(repeatedAlert, issuedDate: durationExpired, now: now)) + } + + // MARK: Configuration Tests + + func testRawValue() { + let now = Date() + let alertMuter = AlertMuter(startTime: now) + let rawValue = alertMuter.configuration.rawValue + XCTAssertEqual(rawValue["duration"] as? TimeInterval, alertMuter.configuration.duration) + XCTAssertEqual(rawValue["startTime"] as? Date, alertMuter.configuration.startTime) + } + + func testInitFromRawValue() { + let duration = TimeInterval.minutes(30) + let now = Date() + let rawValue: [String: Any] = ["duration": duration, "startTime": now] + + let configuration = AlertMuter.Configuration(rawValue: rawValue) + XCTAssertEqual(duration, configuration?.duration) + XCTAssertEqual(now, configuration?.startTime) + } + + func testInitFromRawValueNil() { + let rawValue = ["startTime": Date()] + XCTAssertNil(AlertMuter.Configuration(rawValue: rawValue)) + } + + func testShouldMute() { + var configuration = AlertMuter.Configuration() + XCTAssertFalse(configuration.shouldMute) + + configuration.startTime = Date() + XCTAssertTrue(configuration.shouldMute) + + let duration = TimeInterval.minutes(45) + configuration.duration = duration + configuration.startTime = Date().addingTimeInterval(-(duration+1)) + XCTAssertFalse(configuration.shouldMute) + } + + func testMutingEndTime() { + var configuration = AlertMuter.Configuration() + XCTAssertNil(configuration.mutingEndTime) + + let duration = TimeInterval.minutes(45) + configuration.duration = duration + let now = Date() + configuration.startTime = now + XCTAssertEqual(configuration.mutingEndTime, now.addingTimeInterval(duration)) + } + + func testShouldMuteAlertScheduledAt() { + var configuration = AlertMuter.Configuration() + XCTAssertFalse(configuration.shouldMuteAlert()) + XCTAssertFalse(configuration.shouldMuteAlert(scheduledAt: -1)) + + let duration = TimeInterval.minutes(45) + configuration.duration = duration + configuration.startTime = Date() + XCTAssertTrue(configuration.shouldMuteAlert()) + XCTAssertFalse(configuration.shouldMuteAlert(scheduledAt: duration)) + } +} diff --git a/LoopTests/Managers/Alerts/AlertStoreTests.swift b/LoopTests/Managers/Alerts/AlertStoreTests.swift index 15cbab81ac..3f6286cf17 100644 --- a/LoopTests/Managers/Alerts/AlertStoreTests.swift +++ b/LoopTests/Managers/Alerts/AlertStoreTests.swift @@ -20,16 +20,17 @@ class AlertStoreTests: XCTestCase { static let historicDate = Date(timeIntervalSinceNow: -expiryInterval + TimeInterval.hours(4)) // Within default 24 hour expiration static let identifier1 = Alert.Identifier(managerIdentifier: "managerIdentifier1", alertIdentifier: "alertIdentifier1") - let alert1 = Alert(identifier: identifier1, foregroundContent: nil, backgroundContent: nil, trigger: .immediate, sound: nil) + static let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "OK") + let alert1 = Alert(identifier: identifier1, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .immediate, sound: nil) static let identifier2 = Alert.Identifier(managerIdentifier: "managerIdentifier2", alertIdentifier: "alertIdentifier2") static let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") let alert2 = Alert(identifier: identifier2, foregroundContent: content, backgroundContent: content, trigger: .immediate, interruptionLevel: .critical, sound: .sound(name: "soundName")) static let delayedAlertDelay = 30.0 // seconds static let delayedAlertIdentifier = Alert.Identifier(managerIdentifier: "managerIdentifier3", alertIdentifier: "alertIdentifier3") - let delayedAlert = Alert(identifier: delayedAlertIdentifier, foregroundContent: nil, backgroundContent: nil, trigger: .delayed(interval: delayedAlertDelay), sound: nil) + let delayedAlert = Alert(identifier: delayedAlertIdentifier, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .delayed(interval: delayedAlertDelay), sound: nil) static let repeatingAlertDelay = 30.0 // seconds static let repeatingAlertIdentifier = Alert.Identifier(managerIdentifier: "managerIdentifier4", alertIdentifier: "alertIdentifier4") - let repeatingAlert = Alert(identifier: repeatingAlertIdentifier, foregroundContent: nil, backgroundContent: nil, trigger: .repeating(repeatInterval: repeatingAlertDelay), sound: nil) + let repeatingAlert = Alert(identifier: repeatingAlertIdentifier, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .repeating(repeatInterval: repeatingAlertDelay), sound: nil) override func setUp() { alertStore = AlertStore(expireAfter: Self.expiryInterval) @@ -825,11 +826,11 @@ class AlertStoreLogCriticalEventLogTests: XCTestCase { override func setUp() { super.setUp() - let alerts = [AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:08:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m1", alertIdentifier: "a1"), foregroundContent: nil, backgroundContent: nil, trigger: .immediate), syncIdentifier: UUID(uuidString: "52A046F7-F449-49B2-B003-7A378D0002DE")!), - AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:10:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m2", alertIdentifier: "a2"), foregroundContent: nil, backgroundContent: nil, trigger: .immediate), syncIdentifier: UUID(uuidString: "0929E349-972F-4B06-9808-68914A541515")!), - AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:04:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m3", alertIdentifier: "a3"), foregroundContent: nil, backgroundContent: nil, trigger: .immediate), syncIdentifier: UUID(uuidString: "285AEA4B-0DEE-41F4-8669-800E9582A6E7")!), - AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:06:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m4", alertIdentifier: "a4"), foregroundContent: nil, backgroundContent: nil, trigger: .immediate), syncIdentifier: UUID(uuidString: "4B3109BD-DE11-42BD-A777-D4783459C483")!), - AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:02:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m5", alertIdentifier: "a5"), foregroundContent: nil, backgroundContent: nil, trigger: .immediate), syncIdentifier: UUID(uuidString: "48C8ACC7-9DB7-411D-B5A3-CD907D464B78")!)] + let alerts = [AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:08:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m1", alertIdentifier: "a1"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "52A046F7-F449-49B2-B003-7A378D0002DE")!), + AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:10:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m2", alertIdentifier: "a2"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "0929E349-972F-4B06-9808-68914A541515")!), + AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:04:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m3", alertIdentifier: "a3"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "285AEA4B-0DEE-41F4-8669-800E9582A6E7")!), + AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:06:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m4", alertIdentifier: "a4"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "4B3109BD-DE11-42BD-A777-D4783459C483")!), + AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:02:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m5", alertIdentifier: "a5"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "48C8ACC7-9DB7-411D-B5A3-CD907D464B78")!)] alertStore = AlertStore() XCTAssertNil(alertStore.addAlerts(alerts: alerts)) @@ -871,9 +872,9 @@ class AlertStoreLogCriticalEventLogTests: XCTestCase { progress: progress)) XCTAssertEqual(outputStream.string, """ [ -{"acknowledgedDate":"2100-01-02T03:08:00.000Z","alertIdentifier":"a1","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:08:00.000Z","managerIdentifier":"m1","modificationCounter":1,"syncIdentifier":"52A046F7-F449-49B2-B003-7A378D0002DE","triggerType":0}, -{"acknowledgedDate":"2100-01-02T03:04:00.000Z","alertIdentifier":"a3","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:04:00.000Z","managerIdentifier":"m3","modificationCounter":3,"syncIdentifier":"285AEA4B-0DEE-41F4-8669-800E9582A6E7","triggerType":0}, -{"acknowledgedDate":"2100-01-02T03:06:00.000Z","alertIdentifier":"a4","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:06:00.000Z","managerIdentifier":"m4","modificationCounter":4,"syncIdentifier":"4B3109BD-DE11-42BD-A777-D4783459C483","triggerType":0} +{"acknowledgedDate":"2100-01-02T03:08:00.000Z","alertIdentifier":"a1","backgroundContent":"{\\\"title\\\":\\\"BACKGROUND\\\",\\\"acknowledgeActionButtonLabel\\\":\\\"OK\\\",\\\"body\\\":\\\"background\\\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:08:00.000Z","managerIdentifier":"m1","modificationCounter":1,"syncIdentifier":"52A046F7-F449-49B2-B003-7A378D0002DE","triggerType":0}, +{"acknowledgedDate":"2100-01-02T03:04:00.000Z","alertIdentifier":"a3","backgroundContent":"{\\\"title\\\":\\\"BACKGROUND\\\",\\\"acknowledgeActionButtonLabel\\\":\\\"OK\\\",\\\"body\\\":\\\"background\\\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:04:00.000Z","managerIdentifier":"m3","modificationCounter":3,"syncIdentifier":"285AEA4B-0DEE-41F4-8669-800E9582A6E7","triggerType":0}, +{"acknowledgedDate":"2100-01-02T03:06:00.000Z","alertIdentifier":"a4","backgroundContent":"{\\\"title\\\":\\\"BACKGROUND\\\",\\\"acknowledgeActionButtonLabel\\\":\\\"OK\\\",\\\"body\\\":\\\"background\\\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:06:00.000Z","managerIdentifier":"m4","modificationCounter":4,"syncIdentifier":"4B3109BD-DE11-42BD-A777-D4783459C483","triggerType":0} ] """ ) diff --git a/LoopTests/Managers/Alerts/InAppModalAlertIssuerTests.swift b/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift similarity index 75% rename from LoopTests/Managers/Alerts/InAppModalAlertIssuerTests.swift rename to LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift index 7d164a9849..ee2c47606b 100644 --- a/LoopTests/Managers/Alerts/InAppModalAlertIssuerTests.swift +++ b/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift @@ -1,5 +1,5 @@ // -// InAppModalAlertIssuerTests.swift +// InAppModalAlertSchedulerTests.swift // LoopTests // // Created by Rick Pasetto on 4/15/20. @@ -10,7 +10,7 @@ import LoopKit import XCTest @testable import Loop -class InAppModalAlertIssuerTests: XCTestCase { +class InAppModalAlertSchedulerTests: XCTestCase { class MockAlertAction: UIAlertAction { typealias Handler = ((UIAlertAction) -> Void) @@ -73,21 +73,6 @@ class InAppModalAlertIssuerTests: XCTestCase { } } - class MockSoundPlayer: AlertSoundPlayer { - var vibrateCalled = false - func vibrate() { - vibrateCalled = true - } - var urlPlayed: URL? - func play(url: URL) { - urlPlayed = url - } - var stopAllCalled = false - func stopAll() { - stopAllCalled = true - } - } - static let managerIdentifier = "managerIdentifier" let alertIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: "bar") let foregroundContent = Alert.Content(title: "FOREGROUND", body: "foreground", acknowledgeActionButtonLabel: "") @@ -98,38 +83,33 @@ class InAppModalAlertIssuerTests: XCTestCase { var mockTimerRepeats: Bool? var mockAlertManagerResponder: MockAlertManagerResponder! var mockViewController: MockViewController! - var mockSoundPlayer: MockSoundPlayer! - var inAppModalAlertIssuer: InAppModalAlertIssuer! + var inAppModalAlertScheduler: InAppModalAlertScheduler! override func setUp() { mockAlertManagerResponder = MockAlertManagerResponder() mockViewController = MockViewController() - mockSoundPlayer = MockSoundPlayer() - - let newTimerFunc: InAppModalAlertIssuer.TimerFactoryFunction = { timeInterval, repeats, block in + + let newTimerFunc: InAppModalAlertScheduler.TimerFactoryFunction = { timeInterval, repeats, block in let timer = Timer(timeInterval: timeInterval, repeats: repeats) { _ in block?() } self.mockTimer = timer self.mockTimerTimeInterval = timeInterval self.mockTimerRepeats = repeats return timer } - inAppModalAlertIssuer = InAppModalAlertIssuer(alertPresenter: mockViewController, - alertManagerResponder: mockAlertManagerResponder, - soundPlayer: mockSoundPlayer, - newActionFunc: MockAlertAction.init, - newTimerFunc: newTimerFunc) + inAppModalAlertScheduler = InAppModalAlertScheduler(alertPresenter: mockViewController, + alertManagerResponder: mockAlertManagerResponder, + newActionFunc: MockAlertAction.init, + newTimerFunc: newTimerFunc) } func testIssueImmediateAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() let alertController = mockViewController.viewControllerPresented as? UIAlertController XCTAssertNotNil(alertController) XCTAssertEqual("FOREGROUND", alertController?.title) - XCTAssertEqual(nil, mockSoundPlayer.urlPlayed?.absoluteString) - XCTAssertFalse(mockSoundPlayer.vibrateCalled) } func testIssueImmediateAlertWithSound() { @@ -139,14 +119,12 @@ class InAppModalAlertIssuerTests: XCTestCase { backgroundContent: backgroundContent, trigger: .immediate, sound: .sound(name: soundName)) - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() let alertController = mockViewController.viewControllerPresented as? UIAlertController XCTAssertNotNil(alertController) XCTAssertEqual("FOREGROUND", alertController?.title) - XCTAssertEqual("\(InAppModalAlertIssuerTests.managerIdentifier)-\(soundName)", mockSoundPlayer.urlPlayed?.lastPathComponent) - XCTAssertTrue(mockSoundPlayer.vibrateCalled) } func testIssueImmediateAlertWithVibrate() { @@ -155,42 +133,24 @@ class InAppModalAlertIssuerTests: XCTestCase { backgroundContent: backgroundContent, trigger: .immediate, sound: .vibrate) - inAppModalAlertIssuer.issueAlert(alert) - - waitOnMain() - let alertController = mockViewController.viewControllerPresented as? UIAlertController - XCTAssertNotNil(alertController) - XCTAssertEqual("FOREGROUND", alertController?.title) - XCTAssertEqual(nil, mockSoundPlayer.urlPlayed?.absoluteString) - XCTAssertTrue(mockSoundPlayer.vibrateCalled) - } - - func testIssueImmediateAlertWithSilence() { - let alert = Alert(identifier: alertIdentifier, - foregroundContent: foregroundContent, - backgroundContent: backgroundContent, - trigger: .immediate, - sound: .silence) - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() let alertController = mockViewController.viewControllerPresented as? UIAlertController XCTAssertNotNil(alertController) XCTAssertEqual("FOREGROUND", alertController?.title) - XCTAssertEqual(nil, mockSoundPlayer.urlPlayed?.absoluteString) - XCTAssertFalse(mockSoundPlayer.vibrateCalled) } - + func testRemoveImmediateAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() let alertControllerPresented = mockViewController.viewControllerPresented as? UIAlertController XCTAssertNotNil(alertControllerPresented) var dismissed = false - inAppModalAlertIssuer.removePresentedAlert(identifier: alert.identifier) { + inAppModalAlertScheduler.removePresentedAlert(identifier: alert.identifier) { dismissed = true } @@ -204,17 +164,17 @@ class InAppModalAlertIssuerTests: XCTestCase { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) mockViewController.autoComplete = false - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() mockViewController.viewControllerPresented = nil - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) XCTAssertNil(mockViewController.viewControllerPresented) } func testIssueImmediateAlertWithoutForegroundContentDoesNothing() { let alert = Alert(identifier: alertIdentifier, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .immediate) - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() XCTAssertNil(mockViewController.viewControllerPresented) @@ -222,7 +182,7 @@ class InAppModalAlertIssuerTests: XCTestCase { func testIssueImmediateAlertAcknowledgement() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() let action = (mockViewController.viewControllerPresented as? UIAlertController)?.actions[0] as? MockAlertAction XCTAssertNotNil(action) @@ -234,7 +194,7 @@ class InAppModalAlertIssuerTests: XCTestCase { func testIssueDelayedAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) mockViewController.autoComplete = false - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() // Timer should be created but won't fire yet @@ -253,13 +213,13 @@ class InAppModalAlertIssuerTests: XCTestCase { func testIssueDelayedAlertTwiceOnlyOneWorks() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) mockViewController.autoComplete = false - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() guard let firstTimer = mockTimer else { XCTFail(); return } mockTimer = nil // This should not schedule another timer - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() XCTAssertNil(mockTimer) @@ -273,7 +233,7 @@ class InAppModalAlertIssuerTests: XCTestCase { func testIssueDelayedAlertWithoutForegroundContentDoesNothing() { let alert = Alert(identifier: alertIdentifier, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() XCTAssertNil(mockViewController.viewControllerPresented) @@ -281,11 +241,11 @@ class InAppModalAlertIssuerTests: XCTestCase { func testRetractAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() XCTAssert(mockTimer?.isValid == true) - inAppModalAlertIssuer.retractAlert(identifier: alert.identifier) + inAppModalAlertScheduler.unscheduleAlert(identifier: alert.identifier) waitOnMain() XCTAssert(mockTimer?.isValid == false) @@ -294,7 +254,7 @@ class InAppModalAlertIssuerTests: XCTestCase { func testIssueRepeatingAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .repeating(repeatInterval: 0.1)) mockViewController.autoComplete = false - inAppModalAlertIssuer.issueAlert(alert) + inAppModalAlertScheduler.scheduleAlert(alert) waitOnMain() // Timer should be created but won't fire yet diff --git a/LoopTests/Managers/Alerts/StoredAlertTests.swift b/LoopTests/Managers/Alerts/StoredAlertTests.swift index 2c295f9adc..504a672fae 100644 --- a/LoopTests/Managers/Alerts/StoredAlertTests.swift +++ b/LoopTests/Managers/Alerts/StoredAlertTests.swift @@ -39,14 +39,16 @@ class StoredAlertEncodableTests: XCTestCase { } func testInterruptionLevel() throws { + let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "OK") managedObjectContext.performAndWait { - let alert = Alert(identifier: Alert.Identifier(managerIdentifier: "foo", alertIdentifier: "bar"), foregroundContent: nil, backgroundContent: nil, trigger: .immediate, interruptionLevel: .active) + let alert = Alert(identifier: Alert.Identifier(managerIdentifier: "foo", alertIdentifier: "bar"), foregroundContent: nil, backgroundContent: backgroundContent, trigger: .immediate, interruptionLevel: .active) let storedAlert = StoredAlert(from: alert, context: managedObjectContext, syncIdentifier: UUID(uuidString: "A7073F28-0322-4506-A733-CF6E0687BAF7")!) XCTAssertEqual(.active, storedAlert.interruptionLevel) storedAlert.issuedDate = dateFormatter.date(from: "2020-05-14T21:00:12Z")! try! assertStoredAlertEncodable(storedAlert, encodesJSON: """ { "alertIdentifier" : "bar", + "backgroundContent" : "{\\\"title\\\":\\\"BACKGROUND\\\",\\\"acknowledgeActionButtonLabel\\\":\\\"OK\\\",\\\"body\\\":\\\"background\\\"}", "interruptionLevel" : "active", "issuedDate" : "2020-05-14T21:00:12Z", "managerIdentifier" : "foo", @@ -62,6 +64,7 @@ class StoredAlertEncodableTests: XCTestCase { try! assertStoredAlertEncodable(storedAlert, encodesJSON: """ { "alertIdentifier" : "bar", + "backgroundContent" : "{\\\"title\\\":\\\"BACKGROUND\\\",\\\"acknowledgeActionButtonLabel\\\":\\\"OK\\\",\\\"body\\\":\\\"background\\\"}", "interruptionLevel" : "critical", "issuedDate" : "2020-05-14T21:00:12Z", "managerIdentifier" : "foo", diff --git a/LoopTests/Managers/Alerts/UserNotificationAlertIssuerTests.swift b/LoopTests/Managers/Alerts/UserNotificationAlertSchedulerTests.swift similarity index 65% rename from LoopTests/Managers/Alerts/UserNotificationAlertIssuerTests.swift rename to LoopTests/Managers/Alerts/UserNotificationAlertSchedulerTests.swift index bb61cb2015..df6e7fba69 100644 --- a/LoopTests/Managers/Alerts/UserNotificationAlertIssuerTests.swift +++ b/LoopTests/Managers/Alerts/UserNotificationAlertSchedulerTests.swift @@ -1,5 +1,5 @@ // -// UserNotificationAlertIssuerTests.swift +// UserNotificationAlertSchedulerTests.swift // LoopTests // // Created by Rick Pasetto on 4/15/20. @@ -10,9 +10,9 @@ import LoopKit import XCTest @testable import Loop -class UserNotificationAlertIssuerTests: XCTestCase { +class UserNotificationAlertSchedulerTests: XCTestCase { - var userNotificationAlertIssuer: UserNotificationAlertIssuer! + var userNotificationAlertScheduler: UserNotificationAlertScheduler! let alertIdentifier = Alert.Identifier(managerIdentifier: "foo", alertIdentifier: "bar") let foregroundContent = Alert.Content(title: "FOREGROUND", body: "foreground", acknowledgeActionButtonLabel: "") @@ -22,13 +22,13 @@ class UserNotificationAlertIssuerTests: XCTestCase { override func setUp() { mockUserNotificationCenter = MockUserNotificationCenter() - userNotificationAlertIssuer = - UserNotificationAlertIssuer(userNotificationCenter: mockUserNotificationCenter) + userNotificationAlertScheduler = + UserNotificationAlertScheduler(userNotificationCenter: mockUserNotificationCenter) } func testIssueImmediateAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) - userNotificationAlertIssuer.issueAlert(alert, timestamp: Date.distantPast) + userNotificationAlertScheduler.scheduleAlert(alert, timestamp: Date.distantPast) waitOnMain() @@ -49,7 +49,7 @@ class UserNotificationAlertIssuerTests: XCTestCase { func testIssueImmediateCriticalAlert() { let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "") let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate, interruptionLevel: .critical) - userNotificationAlertIssuer.issueAlert(alert, timestamp: Date.distantPast) + userNotificationAlertScheduler.scheduleAlert(alert, timestamp: Date.distantPast) waitOnMain() @@ -69,7 +69,7 @@ class UserNotificationAlertIssuerTests: XCTestCase { func testIssueDelayedAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) - userNotificationAlertIssuer.issueAlert(alert, timestamp: Date.distantPast) + userNotificationAlertScheduler.scheduleAlert(alert, timestamp: Date.distantPast) waitOnMain() @@ -90,7 +90,7 @@ class UserNotificationAlertIssuerTests: XCTestCase { func testIssueRepeatingAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .repeating(repeatInterval: 100)) - userNotificationAlertIssuer.issueAlert(alert, timestamp: Date.distantPast) + userNotificationAlertScheduler.scheduleAlert(alert, timestamp: Date.distantPast) waitOnMain() @@ -111,25 +111,56 @@ class UserNotificationAlertIssuerTests: XCTestCase { func testRetractAlert() { let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) - userNotificationAlertIssuer.issueAlert(alert) + userNotificationAlertScheduler.scheduleAlert(alert) waitOnMain() mockUserNotificationCenter.deliverAll() - userNotificationAlertIssuer.retractAlert(identifier: alert.identifier) + userNotificationAlertScheduler.unscheduleAlert(identifier: alert.identifier) waitOnMain() XCTAssertTrue(mockUserNotificationCenter.pendingRequests.isEmpty) XCTAssertTrue(mockUserNotificationCenter.deliveredRequests.isEmpty) } - - func testDoesNotShowIfNoBackgroundContent() { - let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: nil, trigger: .immediate) - userNotificationAlertIssuer.issueAlert(alert) + func testIssueMutedAlert() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) + userNotificationAlertScheduler.scheduleAlert(alert, timestamp: Date.distantPast, muted: true) waitOnMain() - - XCTAssertTrue(mockUserNotificationCenter.pendingRequests.isEmpty) + + XCTAssertEqual(1, mockUserNotificationCenter.pendingRequests.count) + if let request = mockUserNotificationCenter.pendingRequests.first { + XCTAssertEqual(self.backgroundContent.title, request.content.title) + XCTAssertEqual(self.backgroundContent.body, request.content.body) + XCTAssertNil(request.content.sound) + XCTAssertEqual(alertIdentifier.value, request.content.threadIdentifier) + XCTAssertEqual([ + LoopNotificationUserInfoKey.managerIDForAlert.rawValue: alertIdentifier.managerIdentifier, + LoopNotificationUserInfoKey.alertTypeID.rawValue: alertIdentifier.alertIdentifier, + ], request.content.userInfo as? [String: String]) + XCTAssertNil(request.trigger) + } + } + + func testIssueMutedCriticalAlert() { + let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "") + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate, interruptionLevel: .critical) + userNotificationAlertScheduler.scheduleAlert(alert, timestamp: Date.distantPast, muted: true) + + waitOnMain() + + XCTAssertEqual(1, mockUserNotificationCenter.pendingRequests.count) + if let request = mockUserNotificationCenter.pendingRequests.first { + XCTAssertEqual(self.backgroundContent.title, request.content.title) + XCTAssertEqual(self.backgroundContent.body, request.content.body) + XCTAssertEqual(UNNotificationSound.defaultCriticalSound(withAudioVolume: 0), request.content.sound) + XCTAssertEqual(alertIdentifier.value, request.content.threadIdentifier) + XCTAssertEqual([ + LoopNotificationUserInfoKey.managerIDForAlert.rawValue: alertIdentifier.managerIdentifier, + LoopNotificationUserInfoKey.alertTypeID.rawValue: alertIdentifier.alertIdentifier, + ], request.content.userInfo as? [String: String]) + XCTAssertNil(request.trigger) + } } } diff --git a/WatchApp/DerivedAssets.xcassets/Contents.json b/WatchApp/DerivedAssets.xcassets/Contents.json index 73c00596a7..da4a164c91 100644 --- a/WatchApp/DerivedAssets.xcassets/Contents.json +++ b/WatchApp/DerivedAssets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "author" : "xcode", - "version" : 1 + "version" : 1, + "author" : "xcode" } -} +} \ No newline at end of file From fa49d3ac2323692266cbf795caa5f16906d69825 Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 31 Oct 2022 08:06:41 -0300 Subject: [PATCH 10/14] [LOOP-4349] corrected loop not looping rescheduling (#532) * corrected identifier for loop not looping notifications * updated test to run for both DIY and Tidepool --- Loop/Managers/Alerts/AlertManager.swift | 2 +- .../Managers/Alerts/AlertManagerTests.swift | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 112d77cc3f..19ba5060f7 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -203,7 +203,7 @@ public final class AlertManager { ) let request = UNNotificationRequest( - identifier: "\(LoopNotificationCategory.loopNotRunning.rawValue)\(failureInterval)", + identifier: "\(LoopNotificationCategory.loopNotRunning.rawValue)\(minutes)", content: notificationContent, trigger: trigger ) diff --git a/LoopTests/Managers/Alerts/AlertManagerTests.swift b/LoopTests/Managers/Alerts/AlertManagerTests.swift index 921d03d0a8..22be68c2e3 100644 --- a/LoopTests/Managers/Alerts/AlertManagerTests.swift +++ b/LoopTests/Managers/Alerts/AlertManagerTests.swift @@ -481,7 +481,8 @@ class AlertManagerTests: XCTestCase { alertManager.loopDidComplete(lastLoopDate) alertManager.alertMuter.configuration.startTime = Date() alertManager.alertMuter.configuration.duration = .hours(4) - + waitOnMain() + let testExpectation = expectation(description: #function) var loopNotRunningRequests: [UNNotificationRequest] = [] UNUserNotificationCenter.current().getPendingNotificationRequests() { notificationRequests in @@ -492,8 +493,21 @@ class AlertManagerTests: XCTestCase { } wait(for: [testExpectation], timeout: 1) - XCTAssertNil(loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .timeSensitive })!.content.sound) - XCTAssertEqual(loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .critical })!.content.sound, .defaultCriticalSound(withAudioVolume: 0)) + if #available(iOS 15.0, *) { + XCTAssertNil(loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .timeSensitive })?.content.sound) + if let request = loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .critical }) { + XCTAssertEqual(request.content.sound, .defaultCriticalSound(withAudioVolume: 0)) + } + } else if FeatureFlags.criticalAlertsEnabled { + for request in loopNotRunningRequests { + let sound = request.content.sound + XCTAssertTrue(sound == nil || sound == .defaultCriticalSound(withAudioVolume: 0.0)) + } + } else { + for request in loopNotRunningRequests { + XCTAssertNil(request.content.sound) + } + } } } From 70fdf187ce7ed07ef7c24b88af4695bec00d6ab2 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 3 Nov 2022 06:23:15 -0700 Subject: [PATCH 11/14] COASTAL-1076 Support PumpManagerUI pausing onboarding (#533) * Prevent marking onboarding as finished when pausing * Update comment for clarity --- Loop/Managers/DeviceDataManager.swift | 4 ++++ Loop/Managers/OnboardingManager.swift | 33 ++++++++++++++++++++------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index faf0182b27..acb5e80628 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -1137,6 +1137,10 @@ extension DeviceDataManager: PumpManagerOnboardingDelegate { self.settingsManager.storeSettings() } } + + func pumpManagerOnboarding(didPauseOnboarding pumpManager: PumpManagerUI) { + + } } // MARK: - AlertStoreDelegate diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index 7ab6d41e56..2abc7bc887 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -6,6 +6,7 @@ // Copyright © 2021 LoopKit Authors. All rights reserved. // +import os.log import HealthKit import LoopKit import LoopKitUI @@ -19,6 +20,8 @@ class OnboardingManager { private weak var windowProvider: WindowProvider? private let userDefaults: UserDefaults + private let log = OSLog(category: "OnboardingManager") + @Published public private(set) var isSuspended: Bool { didSet { userDefaults.onboardingManagerIsSuspended = isSuspended } } @@ -33,7 +36,7 @@ class OnboardingManager { didSet { userDefaults.onboardingManagerActiveOnboardingRawValue = activeOnboarding?.rawValue } } - private var completion: (() -> Void)? + private var onboardingCompletion: (() -> Void)? init(pluginManager: PluginManager, bluetoothProvider: BluetoothProvider, deviceDataManager: DeviceDataManager, servicesManager: ServicesManager, loopDataManager: LoopDataManager, windowProvider: WindowProvider?, userDefaults: UserDefaults = .standard) { self.pluginManager = pluginManager @@ -60,17 +63,17 @@ class OnboardingManager { func launch(_ completion: @escaping () -> Void) { dispatchPrecondition(condition: .onQueue(.main)) - precondition(self.completion == nil) + precondition(self.onboardingCompletion == nil) - self.completion = completion + self.onboardingCompletion = completion continueOnboarding() } func resume() { dispatchPrecondition(condition: .onQueue(.main)) - precondition(self.completion == nil) + precondition(self.onboardingCompletion == nil) - self.completion = { + self.onboardingCompletion = { self.windowProvider?.window?.rootViewController?.dismiss(animated: true, completion: nil) } continueOnboarding(allowResume: true) @@ -204,8 +207,8 @@ class OnboardingManager { private func complete() { dispatchPrecondition(condition: .onQueue(.main)) - if let completion = completion { - self.completion = nil + if let completion = onboardingCompletion { + self.onboardingCompletion = nil completion() } } @@ -256,6 +259,7 @@ extension OnboardingManager: OnboardingDelegate { } func onboardingDidSuspend(_ onboarding: OnboardingUI) { + log.debug("OnboardingUI %@ did suspend", onboarding.onboardingIdentifier) guard onboarding.onboardingIdentifier == activeOnboarding?.onboardingIdentifier else { return } self.isSuspended = true } @@ -266,7 +270,20 @@ extension OnboardingManager: OnboardingDelegate { extension OnboardingManager: CompletionDelegate { func completionNotifyingDidComplete(_ object: CompletionNotifying) { DispatchQueue.main.async { - self.completeActiveOnboarding() + guard let activeOnboarding = self.activeOnboarding else { + return + } + + self.log.debug("completionNotifyingDidComplete during activeOnboarding", activeOnboarding.onboardingIdentifier) + + // 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 + // onboarding is finished/complete. So we check to see if onboarding is finished here before calling `completeActiveOnboarding` + if activeOnboarding.isOnboarded { + self.completeActiveOnboarding() + } + + self.complete() } } } From fa0bddeacb582f19388d0d1bbab6473000e74c0b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 7 Nov 2022 09:09:36 -0800 Subject: [PATCH 12/14] Use new iOS 16 method to notify VC of supported orientation change update (#534) --- Loop/Managers/LoopAppManager.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index d85c36a964..eaecece42f 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -318,7 +318,15 @@ class LoopAppManager: NSObject { private static let defaultSupportedInterfaceOrientations = UIInterfaceOrientationMask.allButUpsideDown - var supportedInterfaceOrientations = defaultSupportedInterfaceOrientations + var supportedInterfaceOrientations = defaultSupportedInterfaceOrientations { + didSet { + if #available(iOS 16.0, *) { + rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + } else { + // Fallback on earlier versions + } + } + } // MARK: - Background Tasks From e0e2d58cb6ad7ca81a9db55a3f52a5a99d59ba2a Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Mon, 7 Nov 2022 13:18:19 -0400 Subject: [PATCH 13/14] [COASTAL-1124] Time alert copy update (#535) * updated copy of time alert * using device model --- Loop/Managers/TrustedTimeChecker.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/TrustedTimeChecker.swift b/Loop/Managers/TrustedTimeChecker.swift index f06054eb8c..707f71e1da 100644 --- a/Loop/Managers/TrustedTimeChecker.swift +++ b/Loop/Managers/TrustedTimeChecker.swift @@ -84,8 +84,8 @@ class TrustedTimeChecker { } private func issueTimeChangedAlert() { - let alertTitle = NSLocalizedString("Time Change Detected", comment: "Time change alert title") - let alertBody = String(format: NSLocalizedString("Your phone’s time has been changed. %1$@ needs accurate time records to make predictions about your glucose and adjust your insulin accordingly.\n\nCheck in your iPhone Settings (General / Date & Time) and verify that Set Automatically is enabled. Failure to resolve could lead to serious under-delivery or over-delivery of insulin.", comment: "Time change alert body. (1: app name)"), Bundle.main.bundleDisplayName) + let alertTitle = String(format: NSLocalizedString("%1$@ Time Settings Need Attention", comment: "Time change alert title"), UIDevice.current.model) + let alertBody = String(format: NSLocalizedString("Your %1$@’s time has been changed. %2$@ needs accurate time records to make predictions about your glucose and adjust your insulin accordingly.\n\nCheck in your %1$@ Settings (General / Date & Time) and verify that 'Set Automatically' is turned ON. Failure to resolve could lead to serious under-delivery or over-delivery of insulin.", comment: "Time change alert body. (1: app name)"), UIDevice.current.model, Bundle.main.bundleDisplayName) let content = Alert.Content(title: alertTitle, body: alertBody, acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Alert acknowledgment OK button")) alertManager?.issueAlert(Alert(identifier: alertIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate)) } From afdc9c9762bda5459b4d11dad0a312360afb5949 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 28 Nov 2022 15:57:58 -0600 Subject: [PATCH 14/14] Disable mute alerts feature (wip) and fix tests --- Loop/Views/AlertManagementView.swift | 8 +++++--- LoopTests/Managers/DoseEnactorTests.swift | 7 ++++++- WatchApp/DerivedAssets.xcassets/Contents.json | 6 +++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 004884ecfe..de7cdc6be8 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -58,10 +58,12 @@ struct AlertManagementView: View { var body: some View { List { alertPermissionsSection - muteAlertsSection + if FeatureFlags.criticalAlertsEnabled { + muteAlertsSection - if alertMuter.configuration.shouldMute { - mutePeriodSection + if alertMuter.configuration.shouldMute { + mutePeriodSection + } } } .navigationTitle(NSLocalizedString("Alert Management", comment: "Title of alert management screen")) diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index 794682ced3..7ab7add2e4 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -22,7 +22,7 @@ extension MockPumpManagerError: LocalizedError { } class MockPumpManager: PumpManager { - + var enactBolusCalled: ((Double, BolusActivationType) -> Void)? var enactTempBasalCalled: ((Double, TimeInterval) -> Void)? @@ -57,6 +57,7 @@ class MockPumpManager: PumpManager { var pumpRecordsBasalProfileStartEvents: Bool = false var pumpReservoirCapacity: Double = 50 + var deliveryUnitsPerMinute = 1.5 var lastSync: Date? @@ -115,6 +116,10 @@ class MockPumpManager: PumpManager { } + public func estimatedDuration(toBolus units: Double) -> TimeInterval { + .minutes(units / deliveryUnitsPerMinute) + } + var managerIdentifier: String = "MockPumpManager" var localizedTitle: String = "MockPumpManager" diff --git a/WatchApp/DerivedAssets.xcassets/Contents.json b/WatchApp/DerivedAssets.xcassets/Contents.json index da4a164c91..73c00596a7 100644 --- a/WatchApp/DerivedAssets.xcassets/Contents.json +++ b/WatchApp/DerivedAssets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +}