diff --git a/MVMCoreUI/Atomic/Actions/ActionCollapseNotificationHandler.swift b/MVMCoreUI/Atomic/Actions/ActionCollapseNotificationHandler.swift index 449d0c9c..626bc203 100644 --- a/MVMCoreUI/Atomic/Actions/ActionCollapseNotificationHandler.swift +++ b/MVMCoreUI/Atomic/Actions/ActionCollapseNotificationHandler.swift @@ -21,9 +21,9 @@ open class ActionCollapseNotificationHandler: MVMCoreActionHandlerProtocol { required public init() {} open func execute(with model: ActionModelProtocol, delegateObject: DelegateObject?, additionalData: [AnyHashable : Any]?) async throws { - guard let notification = await NotificationHandler.shared().getCurrentNotification() else { return } + guard let notification = await NotificationHandler.shared()?.getCurrentNotification() else { return } guard let notification = notification.0 as? CollapsableNotificationProtocol else { - NotificationHandler.shared().hideNotification() + NotificationHandler.shared()?.hideNotification() return } await notification.collapse() diff --git a/MVMCoreUI/Atomic/Actions/ActionDismissNotificationHandler.swift b/MVMCoreUI/Atomic/Actions/ActionDismissNotificationHandler.swift index 34ca66fa..6e34884e 100644 --- a/MVMCoreUI/Atomic/Actions/ActionDismissNotificationHandler.swift +++ b/MVMCoreUI/Atomic/Actions/ActionDismissNotificationHandler.swift @@ -9,11 +9,11 @@ import Foundation import MVMCore -/// Dismiss the current top notification. +/// Dismiss the current notification. open class ActionDismissNotificationHandler: MVMCoreActionHandlerProtocol { required public init() {} open func execute(with model: ActionModelProtocol, delegateObject: DelegateObject?, additionalData: [AnyHashable : Any]?) async throws { - NotificationHandler.shared().hideNotification() + NotificationHandler.shared()?.hideNotification() } } diff --git a/MVMCoreUI/Atomic/Actions/ActionTopNotificationHandler.swift b/MVMCoreUI/Atomic/Actions/ActionTopNotificationHandler.swift index eedfcd9a..47db28cc 100644 --- a/MVMCoreUI/Atomic/Actions/ActionTopNotificationHandler.swift +++ b/MVMCoreUI/Atomic/Actions/ActionTopNotificationHandler.swift @@ -15,6 +15,6 @@ open class ActionTopNotificationHandler: MVMCoreActionHandlerProtocol { open func execute(with model: ActionModelProtocol, delegateObject: DelegateObject?, additionalData: [AnyHashable : Any]?) async throws { guard let model = model as? ActionTopNotificationModel else { return } - try await NotificationHandler.shared().showNotification(for: model.topNotification, delegateObject: delegateObject as? MVMCoreUIDelegateObject) + try await NotificationHandler.shared()?.showNotification(for: model.topNotification, delegateObject: delegateObject as? MVMCoreUIDelegateObject) } } diff --git a/MVMCoreUI/Atomic/Atoms/Views/Label/LabelModel.swift b/MVMCoreUI/Atomic/Atoms/Views/Label/LabelModel.swift index d1995d19..5a84cae5 100644 --- a/MVMCoreUI/Atomic/Atoms/Views/Label/LabelModel.swift +++ b/MVMCoreUI/Atomic/Atoms/Views/Label/LabelModel.swift @@ -60,9 +60,10 @@ // MARK: - Initializer //-------------------------------------------------- - public init(id: String = UUID().uuidString, text: String) { + public init(id: String = UUID().uuidString, text: String, textColor: Color? = nil) { self.id = id self.text = text + self.textColor = textColor } //-------------------------------------------------- diff --git a/MVMCoreUI/Atomic/Molecules/TopNotification/CollapsableNotification.swift b/MVMCoreUI/Atomic/Molecules/TopNotification/CollapsableNotification.swift index d91aba51..22491a49 100644 --- a/MVMCoreUI/Atomic/Molecules/TopNotification/CollapsableNotification.swift +++ b/MVMCoreUI/Atomic/Molecules/TopNotification/CollapsableNotification.swift @@ -7,6 +7,9 @@ // import Foundation +import Combine +import Dispatch +import MVMCore @objcMembers open class CollapsableNotification: View { //-------------------------------------------------- @@ -16,6 +19,9 @@ import Foundation public let topView = CollapsableNotificationTopView() public let bottomView = NotificationMoleculeView() public var verticalStack: UIStackView! + + public var cancellables = Set() + private var timerSource: DispatchSourceTimer? //-------------------------------------------------- // MARK: - Life Cycle @@ -33,6 +39,7 @@ import Foundation NSLayoutConstraint.constraintPinSubview(verticalStack, pinTop: true, topConstant: 0, pinBottom: true, bottomConstant: 0, pinLeft: true, leftConstant: 0, pinRight: true, rightConstant: 0) reset() + subscribeForNotifications() } open override func updateView(_ size: CGFloat) { @@ -46,6 +53,42 @@ import Foundation backgroundColor = .mvmGreen() } + open func subscribeForNotifications() { + // Resets state when about to show. + NotificationHandler.shared()?.onNotificationWillShow.receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] (view, _) in + guard let self = self, + self == view else { return } + self.initialState() + }).store(in: &cancellables) + // Begins the collapse timer when shown. + NotificationHandler.shared()?.onNotificationShown.receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] (view, _) in + guard let self = self, + self == view, + let model = self.model as? CollapsableNotificationModel else { return } + if !model.initiallyCollapsed { + self.autoCollapse() + } + }).store(in: &cancellables) + // Cancels any collapse timer when dismissing + NotificationHandler.shared()?.onNotificationWillDismiss.sink(receiveValue: { [weak self] (view, _) in + guard let self = self, + self == view else { return } + timerSource?.cancel() + timerSource = nil + }).store(in: &cancellables) + } + + /// Set initial collapse/expand state. + public func initialState() { + guard let model = model as? CollapsableNotificationModel else { return } + topView.isHidden = !model.alwaysShowTopLabel && !model.initiallyCollapsed + topView.button.isUserInteractionEnabled = model.initiallyCollapsed + bottomView.isHidden = model.initiallyCollapsed + verticalStack.layoutIfNeeded() + } + //-------------------------------------------------- // MARK: - Molecule //-------------------------------------------------- @@ -66,16 +109,7 @@ import Foundation self?.expand(topViewShowing: model.alwaysShowTopLabel) } } - - // Set initial collapse/expand state. - topView.isHidden = !model.alwaysShowTopLabel && !model.initiallyCollapsed - topView.button.isUserInteractionEnabled = model.initiallyCollapsed - bottomView.isHidden = model.initiallyCollapsed - verticalStack.layoutIfNeeded() - - if !model.initiallyCollapsed { - autoCollapse() - } + initialState() } open func performBlockOperation(with block: @escaping (MVMCoreBlockOperation) -> Void) { @@ -86,33 +120,39 @@ import Foundation /// Collapses after a delay open func autoCollapse() { let delay: DispatchTimeInterval = DispatchTimeInterval.seconds((model as? CollapsableNotificationModel)?.collapseTime ?? 5) - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + timerSource = DispatchSource.makeTimerSource() + timerSource?.setEventHandler(handler: { [weak self] in // If accessibility focused, delay collapse. guard let self = self else { return } - if MVMCoreUIUtility.viewContainsAccessiblityFocus(self) { - NotificationCenter.default.addObserver(self, selector: #selector(self.accessibilityFocusChanged(notification:)), name: UIAccessibility.elementFocusedNotification, object: nil) - } else { - self.collapse() - } - } + self.timerSource = nil + MVMCoreDispatchUtility.performBlock(onMainThread: { + if MVMCoreUIUtility.viewContainsAccessiblityFocus(self) { + NotificationCenter.default.addObserver(self, selector: #selector(self.accessibilityFocusChanged(notification:)), name: UIAccessibility.elementFocusedNotification, object: nil) + } else { + self.collapse() + } + }) + }) + timerSource?.schedule(deadline: .now() + delay) + timerSource?.activate() } /// Collapses to show just the top view. open func collapse(animated: Bool = true) { guard !bottomView.isHidden else { return } performBlockOperation { [weak self] (operation) in - let strongSelf = self + guard let strongSelf = self else { return } MVMCoreDispatchUtility.performBlock(onMainThread: { - strongSelf?.superview?.superview?.layoutIfNeeded() + strongSelf.superview?.superview?.layoutIfNeeded() let animation = { - strongSelf?.topView.isHidden = false - strongSelf?.bottomView.isHidden = true - strongSelf?.verticalStack.layoutIfNeeded() + strongSelf.topView.isHidden = false + strongSelf.bottomView.isHidden = true + strongSelf.verticalStack.layoutIfNeeded() } let completion: (Bool) -> Void = { (finished) in - strongSelf?.topView.button.isUserInteractionEnabled = true - strongSelf?.superview?.superview?.layoutIfNeeded() - UIAccessibility.post(notification: .layoutChanged, argument: strongSelf?.getAccessibilityLayoutChangedArgument()) + strongSelf.topView.button.isUserInteractionEnabled = true + strongSelf.superview?.superview?.layoutIfNeeded() + UIAccessibility.post(notification: .layoutChanged, argument: strongSelf.getAccessibilityLayoutChangedArgument()) operation.markAsFinished() } @@ -130,19 +170,19 @@ import Foundation open func expand(topViewShowing: Bool = false, animated: Bool = true) { guard bottomView.isHidden else { return } performBlockOperation { [weak self] (operation) in - let strongSelf = self + guard let strongSelf = self else { return } MVMCoreDispatchUtility.performBlock(onMainThread: { - strongSelf?.superview?.superview?.layoutIfNeeded() - strongSelf?.topView.button.isUserInteractionEnabled = false + strongSelf.superview?.superview?.layoutIfNeeded() + strongSelf.topView.button.isUserInteractionEnabled = false let animation = { - strongSelf?.topView.isHidden = !topViewShowing - strongSelf?.bottomView.isHidden = false - strongSelf?.verticalStack.layoutIfNeeded() + strongSelf.topView.isHidden = !topViewShowing + strongSelf.bottomView.isHidden = false + strongSelf.verticalStack.layoutIfNeeded() } let completion: (Bool) -> Void = { (finished) in - strongSelf?.superview?.superview?.layoutIfNeeded() - UIAccessibility.post(notification: .layoutChanged, argument: strongSelf?.getAccessibilityLayoutChangedArgument()) - strongSelf?.autoCollapse() + strongSelf.superview?.superview?.layoutIfNeeded() + UIAccessibility.post(notification: .layoutChanged, argument: strongSelf.getAccessibilityLayoutChangedArgument()) + strongSelf.autoCollapse() operation.markAsFinished() } diff --git a/MVMCoreUI/Atomic/Molecules/TopNotification/CollapsableNotificationModel.swift b/MVMCoreUI/Atomic/Molecules/TopNotification/CollapsableNotificationModel.swift index 8bcf2569..3d366347 100644 --- a/MVMCoreUI/Atomic/Molecules/TopNotification/CollapsableNotificationModel.swift +++ b/MVMCoreUI/Atomic/Molecules/TopNotification/CollapsableNotificationModel.swift @@ -7,6 +7,7 @@ // import Foundation +import MVMCore open class CollapsableNotificationModel: NotificationMoleculeModel { public class override var identifier: String { @@ -17,11 +18,14 @@ open class CollapsableNotificationModel: NotificationMoleculeModel { public var alwaysShowTopLabel = false public var collapseTime: Int = 5 public var initiallyCollapsed = false - public var pages: [String]? - public init(with topLabel: LabelModel, headline: LabelModel) { + public init(with topLabel: LabelModel, headline: LabelModel, style: NotificationMoleculeModel.Style = .success, backgroundColor: Color? = nil, topAction: ActionModelProtocol? = nil, collapseTime: Int? = nil, body: LabelModel? = nil, button: ButtonModel? = nil, closeButton: NotificationXButtonModel? = nil) { self.topLabel = topLabel - super.init(with: headline) + self.topAction = topAction + if let collapseTime = collapseTime { + self.collapseTime = collapseTime + } + super.init(with: headline, style: style, backgroundColor: backgroundColor, body: body, button: button, closeButton: closeButton) } open override func setDefaults() { @@ -30,7 +34,12 @@ open class CollapsableNotificationModel: NotificationMoleculeModel { topLabel.numberOfLines = 1 } if topLabel.textColor == nil { - topLabel.textColor = Color(uiColor: .white) + switch style { + case .error, .warning: + topLabel.textColor = Color(uiColor: .mvmBlack) + default: + topLabel.textColor = Color(uiColor: .mvmWhite) + } } if topLabel.textAlignment == nil { topLabel.textAlignment = .center @@ -44,7 +53,6 @@ open class CollapsableNotificationModel: NotificationMoleculeModel { case alwaysShowTopLabel case collapseTime case initiallyCollapsed - case pages } required public init(from decoder: Decoder) throws { @@ -60,7 +68,6 @@ open class CollapsableNotificationModel: NotificationMoleculeModel { if let initiallyCollapsed = try typeContainer.decodeIfPresent(Bool.self, forKey: .initiallyCollapsed) { self.initiallyCollapsed = initiallyCollapsed } - pages = try typeContainer.decodeIfPresent([String].self, forKey: .pages) try super.init(from: decoder) } @@ -73,6 +80,5 @@ open class CollapsableNotificationModel: NotificationMoleculeModel { try container.encode(alwaysShowTopLabel, forKey: .alwaysShowTopLabel) try container.encode(collapseTime, forKey: .collapseTime) try container.encode(initiallyCollapsed, forKey: .initiallyCollapsed) - try container.encodeIfPresent(pages, forKey: .pages) } } diff --git a/MVMCoreUI/Atomic/Molecules/TopNotification/NotificationMoleculeModel.swift b/MVMCoreUI/Atomic/Molecules/TopNotification/NotificationMoleculeModel.swift index 911ef63b..d26bb06c 100644 --- a/MVMCoreUI/Atomic/Molecules/TopNotification/NotificationMoleculeModel.swift +++ b/MVMCoreUI/Atomic/Molecules/TopNotification/NotificationMoleculeModel.swift @@ -40,8 +40,13 @@ open class NotificationMoleculeModel: ContainerModel, MoleculeModelProtocol { // MARK: - Initializer //-------------------------------------------------- - public init(with headline: LabelModel) { + public init(with headline: LabelModel, style: NotificationMoleculeModel.Style = .success, backgroundColor: Color? = nil, body: LabelModel? = nil, button: ButtonModel? = nil, closeButton: NotificationXButtonModel? = nil) { self.headline = headline + self.style = style + self.backgroundColor = backgroundColor + self.body = body + self.button = button + self.closeButton = closeButton super.init() } diff --git a/MVMCoreUI/Atomic/Molecules/TopNotification/NotificationXButtonModel.swift b/MVMCoreUI/Atomic/Molecules/TopNotification/NotificationXButtonModel.swift index 59da1868..71324dff 100644 --- a/MVMCoreUI/Atomic/Molecules/TopNotification/NotificationXButtonModel.swift +++ b/MVMCoreUI/Atomic/Molecules/TopNotification/NotificationXButtonModel.swift @@ -7,6 +7,7 @@ // import Foundation +import MVMCore public class NotificationXButtonModel: ButtonModelProtocol, MoleculeModelProtocol { public static var identifier: String = "notificationXButton" @@ -20,7 +21,10 @@ public class NotificationXButtonModel: ButtonModelProtocol, MoleculeModelProtoco case action } - public init() {} + public init(color: Color? = nil, action: ActionModelProtocol = ActionNoopModel()) { + self.color = color + self.action = action + } public required init(from decoder: Decoder) throws { let typeContainer = try decoder.container(keyedBy: CodingKeys.self) diff --git a/MVMCoreUI/Containers/SplitViewController/MVMCoreUISplitViewController+Extension.swift b/MVMCoreUI/Containers/SplitViewController/MVMCoreUISplitViewController+Extension.swift index 3f1562c9..8b509827 100644 --- a/MVMCoreUI/Containers/SplitViewController/MVMCoreUISplitViewController+Extension.swift +++ b/MVMCoreUI/Containers/SplitViewController/MVMCoreUISplitViewController+Extension.swift @@ -187,7 +187,7 @@ public extension MVMCoreUISplitViewController { let model = navigationController.getNavigationModel(from: viewController) else { return } setNavigationBar(for: viewController, navigationController: navigationController, navigationItemModel: model) Task { - guard (await NotificationHandler.shared().getCurrentNotification()?.0 as? StatusBarUI) == nil else { return } + guard (await NotificationHandler.shared()?.getCurrentNotification()?.0 as? StatusBarUI) == nil || topAlertView == nil else { return } setStatusBarForCurrentViewController() } } @@ -241,21 +241,24 @@ extension MVMCoreUISplitViewController: MVMCoreViewManagerProtocol { var cancellables = Set() // Ensure the status bar background color and tint are proper for the notification. - NotificationHandler.shared().onNotificationWillShow.sink { [weak self] (notification, model) in + NotificationHandler.shared()?.onNotificationWillShow.receive(on: DispatchQueue.main) + .sink { [weak self] (notification, model) in guard let conformer = notification as? StatusBarUI else { return } let statusBarUI = conformer.getStatusBarUI() self?.setStatusBarBackgroundColor(statusBarUI.color, style: statusBarUI.style) }.store(in: &cancellables) - NotificationHandler.shared().onNotificationUpdated.sink { [weak self] (notification, model) in + NotificationHandler.shared()?.onNotificationUpdated.receive(on: DispatchQueue.main) + .sink { [weak self] (notification, model) in guard let conformer = notification as? StatusBarUI else { return } let statusBarUI = conformer.getStatusBarUI() self?.setStatusBarBackgroundColor(statusBarUI.color, style: statusBarUI.style) }.store(in: &cancellables) // Ensure the status bar background color and tint are proper for the view controller. - NotificationHandler.shared().onNotificationDismissed.sink { (notification, model) in - guard let conformer = notification as? StatusBarUI else { return } + NotificationHandler.shared()?.onNotificationDismissed.receive(on: DispatchQueue.main) + .sink { (notification, model) in + guard notification is StatusBarUI else { return } MVMCoreUISplitViewController.main()?.setStatusBarForCurrentViewController() }.store(in: &cancellables) self.cancellables = cancellables diff --git a/MVMCoreUI/Notification/NotificationContainerView.swift b/MVMCoreUI/Notification/NotificationContainerView.swift index dfbca4c2..f55fd5a2 100644 --- a/MVMCoreUI/Notification/NotificationContainerView.swift +++ b/MVMCoreUI/Notification/NotificationContainerView.swift @@ -106,7 +106,8 @@ extension NotificationContainerView: MVMCoreViewProtocol { } public func setupView() { - clipsToBounds = false + translatesAutoresizingMaskIntoConstraints = false + clipsToBounds = true height.isActive = true } } diff --git a/MVMCoreUI/Notification/NotificationHandler.swift b/MVMCoreUI/Notification/NotificationHandler.swift index 43047543..e959cb90 100644 --- a/MVMCoreUI/Notification/NotificationHandler.swift +++ b/MVMCoreUI/Notification/NotificationHandler.swift @@ -42,16 +42,16 @@ public class NotificationOperation: MVMCoreOperation { public var isDisplayable: Bool { get { var isDisplayable: Bool = true - displayableQueue.sync { + //displayableQueue.sync { isDisplayable = _isDisplayable - } + //} return isDisplayable } set { guard newValue != isDisplayable else { return } - displayableQueue.async(flags: .barrier) { [weak self] in - self?._isDisplayable = newValue - } + //displayableQueue.async(flags: .barrier) { [weak self] in + self._isDisplayable = newValue + //} } } /// Thread safety. @@ -114,13 +114,15 @@ public class NotificationOperation: MVMCoreOperation { } public override func main() { + log(message: "Operation Started") 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)) + NotificationHandler.shared()?.onNotificationWillShow.send((self.notification, self.notificationModel)) + self.log(message: "Operation Will Show") await self.showNotification() }, completionBlock: { continuation.resume() @@ -128,12 +130,14 @@ public class NotificationOperation: MVMCoreOperation { } guard await properties.getIsDisplayed() else { // If the animation did not complete... + log(message: "Operation Never Shown") markAsFinished() return } // Publish that the notification has been shown. - NotificationHandler.shared().onNotificationShown.send((notification, notificationModel)) + NotificationHandler.shared()?.onNotificationShown.send((notification, notificationModel)) + log(message: "Operation Did Show") guard !isCancelled else { // If cancelled during the animation, dismiss immediately. @@ -155,6 +159,8 @@ public class NotificationOperation: MVMCoreOperation { await withCheckedContinuation({ continuation in _ = add(transition: { [weak self] in guard let self = self else { return } + self.log(message: "Operation Will Dismiss") + NotificationHandler.shared()?.onNotificationWillDismiss.send((notification, notificationModel)) await self.hideNotification() }, completionBlock: { continuation.resume() @@ -164,16 +170,22 @@ public class NotificationOperation: MVMCoreOperation { 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 } + NotificationHandler.shared()?.onNotificationDismissed.send((notification, notificationModel)) + log(message: "Operation Did Dismiss") + + guard isCancelled || !notificationModel.persistent else { return } markAsFinished() } } + public override func markAsFinished() { + log(message: "Operation Finished") + super.markAsFinished() + } + public override func cancel() { super.cancel() + log(message: "Operation Cancelled") Task { if await !properties.getIsDisplayed() { // Cancel any pending show transitions. @@ -190,16 +202,8 @@ public class NotificationOperation: MVMCoreOperation { } } - 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 + public func log(message: String) { + MVMCoreUILoggingHandler.logDebugMessage(withDelegate: "------Notification message: \(message) type: \(notificationModel.type) id: \(notificationModel.id) priority: e\(notificationModel.priority.rawValue) a\(queuePriority.rawValue) operation: \(String(describing: self)) ------") } // MARK: - Automatic @@ -212,11 +216,11 @@ public class NotificationOperation: MVMCoreOperation { 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))") + self?.log(message: "Operation Time Out") 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) @@ -225,15 +229,17 @@ public class NotificationOperation: MVMCoreOperation { self.stop() }) timerSource?.setCancelHandler(handler: { [weak self] in - print("SSSS TIMER EVENT CANCELLED FOR: \(String(describing: self?.notificationModel.type))") + self?.log(message: "Operation Time Out Cancel") }) timerSource?.schedule(deadline: .now() + .seconds(notificationModel.dismissTime)) + timerSource?.activate() } /// 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 } + self.log(message: "Operation Accessibility Focus Removed") NotificationCenter.default.removeObserver(self, name: UIAccessibility.elementFocusedNotification, object: nil) stop() } @@ -271,30 +277,54 @@ public class NotificationOperation: MVMCoreOperation { } } +extension NotificationOperation: NSCopying { + 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 + } +} + /// Manages notifications. -public class NotificationHandler { +open class NotificationHandler { - private var queue = OperationQueue() + private var queue: OperationQueue = { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + return queue + }() private var transitionDelegate: NotificationTransitionDelegateProtocol - private var delegateObject: MVMCoreUIDelegateObject? - + // MARK: - Publishers + /// 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 will dismissed. + public let onNotificationWillDismiss = 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>() + // MARK: - + /// Returns the handler stored in the CoreUIObject - public static func shared() -> Self { - return MVMCoreActionUtility.fatalClassCheck(object: CoreUIObject.sharedInstance()?.topNotificationHandler) + public static func shared() -> Self? { + guard let shared = CoreUIObject.sharedInstance()?.topNotificationHandler else { return nil } + return MVMCoreActionUtility.fatalClassCheck(object: shared) } public init(with transitionDelegate: NotificationTransitionDelegateProtocol) { @@ -316,13 +346,13 @@ public class NotificationHandler { } /// Checks for new top alert json - @objc private func responseJSONUpdated(notification: Notification) async { + @objc private func responseJSONUpdated(notification: Notification) { 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 + NotificationHandler.shared()?.cancelNotification(using: { view, model in return model.type == disableType }) } @@ -336,7 +366,7 @@ public class NotificationHandler { } /// 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 { + open 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) @@ -369,7 +399,7 @@ public class NotificationHandler { } /// 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 { + open 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) @@ -389,7 +419,7 @@ public class NotificationHandler { guard !operation.isCancelled, !operation.isFinished else { continue } if operation.isReady, - highestReadyOperation == nil || operation.queuePriority.rawValue > highestReadyOperation!.queuePriority.rawValue { + highestReadyOperation == nil || operation.notificationModel.priority.rawValue > highestReadyOperation!.notificationModel.priority.rawValue { highestReadyOperation = operation } if operation.isExecuting { @@ -417,7 +447,7 @@ public class NotificationHandler { // MARK: - Verify /// Returns if any notification is executing - public func isNotificationShowing() -> Bool { + open func isNotificationShowing() -> Bool { return queue.operations.first(where: { operation in return operation.isExecuting }) != nil @@ -426,7 +456,7 @@ public class NotificationHandler { /** 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 { + open 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 } @@ -435,7 +465,7 @@ public class NotificationHandler { } /// Returns the current executing notification view and model - public func getCurrentNotification() async -> (UIView, NotificationModel)? { + open func getCurrentNotification() async -> (UIView, NotificationModel)? { for operation in queue.operations { guard operation.isExecuting, let operation = operation as? NotificationOperation, @@ -448,19 +478,25 @@ public class NotificationHandler { // MARK: - Show and hide /// Creates the view and queues up the notification. - public func showNotification(for model: NotificationModel, delegateObject: MVMCoreUIDelegateObject? = nil) async throws { + open func showNotification(for model: NotificationModel, delegateObject: MVMCoreUIDelegateObject? = nil) async throws { guard !checkAndUpdateExisting(with: model, delegateObject: delegateObject) else { return } + let view = try await createNotification(with: model, delegateObject: delegateObject) + let operation = NotificationOperation(with: view, notificationModel: model, transitionDelegate: transitionDelegate) + add(operation: operation) + } + + @MainActor + private func createNotification(with model: NotificationModel, delegateObject: MVMCoreUIDelegateObject?) async throws -> UIView & MoleculeViewProtocol { 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) + return view } /// Cancel the current top alert view. - public func hideNotification() { + open func hideNotification() { guard let currentOperation = queue.operations.first(where: { operation in - return operation.isExecuting + return operation.isExecuting && !operation.isCancelled }) as? NotificationOperation else { return } currentOperation.notificationModel.persistent = false currentOperation.reAddAfterCancel = false @@ -470,7 +506,7 @@ public class NotificationHandler { /** 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)) { + open 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 @@ -480,7 +516,7 @@ public class NotificationHandler { } /// Cancel all notifications, current or pending. - public func removeAllNotifications() { + open func removeAllNotifications() { queue.cancelAllOperations() } } @@ -492,14 +528,16 @@ extension NotificationHandler: MVMCorePresentationDelegateProtocol { 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) + Task { + queue.operations.compactMap { + $0 as? NotificationOperation + }.sorted { + $0.notificationModel.priority.rawValue > $1.notificationModel.priority.rawValue + }.forEach { + $0.updateDisplayable(by: pageType) + } + reevaluteQueue() } - reevaluteQueue() } } @@ -510,6 +548,7 @@ extension NotificationOperation { queuePriority = model.priority guard isExecuting, !isCancelled else { return } + self.log(message: "Operation Updated") updateStopTimer() Task { @MainActor in transitionDelegate.update(with: notificationModel, delegateObject: delegateObject) diff --git a/MVMCoreUI/Notification/NotificationModel.swift b/MVMCoreUI/Notification/NotificationModel.swift index 2de77988..435d95b6 100644 --- a/MVMCoreUI/Notification/NotificationModel.swift +++ b/MVMCoreUI/Notification/NotificationModel.swift @@ -69,12 +69,14 @@ open class NotificationModel: Codable, Identifiable { // MARK: - Initializer //-------------------------------------------------- - public init(with type: String, molecule: MoleculeModelProtocol, priority: Operation.QueuePriority = .normal, persistent: Bool = false, dismissTime: Int = 5, pages: [String]? = nil, analyticsData: JSONValueDictionary? = nil, id: String = UUID().uuidString) { + required public init(with type: String, molecule: MoleculeModelProtocol, priority: Operation.QueuePriority = .normal, persistent: Bool = false, dismissTime: Int? = nil, pages: [String]? = nil, analyticsData: JSONValueDictionary? = nil, id: String = UUID().uuidString) { self.type = type self.molecule = molecule self.priority = priority self.persistent = persistent - self.dismissTime = dismissTime + if let dismissTime = dismissTime { + self.dismissTime = dismissTime + } self.pages = pages self.analyticsData = analyticsData self.id = id diff --git a/MVMCoreUI/OtherHandlers/CoreUIObject.swift b/MVMCoreUI/OtherHandlers/CoreUIObject.swift index 6312581e..665bb4da 100644 --- a/MVMCoreUI/OtherHandlers/CoreUIObject.swift +++ b/MVMCoreUI/OtherHandlers/CoreUIObject.swift @@ -20,9 +20,6 @@ import MVMCore session = MVMCoreUISession() Task { @MainActor in self.sessionHandler = MVMCoreSessionTimeHandler() - let topAlertView = NotificationContainerView() - MVMCoreUISession.sharedGlobal()?.topAlertView = topAlertView - self.topNotificationHandler = NotificationHandler(with: topAlertView) } actionHandler = MVMCoreUIActionHandler() viewControllerMapping = MVMCoreUIViewControllerMappingObject()