mvm_core_ui/MVMCoreUI/Notification/TopNotificationHandler.swift
2023-05-26 13:00:13 -04:00

471 lines
18 KiB
Swift

//
// 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
}
}