// // TopNotificationHandler.swift // MVMCoreUI // // Created by Scott Pfeil on 4/11/23. // Copyright © 2023 Verizon Wireless. All rights reserved. // import MVMCore import Dispatch public protocol NotificationTransitionDelegateProtocol { @MainActor func show(notification: UIView) async @MainActor func hide(notification: UIView) async @MainActor func update(with model: TopNotificationModel) } public class NotificationOperation: MVMCoreOperation { private let notification: UIView private var notificationModel: TopNotificationModel /// The delegate that manages transitioning the notification. private let transitionDelegate: NotificationTransitionDelegateProtocol /// The notification animation transition operation (show or hide). private var transitionOperation: MVMCoreOperation? /// The stop timer for non-persistent notifications. private var timerSource: DispatchSourceTimer? /// Determines if the operation is ready. Certain notifications are only meant to be displayed on certain pages. public var isDisplayable: Bool { get { var isDisplayable: Bool = true displayableQueue.sync { isDisplayable = _isDisplayable } return isDisplayable } set { guard newValue != isDisplayable else { return } displayableQueue.async(flags: .barrier) { [weak self] in self?._isDisplayable = newValue } } } private var displayableQueue = DispatchQueue(label: "displayable", attributes: .concurrent) private var _isDisplayable: Bool = true { willSet { guard super.isReady else { return } willChangeValue(for: \.isReady) } didSet { guard super.isReady else { return } didChangeValue(for: \.isReady) } } /// This operation is ready only if this notification is allowed to show. public override var isReady: Bool { get { guard !isCancelled else { return super.isReady } return super.isReady && isDisplayable } } private actor Properties { private var isDisplayed: Bool = false private var isAnimating: Bool = false func set(displayed: Bool) { isDisplayed = displayed } func getIsDisplayed() -> Bool { return isDisplayed } func set(animating: Bool) { isAnimating = animating } func getIsAnimating() -> Bool { return isAnimating } } private var properties = Properties() // A flag for tracking if the operation needs to be re-added because it was cancelled for a higher priority notification. public var reAddAfterCancel = false public init(with notification: UIView, notificationModel: TopNotificationModel, transitionDelegate: NotificationTransitionDelegateProtocol) { self.notification = notification self.notificationModel = notificationModel self.transitionDelegate = transitionDelegate super.init() queuePriority = notificationModel.priority } public override func main() { guard !checkAndHandleForCancellation() else { return } add { await self.showNotification() guard !self.isCancelled else { // Cancelled, dismiss immediately. self.stop() return } self.updateStopTimer() } } public func stop() { if let timerSource = timerSource { timerSource.cancel() } Task { guard await properties.getIsDisplayed(), await !properties.getIsAnimating() else { return } add { await self.hideNotification() guard !self.isCancelled, !self.notificationModel.persistent else { return } self.markAsFinished() } } } /// Adds the transition of the notification to the queue. private func add(transition: @escaping () async -> Void) { Task { guard await properties.getIsDisplayed(), await !properties.getIsAnimating() else { return } transitionOperation = MVMCoreBlockOperation(block: { [weak self] blockOperation in guard !blockOperation.checkAndHandleForCancellation() else { return } guard let self = self else { blockOperation.markAsFinished() return } Task { await transition() blockOperation.markAsFinished() } }) transitionOperation?.completionBlock = { [weak self] in self?.transitionOperation = nil } // Add the animation to the navigation queue to avoid animation collisions. await MVMCoreNavigationHandler.shared()?.addNavigationOperation(transitionOperation!) } } private func updateStopTimer() { if let timerSource = timerSource { timerSource.cancel() } guard !notificationModel.persistent else { return } timerSource = DispatchSource.makeTimerSource() timerSource?.setEventHandler(handler: { [weak self] in print("SSSS TIMER EVENT FIRED FOR: \(String(describing: self?.notificationModel.type))") guard let self = self, !self.isFinished, !self.checkAndHandleForCancellation() else { return } self.stop() }) timerSource?.setCancelHandler(handler: { [weak self] in print("SSSS TIMER EVENT CANCELLED FOR: \(String(describing: self?.notificationModel.type))") }) timerSource?.schedule(deadline: .now() + .seconds(notificationModel.dismissTime)) } public override func cancel() { super.cancel() Task { if await !properties.getIsDisplayed() { // Cancel any pending show transitions. transitionOperation?.cancel() } // Do nothing if animating. guard await !properties.getIsAnimating() else { return } if await properties.getIsDisplayed() { stop() } else if isExecuting { markAsFinished() } } } @MainActor private func showNotification() async { await properties.set(animating: true) await transitionDelegate.show(notification: notification) await properties.set(displayed: true) await properties.set(animating: false) } @MainActor private func hideNotification() async { await properties.set(animating: true) await transitionDelegate.hide(notification: notification) await properties.set(displayed: false) await properties.set(animating: false) } /// Updates the notification with the new model. public func update(with model: TopNotificationModel) { self.notificationModel = model queuePriority = model.priority guard isExecuting, !isCancelled else { return } updateStopTimer() Task { @MainActor in transitionDelegate.update(with: notificationModel) } } func copy(with zone: NSZone? = nil) -> Any { let operation = NotificationOperation(with: notification, notificationModel: notificationModel, transitionDelegate: transitionDelegate) operation.reAddAfterCancel = reAddAfterCancel operation.isDisplayable = isDisplayable for dependency in dependencies { operation.addDependency(dependency) } operation.name = name operation.qualityOfService = qualityOfService return operation } } public class TopNotificationHandler { /// The operation queue of top notification operations. private var queue = OperationQueue() /// Returns the handler stored in the CoreUIObject public static func shared() -> Self { return MVMCoreActionUtility.fatalClassCheck(object: CoreUIObject.sharedInstance()?.topNotificationHandler) } public init() { registerWithNotificationCenter() registerForPageChanges() } // MARK: - JSON Handling /// Registers with the notification center to know when json is updated. private func registerWithNotificationCenter() { NotificationCenter.default.addObserver(self, selector: #selector(responseJSONUpdated(notification:)), name: NSNotification.Name(rawValue: NotificationResponseLoaded), object: nil) } /// Registers to know when pages change. private func registerForPageChanges() { MVMCoreNavigationHandler.shared()?.addDelegate(self) } private func getDelegateObject() -> MVMCoreUIDelegateObject? { // TODO: Top alert view is current delegate. Should move to current view controller eventually? guard let alertView = MVMCoreUISplitViewController.main()?.topAlertView else { return nil } return MVMCoreUIDelegateObject.create(withDelegateForAll: alertView) } /// Checks for new top alert json @objc private func responseJSONUpdated(notification: Notification) { guard let loadObject = (notification.userInfo?[String(describing: MVMCoreLoadObject.self)] as? MVMCoreLoadObject) else { return } // Dismiss any top alerts that server wants us to dismiss/ if let disableType = loadObject.responseInfoMap?.optionalStringForKey("disableType") { TopNotificationHandler.shared().hideTopAlertView(of: disableType) } // Show any new top alert. guard let responseJSON = loadObject.responseJSON, let json = responseJSON.optionalDictionaryForKey(KeyTopAlert) else { return } showTopNotification(with: json) } /// Decodes the json into a TopNotificationModel public func decodeTopNotification(with json: [AnyHashable: Any], delegateObject: MVMCoreUIDelegateObject?) -> TopNotificationModel? { do { return try TopNotificationModel.decode(json: json, delegateObject: delegateObject) } catch { if let errorObject = MVMCoreErrorObject.createErrorObject(for: error, location: "\(self)") { MVMCoreUILoggingHandler.addError(toLog: errorObject) } return nil } } // MARK: - Operation Handling private func add(operation: MVMCoreTopAlertOperation) { operation.completionBlock = { [weak self] in // If the alert was cancelled to show another with higher priority, re-add to the operation when cancelled to the queue. if operation.reAddAfterCancel { let newOperation: MVMCoreTopAlertOperation = operation.copy() as! MVMCoreTopAlertOperation newOperation.reAddAfterCancel = false self?.add(operation: newOperation) } operation.completionBlock = nil } let currentPageType = (MVMCoreUISplitViewController.main()?.getCurrentDetailViewController() as? MVMCoreViewControllerProtocol)?.pageType operation.updateDisplayable(byPageType: currentPageType) queue.addOperation(operation) reevaluteQueue() } /// Checks for existing top alert object of same type and updates it. Only happens for molecular top alerts. Returns true if we updated. private func checkAndUpdateExisting(with topAlertObject: MVMCoreTopAlertObject) -> Bool { for case let operation as MVMCoreTopAlertOperation in queue.operations { guard topAlertObject.json != nil, operation.topAlertObject.type == topAlertObject.type else { continue } operation.update(with: topAlertObject) let pageType = (MVMCoreUISplitViewController.main()?.getCurrentDetailViewController() as? MVMCoreViewControllerProtocol)?.pageType operation.updateDisplayable(byPageType: pageType) reevaluteQueue() return true } return false } /// Re-evaluates the queue operations private func reevaluteQueue() { var highestReadyOperation: MVMCoreTopAlertOperation? var executingOperation: MVMCoreTopAlertOperation? for case let operation as MVMCoreTopAlertOperation in queue.operations { guard !operation.isCancelled, !operation.isFinished else { continue } if operation.isReady, highestReadyOperation == nil || operation.queuePriority.rawValue > highestReadyOperation!.queuePriority.rawValue { highestReadyOperation = operation } if operation.isExecuting { executingOperation = operation } } guard let currentOperation = executingOperation else { return } // Cancel the executing operation if it is no longer ready to run. Re-add for later if it is persistent. guard currentOperation.isReady else { currentOperation.reAddAfterCancel = currentOperation.topAlertObject.persistent currentOperation.cancel() return } // If the highest priority operation is not executing, and the executing operation is persistent, cancel it. if let newOperation = highestReadyOperation, currentOperation != newOperation, currentOperation.topAlertObject.persistent { currentOperation.reAddAfterCancel = true currentOperation.cancel() } } // MARK: - Show and hide public func isTopAlertShowing() -> Bool { return queue.operations.first(where: { operation in return operation.isExecuting }) != nil } public func hasPersistentTopAlert(of type: String) -> Bool { return queue.operations.first(where: { operation in guard operation.isExecuting, let operation = operation as? MVMCoreTopAlertOperation else { return false } return operation.topAlertObject.persistent && operation.topAlertObject.type == type }) as? MVMCoreTopAlertOperation != nil } /// Shows the top alert with the json. func showTopNotification(with json: [AnyHashable: Any]) { guard let model = decodeTopNotification(with: json, delegateObject: getDelegateObject()) else { return } showTopNotification(with: model) } /// Shows the top notification with the model. func showTopNotification(with model: TopNotificationModel) { let object = model.createTopAlertObject() guard !checkAndUpdateExisting(with: object), let operation = MVMCoreTopAlertOperation(topAlertObject: object) else { return } TopNotificationHandler.shared().add(operation: operation) } /// Show the top alert with the legacy object. public func showTopAlert(with topAlertObject: MVMCoreTopAlertObject) { let alertOperation = MVMCoreTopAlertOperation(topAlertObject: topAlertObject)! add(operation: alertOperation) } /// Cancel the current top alert view. public func hideTopAlertView() { guard let currentOperation = queue.operations.first(where: { operation in return operation.isExecuting }) as? MVMCoreTopAlertOperation else { return } currentOperation.topAlertObject.persistent = false currentOperation.reAddAfterCancel = false currentOperation.cancel() } /// Cancel all operations of this type. public func hideTopAlertView(of type: String) { for operation in queue.operations { guard let operation = operation as? MVMCoreTopAlertOperation, operation.topAlertObject.type == type else { continue } operation.reAddAfterCancel = false operation.cancel() } } /// Cancel all persistent operations of this type. public func hidePersistentTopAlertView(of type: String) { for operation in queue.operations { guard let operation = operation as? MVMCoreTopAlertOperation, operation.topAlertObject.persistent, operation.topAlertObject.type == type else { continue } operation.reAddAfterCancel = false operation.cancel() } } /// Finds an cancels top alerts associated with the object. public func removeTopAlert(for object: MVMCoreTopAlertObject) { for operation in queue.operations { guard let operation = operation as? MVMCoreTopAlertOperation, operation.topAlertObject === object else { return } operation.reAddAfterCancel = false operation.cancel() } } public func removeAllTopAlerts() { queue.cancelAllOperations() } } extension TopNotificationHandler: MVMCorePresentationDelegateProtocol { // Update displayable for each top alert operation when page type changes, in top queue priority order. public func navigationController(_ navigationController: UINavigationController, displayedViewController viewController: UIViewController) { guard queue.operations.count > 0 else { return } let viewController = MVMCoreUIUtility.getViewControllerTraversingManagers(viewController) guard viewController == MVMCoreUISplitViewController.main()?.getCurrentViewController() else { return } let pageType = (viewController as? MVMCoreViewControllerProtocol)?.pageType queue.operations.compactMap { $0 as? MVMCoreTopAlertOperation }.sorted { $0.queuePriority.rawValue > $1.queuePriority.rawValue }.forEach { $0.updateDisplayable(byPageType: pageType) } reevaluteQueue() } } extension NotificationOperation { /// Updates if the operation is displayable based on the page type. func updateDisplayable(by pageType: String) { isDisplayable = notificationModel.pages?.contains(pageType) ?? true } }