// // NativeToastManager.swift // OutLive // // A native toast presenter styled to mirror the app's glassy, elevated UI. // import Foundation import UIKit @objc(NativeToastManager) class NativeToastManager: NSObject { private var currentToastView: ToastView? private var hideWorkItem: DispatchWorkItem? @objc static func requiresMainQueueSetup() -> Bool { true } @objc func show(_ options: NSDictionary) { let payload = ToastPayload(dictionary: options) guard payload.isValid else { return } DispatchQueue.main.async { [weak self] in self?.presentToast(with: payload) } } // MARK: - Presentation private func presentToast(with payload: ToastPayload) { guard let window = keyWindow else { return } hideWorkItem?.cancel() hideWorkItem = nil if let current = currentToastView { dismissToast(current, animated: false) } let toastView = ToastView(payload: payload) currentToastView = toastView window.addSubview(toastView) NSLayoutConstraint.activate([ toastView.centerXAnchor.constraint(equalTo: window.centerXAnchor), toastView.topAnchor.constraint(equalTo: window.safeAreaLayoutGuide.topAnchor, constant: 18), toastView.leadingAnchor.constraint(greaterThanOrEqualTo: window.leadingAnchor, constant: 20), toastView.trailingAnchor.constraint(lessThanOrEqualTo: window.trailingAnchor, constant: -20) ]) window.layoutIfNeeded() toastView.alpha = 0 toastView.transform = CGAffineTransform(translationX: 0, y: -26).scaledBy(x: 0.9, y: 0.9) let notificationFeedback = UINotificationFeedbackGenerator() let selectionFeedback = UISelectionFeedbackGenerator() if payload.style.feedbackType != nil { notificationFeedback.prepare() } else { selectionFeedback.prepare() } UIView.animate( withDuration: 0.55, delay: 0, usingSpringWithDamping: 0.82, initialSpringVelocity: 0.6, options: [.curveEaseOut, .allowUserInteraction] ) { toastView.alpha = 1 toastView.transform = .identity } completion: { _ in if let feedback = payload.style.feedbackType { notificationFeedback.notificationOccurred(feedback) } else { selectionFeedback.selectionChanged() } if UIAccessibility.isVoiceOverRunning { UIAccessibility.post(notification: .announcement, argument: payload.message) } } let workItem = DispatchWorkItem { [weak self, weak toastView] in guard let toastView, let self else { return } self.dismissToast(toastView, animated: true) } hideWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + payload.duration, execute: workItem) } private func dismissToast(_ toastView: ToastView, animated: Bool) { let cleanup: () -> Void = { [weak self, weak toastView] in guard let toastView else { return } toastView.removeFromSuperview() if self?.currentToastView === toastView { self?.currentToastView = nil } } guard animated else { cleanup() return } UIView.animate( withDuration: 0.28, delay: 0, options: [.curveEaseIn, .allowUserInteraction] ) { toastView.alpha = 0 toastView.transform = CGAffineTransform(translationX: 0, y: -22).scaledBy(x: 0.88, y: 0.88) } completion: { _ in cleanup() } } // MARK: - Window Discovery private var keyWindow: UIWindow? { if #available(iOS 13.0, *) { return UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .first(where: { $0.activationState == .foregroundActive })? .windows .first(where: { $0.isKeyWindow }) } else { #if !os(tvOS) return UIApplication.shared.keyWindow #else return nil #endif } } } // MARK: - Payload & Style Models private struct ToastPayload { let message: String let duration: TimeInterval let style: ToastStyle var isValid: Bool { !message.isEmpty } init(dictionary: NSDictionary) { let primaryMessage = (dictionary["message"] as? String)?.trimmed ?? "" let fallbackMessage = (dictionary["text1"] as? String)?.trimmed ?? "" message = primaryMessage.isEmpty ? fallbackMessage : primaryMessage let durationMs = dictionary["duration"] as? Double ?? 2000 duration = max(1.0, min(durationMs / 1000.0, 10.0)) let type = (dictionary["type"] as? String) ?? "default" let overrides = ToastStyleOverrides( backgroundHex: dictionary["backgroundColor"] as? String, textHex: dictionary["textColor"] as? String, icon: dictionary["icon"] as? String ) style = ToastStyle(styleName: type, overrides: overrides) } } private struct ToastStyleOverrides { let backgroundColor: UIColor? let textColor: UIColor? let icon: String? init(backgroundHex: String?, textHex: String?, icon: String?) { backgroundColor = backgroundHex.flatMap { UIColor.fromHexString($0) } textColor = textHex.flatMap { UIColor.fromHexString($0) } self.icon = icon?.trimmed } } private enum ToastIcon { case system(name: String) case glyph(String) case none static func from(raw: String?, fallback: ToastIcon) -> ToastIcon { guard let trimmed = raw?.trimmed, !trimmed.isEmpty else { return fallback } switch trimmed.lowercased() { case "none", "null": return .none default: break } if UIImage(systemName: trimmed) != nil { return .system(name: trimmed) } return .glyph(trimmed) } var hasVisual: Bool { switch self { case .none: return false default: return true } } } private struct ToastStyle { let gradientColors: [UIColor] let textColor: UIColor let icon: ToastIcon let iconTint: UIColor let iconBackground: UIColor let borderColor: UIColor let shadowColor: UIColor let feedbackType: UINotificationFeedbackGenerator.FeedbackType? init(styleName: String, overrides: ToastStyleOverrides) { let baseColor: UIColor let defaultIcon: ToastIcon let defaultFeedback: UINotificationFeedbackGenerator.FeedbackType? switch styleName.lowercased() { case "success": baseColor = Palette.success defaultIcon = .system(name: "checkmark.seal.fill") defaultFeedback = .success case "error", "danger", "fail": baseColor = Palette.error defaultIcon = .system(name: "xmark.octagon.fill") defaultFeedback = .error case "warning": baseColor = Palette.warning defaultIcon = .system(name: "exclamationmark.triangle.fill") defaultFeedback = .warning case "info": baseColor = Palette.info defaultIcon = .system(name: "info.circle.fill") defaultFeedback = nil default: baseColor = Palette.primary defaultIcon = .system(name: "sparkles") defaultFeedback = nil } let accent = overrides.backgroundColor ?? baseColor textColor = overrides.textColor ?? .white iconTint = textColor icon = ToastIcon.from(raw: overrides.icon, fallback: defaultIcon) gradientColors = accent.gradientPair iconBackground = accent.lighten(by: 0.3).withAlphaComponent(0.22) borderColor = accent.lighten(by: 0.55).withAlphaComponent(0.32) shadowColor = accent.darken(by: 0.55).withAlphaComponent(0.5) feedbackType = defaultFeedback } } // MARK: - Toast View private final class ToastView: UIView { private let payload: ToastPayload private let gradientLayer = CAGradientLayer() private let highlightLayer = CALayer() private let backgroundView = UIView() private let iconImageView = UIImageView() private let iconLabel = UILabel() init(payload: ToastPayload) { self.payload = payload super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false accessibilityTraits = .staticText accessibilityLabel = payload.message configureShadow() configureBackground() configureContent() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = backgroundView.bounds highlightLayer.frame = backgroundView.bounds layer.shadowPath = UIBezierPath( roundedRect: backgroundView.bounds, cornerRadius: backgroundView.layer.cornerRadius ).cgPath } private func configureShadow() { layer.shadowColor = payload.style.shadowColor.cgColor layer.shadowOpacity = 0.32 layer.shadowRadius = 18 layer.shadowOffset = CGSize(width: 0, height: 12) } private func configureBackground() { backgroundView.translatesAutoresizingMaskIntoConstraints = false backgroundView.layer.cornerRadius = 22 backgroundView.layer.masksToBounds = true backgroundView.layer.borderWidth = 1 / UIScreen.main.scale backgroundView.layer.borderColor = payload.style.borderColor.cgColor addSubview(backgroundView) NSLayoutConstraint.activate([ backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), backgroundView.topAnchor.constraint(equalTo: topAnchor), backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) gradientLayer.colors = payload.style.gradientColors.map { $0.cgColor } gradientLayer.startPoint = CGPoint(x: 0, y: 0) gradientLayer.endPoint = CGPoint(x: 1, y: 1) gradientLayer.locations = [0, 1] gradientLayer.cornerRadius = backgroundView.layer.cornerRadius highlightLayer.backgroundColor = UIColor.white.withAlphaComponent(0.06).cgColor highlightLayer.cornerRadius = backgroundView.layer.cornerRadius backgroundView.layer.insertSublayer(gradientLayer, at: 0) backgroundView.layer.insertSublayer(highlightLayer, at: 1) } private func configureContent() { let contentStack = UIStackView() contentStack.axis = .horizontal contentStack.alignment = .center contentStack.spacing = 14 contentStack.translatesAutoresizingMaskIntoConstraints = false backgroundView.addSubview(contentStack) NSLayoutConstraint.activate([ contentStack.topAnchor.constraint(equalTo: backgroundView.topAnchor, constant: 16), contentStack.bottomAnchor.constraint(equalTo: backgroundView.bottomAnchor, constant: -16), contentStack.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor, constant: 20), contentStack.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor, constant: -20) ]) if payload.style.icon.hasVisual { let iconContainer = UIView() iconContainer.translatesAutoresizingMaskIntoConstraints = false iconContainer.backgroundColor = payload.style.iconBackground iconContainer.layer.cornerRadius = 18 iconContainer.layer.masksToBounds = false iconContainer.layer.borderWidth = 1 / UIScreen.main.scale iconContainer.layer.borderColor = payload.style.iconTint.withAlphaComponent(0.18).cgColor iconImageView.translatesAutoresizingMaskIntoConstraints = false iconImageView.tintColor = payload.style.iconTint iconImageView.contentMode = .scaleAspectFit iconLabel.translatesAutoresizingMaskIntoConstraints = false iconLabel.textColor = payload.style.iconTint iconLabel.font = UIFont.systemFont(ofSize: 16, weight: .bold) iconLabel.textAlignment = .center iconContainer.addSubview(iconImageView) iconContainer.addSubview(iconLabel) NSLayoutConstraint.activate([ iconContainer.widthAnchor.constraint(equalToConstant: 36), iconContainer.heightAnchor.constraint(equalToConstant: 36), iconImageView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), iconImageView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), iconImageView.heightAnchor.constraint(equalToConstant: 20), iconImageView.widthAnchor.constraint(equalToConstant: 20), iconLabel.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), iconLabel.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), iconLabel.leadingAnchor.constraint(equalTo: iconContainer.leadingAnchor, constant: 2), iconLabel.trailingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -2) ]) switch payload.style.icon { case .system(let name): iconImageView.image = UIImage(systemName: name) iconImageView.isHidden = false iconLabel.isHidden = true iconLabel.text = nil case .glyph(let glyph): iconLabel.text = glyph iconImageView.isHidden = true iconLabel.isHidden = false iconImageView.image = nil case .none: iconImageView.isHidden = true iconLabel.isHidden = true iconImageView.image = nil iconLabel.text = nil } contentStack.addArrangedSubview(iconContainer) } let messageLabel = UILabel() messageLabel.translatesAutoresizingMaskIntoConstraints = false messageLabel.text = payload.message messageLabel.textColor = payload.style.textColor messageLabel.numberOfLines = 0 messageLabel.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont( for: UIFont.systemFont(ofSize: 15, weight: .semibold) ) messageLabel.adjustsFontForContentSizeCategory = true messageLabel.setContentCompressionResistancePriority(.required, for: .horizontal) messageLabel.setContentCompressionResistancePriority(.required, for: .vertical) messageLabel.setContentHuggingPriority(.required, for: .vertical) contentStack.addArrangedSubview(messageLabel) } } // MARK: - Palette private enum Palette { static let primary = UIColor(hex: 0x7A5AF8) static let success = UIColor(hex: 0x19B36E) static let error = UIColor(hex: 0xF95555) static let warning = UIColor(hex: 0xFFAD42) static let info = UIColor(hex: 0x6938EF) } // MARK: - Helpers private extension UIColor { convenience init(hex: UInt32, alpha: CGFloat = 1.0) { let red = CGFloat((hex & 0xFF0000) >> 16) / 255.0 let green = CGFloat((hex & 0x00FF00) >> 8) / 255.0 let blue = CGFloat(hex & 0x0000FF) / 255.0 self.init(red: red, green: green, blue: blue, alpha: alpha) } static func fromHexString(_ hexString: String) -> UIColor? { var string = hexString.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() if string.hasPrefix("#") { string.removeFirst() } else if string.hasPrefix("0X") { string.removeFirst(2) } guard string.count == 6 || string.count == 8 else { return nil } var hexValue: UInt64 = 0 guard Scanner(string: string).scanHexInt64(&hexValue) else { return nil } if string.count == 8 { let alpha = CGFloat(hexValue & 0xFF) / 255.0 let rgb = UInt32(hexValue >> 8) return UIColor(hex: rgb, alpha: alpha) } else { return UIColor(hex: UInt32(hexValue)) } } func mix(with color: UIColor, amount: CGFloat) -> UIColor { let amount = max(0, min(1, amount)) var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0 guard getRed(&r1, green: &g1, blue: &b1, alpha: &a1), color.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) else { return self } let red = r1 * (1 - amount) + r2 * amount let green = g1 * (1 - amount) + g2 * amount let blue = b1 * (1 - amount) + b2 * amount let alpha = a1 * (1 - amount) + a2 * amount return UIColor(red: red, green: green, blue: blue, alpha: alpha) } func lighten(by amount: CGFloat) -> UIColor { mix(with: .white, amount: amount) } func darken(by amount: CGFloat) -> UIColor { mix(with: .black, amount: amount) } var gradientPair: [UIColor] { [lighten(by: 0.18), darken(by: 0.12)] } } private extension String { var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } }