532 lines
22 KiB
Swift
532 lines
22 KiB
Swift
//
|
|
// TopNotificationHandler.swift
|
|
// MVMCoreUI
|
|
//
|
|
// Created by Scott Pfeil on 4/11/23.
|
|
// Copyright © 2023 Verizon Wireless. All rights reserved.
|
|
//
|
|
|
|
import MVMCore
|
|
import Dispatch
|
|
import Combine
|
|
|
|
/// Handles the UI tasks for the notification.
|
|
public protocol NotificationTransitionDelegateProtocol {
|
|
@MainActor
|
|
func show(notification: UIView) async
|
|
|
|
@MainActor
|
|
func hide(notification: UIView) async
|
|
|
|
@MainActor
|
|
func update(with model: NotificationModel, delegateObject: MVMCoreUIDelegateObject?)
|
|
}
|
|
|
|
/// An operation for managing the life cycle of the notification.
|
|
public class NotificationOperation: MVMCoreOperation {
|
|
|
|
public let notification: UIView
|
|
|
|
public var notificationModel: NotificationModel
|
|
|
|
/// The delegate that manages transitioning the notification.
|
|
private let transitionDelegate: NotificationTransitionDelegateProtocol
|
|
|
|
/// The showing animation transition operation.
|
|
private weak var showTransitionOperation: Operation?
|
|
|
|
/// The stop timer for non-persistent notifications.
|
|
private var timerSource: DispatchSourceTimer?
|
|
|
|
/// Determines if the operation is ready. For example, certain notifications are only meant to be displayed on certain pages and this can be set accordingly.
|
|
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
|
|
}
|
|
}
|
|
}
|
|
/// Thread safety.
|
|
private var displayableQueue = DispatchQueue(label: "displayable", attributes: .concurrent)
|
|
/// Updates the operation readiness accordingly.
|
|
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
|
|
}
|
|
}
|
|
|
|
public actor Properties {
|
|
/// If the notification is currently displayed.
|
|
private var isDisplayed: Bool = false
|
|
|
|
/// If the notification is currently animating (showing/hiding).
|
|
private var isAnimating: Bool = false
|
|
|
|
fileprivate func set(displayed: Bool) {
|
|
isDisplayed = displayed
|
|
}
|
|
|
|
public func getIsDisplayed() -> Bool {
|
|
return isDisplayed
|
|
}
|
|
|
|
fileprivate func set(animating: Bool) {
|
|
isAnimating = animating
|
|
}
|
|
|
|
public func getIsAnimating() -> Bool {
|
|
return isAnimating
|
|
}
|
|
}
|
|
/// Actor isolated properties for the operation.
|
|
public 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: NotificationModel, transitionDelegate: NotificationTransitionDelegateProtocol) {
|
|
self.notification = notification
|
|
self.notificationModel = notificationModel
|
|
self.transitionDelegate = transitionDelegate
|
|
super.init()
|
|
queuePriority = notificationModel.priority
|
|
}
|
|
|
|
public override func main() {
|
|
guard !checkAndHandleForCancellation() else { return }
|
|
Task {
|
|
await withCheckedContinuation { continuation in
|
|
// Show the notification.
|
|
showTransitionOperation = add(transition: { [weak self] in
|
|
guard let self = self else { return }
|
|
NotificationHandler.shared().onNotificationWillShow.send((self.notification, self.notificationModel))
|
|
await self.showNotification()
|
|
}, completionBlock: {
|
|
continuation.resume()
|
|
})
|
|
}
|
|
guard await properties.getIsDisplayed() else {
|
|
// If the animation did not complete...
|
|
markAsFinished()
|
|
return
|
|
}
|
|
|
|
// Publish that the notification has been shown.
|
|
NotificationHandler.shared().onNotificationShown.send((notification, notificationModel))
|
|
|
|
guard !isCancelled else {
|
|
// If cancelled during the animation, dismiss immediately.
|
|
stop()
|
|
return
|
|
}
|
|
updateStopTimer()
|
|
}
|
|
}
|
|
|
|
public func stop() {
|
|
if let timerSource = timerSource {
|
|
timerSource.cancel()
|
|
}
|
|
Task {
|
|
guard await properties.getIsDisplayed(),
|
|
await !properties.getIsAnimating() else { return }
|
|
// Hide the notification
|
|
await withCheckedContinuation({ continuation in
|
|
_ = add(transition: { [weak self] in
|
|
guard let self = self else { return }
|
|
await self.hideNotification()
|
|
}, completionBlock: {
|
|
continuation.resume()
|
|
})
|
|
})
|
|
// The animation must complete...
|
|
guard await !self.properties.getIsDisplayed() else { return }
|
|
|
|
// Publish that the notification has been hidden.
|
|
NotificationHandler.shared().onNotificationDismissed.send((notification, notificationModel))
|
|
|
|
guard !isCancelled,
|
|
!notificationModel.persistent else { return }
|
|
markAsFinished()
|
|
}
|
|
}
|
|
|
|
public override func cancel() {
|
|
super.cancel()
|
|
Task {
|
|
if await !properties.getIsDisplayed() {
|
|
// Cancel any pending show transitions.
|
|
showTransitionOperation?.cancel()
|
|
}
|
|
|
|
// Do nothing if animating.
|
|
guard await !properties.getIsAnimating() else { return }
|
|
if await properties.getIsDisplayed() {
|
|
stop()
|
|
} else if isExecuting {
|
|
markAsFinished()
|
|
}
|
|
}
|
|
}
|
|
|
|
public 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
|
|
}
|
|
|
|
// MARK: - Automatic
|
|
|
|
/// Sets up a timer to hide the notification.
|
|
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 }
|
|
|
|
// If voice over is on and the notification is focused, do not collapse until unfocused.
|
|
guard !MVMCoreUIUtility.viewContainsAccessiblityFocus(notification) else {
|
|
NotificationCenter.default.addObserver(self, selector: #selector(accessibilityFocusChanged), name: UIAccessibility.elementFocusedNotification, object: nil)
|
|
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))
|
|
}
|
|
|
|
/// If the voice over user leaves top alert focus, hide.
|
|
@objc func accessibilityFocusChanged(_ notification: NSNotification) {
|
|
guard let _ = notification.userInfo?[UIAccessibility.focusedElementUserInfoKey],
|
|
!MVMCoreUIUtility.viewContainsAccessiblityFocus(self.notification) else { return }
|
|
NotificationCenter.default.removeObserver(self, name: UIAccessibility.elementFocusedNotification, object: nil)
|
|
stop()
|
|
}
|
|
|
|
// MARK: - Transitions
|
|
|
|
/// Adds the transition of the notification to the navigation queue to avoid animation collisions.
|
|
private func add(transition: @escaping () async -> Void, completionBlock: (() -> Void)?) -> Operation {
|
|
let transitionOperation = MVMCoreBlockOperation(block: { blockOperation in
|
|
guard !blockOperation.checkAndHandleForCancellation() else { return }
|
|
Task {
|
|
await transition()
|
|
blockOperation.markAsFinished()
|
|
}
|
|
})!
|
|
transitionOperation.completionBlock = completionBlock
|
|
MVMCoreNavigationHandler.shared()?.addNavigationOperation(transitionOperation)
|
|
return transitionOperation
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|
|
|
|
/// Manages notifications.
|
|
public class NotificationHandler {
|
|
|
|
private var queue = OperationQueue()
|
|
|
|
private var transitionDelegate: NotificationTransitionDelegateProtocol
|
|
|
|
private var delegateObject: MVMCoreUIDelegateObject?
|
|
|
|
/// Publishes when a notification will show.
|
|
public let onNotificationWillShow = PassthroughSubject<(UIView, NotificationModel), Never>()
|
|
|
|
/// Publishes when a notification is shown.
|
|
public let onNotificationShown = PassthroughSubject<(UIView, NotificationModel), Never>()
|
|
|
|
/// Publishes when a notification is dismissed.
|
|
public let onNotificationDismissed = PassthroughSubject<(UIView, NotificationModel), Never>()
|
|
|
|
/// Publishes when a notification is updated.
|
|
public let onNotificationUpdated = PassthroughSubject<(UIView, NotificationModel), Never>()
|
|
|
|
/// Returns the handler stored in the CoreUIObject
|
|
public static func shared() -> Self {
|
|
return MVMCoreActionUtility.fatalClassCheck(object: CoreUIObject.sharedInstance()?.topNotificationHandler)
|
|
}
|
|
|
|
public init(with transitionDelegate: NotificationTransitionDelegateProtocol) {
|
|
self.transitionDelegate = transitionDelegate
|
|
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)
|
|
}
|
|
|
|
/// Checks for new top alert json
|
|
@objc private func responseJSONUpdated(notification: Notification) async {
|
|
guard let loadObject = (notification.userInfo?[String(describing: MVMCoreLoadObject.self)] as? MVMCoreLoadObject) else { return }
|
|
let delegateObject = loadObject.delegateObject as? MVMCoreUIDelegateObject
|
|
|
|
// Dismiss any top alerts that server wants us to dismiss.
|
|
if let disableType = loadObject.responseInfoMap?.optionalStringForKey("disableType") {
|
|
NotificationHandler.shared().cancelNotification(using: { view, model in
|
|
return model.type == disableType
|
|
})
|
|
}
|
|
|
|
// Show any new top alert.
|
|
guard let responseJSON = loadObject.responseJSON,
|
|
let json = responseJSON.optionalDictionaryForKey(KeyTopAlert) else { return }
|
|
Task {
|
|
await showNotification(for: json, delegateObject: delegateObject)
|
|
}
|
|
}
|
|
|
|
/// Converts the json to a model and creates the view and queues up the notification.
|
|
public func showNotification(for json: [AnyHashable: Any], delegateObject: MVMCoreUIDelegateObject?) async {
|
|
do {
|
|
let model = try NotificationModel.decode(json: json, delegateObject: delegateObject)
|
|
try await showNotification(for: model, delegateObject: delegateObject)
|
|
} catch {
|
|
if let errorObject = MVMCoreErrorObject.createErrorObject(for: error, location: "\(self)") {
|
|
MVMCoreUILoggingHandler.addError(toLog: errorObject)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Operation Handling
|
|
|
|
/// Adds the operation to the queue.
|
|
private func add(operation: NotificationOperation) {
|
|
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: NotificationOperation = operation.copy() as! NotificationOperation
|
|
newOperation.reAddAfterCancel = false
|
|
self?.add(operation: newOperation)
|
|
}
|
|
operation.completionBlock = nil
|
|
}
|
|
|
|
let currentPageType = (MVMCoreUISplitViewController.main()?.getCurrentDetailViewController() as? MVMCoreViewControllerProtocol)?.pageType
|
|
operation.updateDisplayable(by: 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 model: NotificationModel, delegateObject: MVMCoreUIDelegateObject?) -> Bool {
|
|
for case let operation as NotificationOperation in queue.operations {
|
|
guard operation.notificationModel.type == model.type else { continue }
|
|
operation.update(with: model, delegateObject: delegateObject)
|
|
let pageType = (MVMCoreUISplitViewController.main()?.getCurrentDetailViewController() as? MVMCoreViewControllerProtocol)?.pageType
|
|
operation.updateDisplayable(by: pageType)
|
|
reevaluteQueue()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Re-evaluates the queue operations
|
|
private func reevaluteQueue() {
|
|
var highestReadyOperation: NotificationOperation?
|
|
var executingOperation: NotificationOperation?
|
|
for case let operation as NotificationOperation 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.notificationModel.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.notificationModel.persistent {
|
|
currentOperation.reAddAfterCancel = true
|
|
currentOperation.cancel()
|
|
}
|
|
}
|
|
|
|
// MARK: - Verify
|
|
|
|
/// Returns if any notification is executing
|
|
public func isNotificationShowing() -> Bool {
|
|
return queue.operations.first(where: { operation in
|
|
return operation.isExecuting
|
|
}) != nil
|
|
}
|
|
|
|
/** Returns if the first executing operation matches the provided predicate.
|
|
* @param predicate The predicate block to decide if it is the notification.
|
|
*/
|
|
public func hasNotification(using predicate: ((UIView, NotificationModel) -> Bool)) -> Bool {
|
|
return queue.operations.first(where: { operation in
|
|
guard operation.isExecuting,
|
|
let operation = operation as? NotificationOperation else { return false }
|
|
return predicate(operation.notification, operation.notificationModel)
|
|
}) as? NotificationOperation != nil
|
|
}
|
|
|
|
/// Returns the current executing notification view and model
|
|
public func getCurrentNotification() async -> (UIView, NotificationModel)? {
|
|
for operation in queue.operations {
|
|
guard operation.isExecuting,
|
|
let operation = operation as? NotificationOperation,
|
|
await operation.properties.getIsDisplayed() else { continue }
|
|
return (operation.notification, operation.notificationModel)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Show and hide
|
|
|
|
/// Creates the view and queues up the notification.
|
|
public func showNotification(for model: NotificationModel, delegateObject: MVMCoreUIDelegateObject? = nil) async throws {
|
|
guard !checkAndUpdateExisting(with: model, delegateObject: delegateObject) else { return }
|
|
guard let view = ModelRegistry.createMolecule(model.molecule, delegateObject: delegateObject, additionalData: nil) else {
|
|
throw ModelRegistry.Error.decoderOther(message: "Molecule not mapped")
|
|
}
|
|
let operation = NotificationOperation(with: view, notificationModel: model, transitionDelegate: transitionDelegate)
|
|
NotificationHandler.shared().add(operation: operation)
|
|
}
|
|
|
|
/// Cancel the current top alert view.
|
|
public func hideNotification() {
|
|
guard let currentOperation = queue.operations.first(where: { operation in
|
|
return operation.isExecuting
|
|
}) as? NotificationOperation else { return }
|
|
currentOperation.notificationModel.persistent = false
|
|
currentOperation.reAddAfterCancel = false
|
|
currentOperation.cancel()
|
|
}
|
|
|
|
/** Iterates through all scheduled notifications and cancels any that match the provided predicate.
|
|
* @param predicate The predicate block to decide whether to cancel an notification.
|
|
*/
|
|
public func cancelNotification(using predicate: ((UIView, NotificationModel) -> Bool)) {
|
|
for case let operation as NotificationOperation in queue.operations {
|
|
if predicate(operation.notification, operation.notificationModel) {
|
|
operation.reAddAfterCancel = false
|
|
operation.cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cancel all notifications, current or pending.
|
|
public func removeAllNotifications() {
|
|
queue.cancelAllOperations()
|
|
}
|
|
}
|
|
|
|
extension NotificationHandler: 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? NotificationOperation
|
|
}.sorted {
|
|
$0.queuePriority.rawValue > $1.queuePriority.rawValue
|
|
}.forEach {
|
|
$0.updateDisplayable(by: pageType)
|
|
}
|
|
reevaluteQueue()
|
|
}
|
|
}
|
|
|
|
extension NotificationOperation {
|
|
/// Updates the operation and notification with the new model.
|
|
public func update(with model: NotificationModel, delegateObject: MVMCoreUIDelegateObject?) {
|
|
self.notificationModel = model
|
|
queuePriority = model.priority
|
|
guard isExecuting,
|
|
!isCancelled else { return }
|
|
updateStopTimer()
|
|
Task { @MainActor in
|
|
transitionDelegate.update(with: notificationModel, delegateObject: delegateObject)
|
|
}
|
|
}
|
|
|
|
/// Updates if the operation is displayable based on the page type.
|
|
func updateDisplayable(by pageType: String?) {
|
|
guard let pages = notificationModel.pages else {
|
|
isDisplayable = true
|
|
return
|
|
}
|
|
guard let pageType = pageType else {
|
|
isDisplayable = false
|
|
return
|
|
}
|
|
isDisplayable = pages.contains(pageType)
|
|
}
|
|
}
|