diff --git a/Sources/SuperwallKit/Config/Models/FeatureFlags.swift b/Sources/SuperwallKit/Config/Models/FeatureFlags.swift index 9b6a76a4c8..6c05b9022b 100644 --- a/Sources/SuperwallKit/Config/Models/FeatureFlags.swift +++ b/Sources/SuperwallKit/Config/Models/FeatureFlags.swift @@ -22,6 +22,7 @@ struct FeatureFlags: Codable, Equatable { var enableMultiplePaywallUrls: Bool var enableConfigRefresh: Bool var enableTextInteraction: Bool + var enableSuperwallLogo: Bool enum CodingKeys: String, CodingKey { case toggles @@ -43,6 +44,7 @@ struct FeatureFlags: Codable, Equatable { enableMultiplePaywallUrls = rawFeatureFlags.value(forKey: "enable_multiple_paywall_urls", default: false) enableConfigRefresh = rawFeatureFlags.value(forKey: "enable_config_refresh_v2", default: false) enableTextInteraction = rawFeatureFlags.value(forKey: "enable_text_interaction", default: false) + enableSuperwallLogo = rawFeatureFlags.value(forKey: "enable_superwall_logo", default: false) } func encode(to encoder: Encoder) throws { @@ -57,7 +59,8 @@ struct FeatureFlags: Codable, Equatable { RawFeatureFlag(key: "enable_none_scheduling_policy", enabled: enableNoneSchedulingPolicy), RawFeatureFlag(key: "enable_multiple_paywall_urls", enabled: enableMultiplePaywallUrls), RawFeatureFlag(key: "enable_config_refresh_v2", enabled: enableConfigRefresh), - RawFeatureFlag(key: "enable_text_interaction", enabled: enableTextInteraction) + RawFeatureFlag(key: "enable_text_interaction", enabled: enableTextInteraction), + RawFeatureFlag(key: "enable_superwall_logo", enabled: enableSuperwallLogo) ] try container.encode(rawFeatureFlags, forKey: .toggles) @@ -73,7 +76,7 @@ struct FeatureFlags: Codable, Equatable { enableMultiplePaywallUrls: Bool, enableConfigRefresh: Bool, enableTextInteraction: Bool, - enableCELLogging: Bool + enableSuperwallLogo: Bool ) { self.enableExpressionParameters = enableExpressionParameters self.enableUserIdSeed = enableUserIdSeed @@ -84,6 +87,7 @@ struct FeatureFlags: Codable, Equatable { self.enableMultiplePaywallUrls = enableMultiplePaywallUrls self.enableConfigRefresh = enableConfigRefresh self.enableTextInteraction = enableTextInteraction + self.enableSuperwallLogo = enableSuperwallLogo } } @@ -111,7 +115,7 @@ extension FeatureFlags: Stubbable { enableMultiplePaywallUrls: true, enableConfigRefresh: true, enableTextInteraction: true, - enableCELLogging: true + enableSuperwallLogo: true ) } } diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index cd42854eef..bb4e5481ac 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -330,7 +330,7 @@ extension DependencyContainer: RequestFactory { responseIdentifiers: ResponseIdentifiers, overrides: PaywallRequest.Overrides? = nil, isDebuggerLaunched: Bool, - presentationSourceType: String? + presentationSourceType: PresentationSourceType? ) -> PaywallRequest { return PaywallRequest( placementData: placementData, diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index cf1d5665a2..808c6a464d 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -45,7 +45,7 @@ protocol RequestFactory: AnyObject { responseIdentifiers: ResponseIdentifiers, overrides: PaywallRequest.Overrides?, isDebuggerLaunched: Bool, - presentationSourceType: String? + presentationSourceType: PresentationSourceType? ) -> PaywallRequest func makePresentationRequest( diff --git a/Sources/SuperwallKit/Misc/DynamicIslandInfo.swift b/Sources/SuperwallKit/Misc/DynamicIslandInfo.swift new file mode 100644 index 0000000000..f61c2ff077 --- /dev/null +++ b/Sources/SuperwallKit/Misc/DynamicIslandInfo.swift @@ -0,0 +1,240 @@ +// +// DynamicIslandInfo.swift +// SuperwallKit +// +// Created by Yusuf Tör on 26/11/2025. +// + +#if !os(visionOS) +import UIKit + +/// Information about the Dynamic Island or notch for positioning UI elements like the Superwall logo. +struct DynamicIslandInfo { + /// Whether the current device has a Dynamic Island. + let hasDynamicIsland: Bool + + /// Whether the current device has a notch (but not a Dynamic Island). + let hasNotch: Bool + + /// Whether the current device has either a Dynamic Island or a notch. + var hasDynamicIslandOrNotch: Bool { + hasDynamicIsland || hasNotch + } + + /// The width of the Dynamic Island in points. Zero for notch devices. + let width: CGFloat + + /// The height of the Dynamic Island in points. Zero for notch devices. + let height: CGFloat + + /// The corner radius of the Dynamic Island in points (half the height for capsule shape). + static let cornerRadius: CGFloat = 18.32 + + /// The top padding from the screen edge to the Dynamic Island or notch. + let topPadding: CGFloat + + /// The frame of the Dynamic Island relative to the screen. + var frame: CGRect { + guard hasDynamicIsland else { return .zero } + let screenWidth = UIScreen.main.bounds.width + let x = (screenWidth - width) / 2 + return CGRect(x: x, y: topPadding, width: width, height: height) + } + + /// The area to the left of the Dynamic Island (left "ear"). + var leftEarFrame: CGRect { + guard hasDynamicIsland else { return .zero } + let screenWidth = UIScreen.main.bounds.width + let earWidth = (screenWidth - width) / 2 + return CGRect(x: 0, y: topPadding, width: earWidth, height: height) + } + + /// The area to the right of the Dynamic Island (right "ear"). + var rightEarFrame: CGRect { + guard hasDynamicIsland else { return .zero } + let screenWidth = UIScreen.main.bounds.width + let earWidth = (screenWidth - width) / 2 + let x = screenWidth - earWidth + return CGRect(x: x, y: topPadding, width: earWidth, height: height) + } + + /// The width of one ear (left or right area beside the Dynamic Island). + var earWidth: CGFloat { + guard hasDynamicIsland else { return 0 } + let screenWidth = UIScreen.main.bounds.width + return (screenWidth - width) / 2 + } + + /// The maximum logo width that can fit in an ear with padding. + /// Returns 0 if there's no Dynamic Island. + var maxLogoWidthInEar: CGFloat { + guard hasDynamicIsland else { return 0 } + // Leave 8pt padding on each side + return earWidth - 16 + } + + /// Gets the Dynamic Island info for the current device. + static var current: DynamicIslandInfo { + let modelName = UIDevice.modelName + return DynamicIslandInfo(for: modelName) + } + + /// Creates Dynamic Island info for a specific device model identifier. + /// - Parameter modelIdentifier: The device model identifier (e.g., "iPhone15,2"). + /// + /// Dynamic Island widths from Apple HIG: + /// https://developer.apple.com/design/human-interface-guidelines/live-activities#Specifications + init(for modelIdentifier: String) { + if DeviceSets.width230Devices.contains(modelIdentifier) { + self = Self.makeDynamicIsland230(for: modelIdentifier) + } else if DeviceSets.width250Devices.contains(modelIdentifier) { + self = Self.makeDynamicIsland250(for: modelIdentifier) + } else if DeviceSets.notchDevices.contains(modelIdentifier) { + self = Self.makeNotch() + } else { + self = Self.makeUnknown() + } + } +} + +// MARK: - Device Sets + +private enum DeviceSets { + /// Devices with 230pt Dynamic Island width + static let width230Devices: Set = [ + "iPhone15,2", // iPhone 14 Pro + "iPhone15,4", // iPhone 15 + "iPhone16,1", // iPhone 15 Pro + "iPhone17,1", // iPhone 16 Pro + "iPhone17,3", // iPhone 16 + "iPhone18,1", // iPhone 17 Pro + "iPhone18,3" // iPhone 17 + ] + + /// Devices with 250pt Dynamic Island width (larger phones: Plus/Pro Max/Air) + static let width250Devices: Set = [ + "iPhone15,3", // iPhone 14 Pro Max + "iPhone15,5", // iPhone 15 Plus + "iPhone16,2", // iPhone 15 Pro Max + "iPhone17,2", // iPhone 16 Pro Max + "iPhone17,4", // iPhone 16 Plus + "iPhone18,2", // iPhone 17 Pro Max + "iPhone18,4" // iPhone Air + ] + + /// Devices with notch (but not Dynamic Island) + static let notchDevices: Set = [ + "iPhone10,3", "iPhone10,6", // iPhone X + "iPhone11,2", // iPhone XS + "iPhone11,4", "iPhone11,6", // iPhone XS Max + "iPhone11,8", // iPhone XR + "iPhone12,1", // iPhone 11 + "iPhone12,3", // iPhone 11 Pro + "iPhone12,5", // iPhone 11 Pro Max + "iPhone13,1", // iPhone 12 mini + "iPhone13,2", // iPhone 12 + "iPhone13,3", // iPhone 12 Pro + "iPhone13,4", // iPhone 12 Pro Max + "iPhone14,4", // iPhone 13 mini + "iPhone14,5", // iPhone 13 + "iPhone14,2", // iPhone 13 Pro + "iPhone14,3", // iPhone 13 Pro Max + "iPhone14,7", // iPhone 14 + "iPhone14,8", // iPhone 14 Plus + "iPhone17,5" // iPhone 16e + ] + + /// 230pt devices with 11px top padding + static let width230TopPadding11: Set = [ + "iPhone15,2", // iPhone 14 Pro + "iPhone15,4", // iPhone 15 + "iPhone16,1", // iPhone 15 Pro + "iPhone17,3" // iPhone 16 + ] + + /// 250pt devices with 11px top padding + static let width250TopPadding11: Set = [ + "iPhone15,3", // iPhone 14 Pro Max + "iPhone15,5", // iPhone 15 Plus + "iPhone16,2", // iPhone 15 Pro Max + "iPhone17,4" // iPhone 16 Plus + ] +} + +// MARK: - Factory Methods + +private extension DynamicIslandInfo { + /// 230pt expanded width minus compact leading/trailing (52.33pt each) + static let width230: CGFloat = 230 - (52.33 * 2) // 125.34pt + + /// 250pt expanded width minus compact leading/trailing (62.33pt each) + static let width250: CGFloat = 250 - (62.33 * 2) // 125.34pt + + /// Standard Dynamic Island height + static let dynamicIslandHeight: CGFloat = 36.67 + + static func makeDynamicIsland230(for modelIdentifier: String) -> DynamicIslandInfo { + let topPadding: CGFloat = DeviceSets.width230TopPadding11.contains(modelIdentifier) ? 11 : 14 + return DynamicIslandInfo( + hasDynamicIsland: true, + hasNotch: false, + width: width230, + height: dynamicIslandHeight, + topPadding: topPadding + ) + } + + static func makeDynamicIsland250(for modelIdentifier: String) -> DynamicIslandInfo { + let topPadding: CGFloat + if modelIdentifier == "iPhone18,4" { // iPhone Air + topPadding = 20 + } else if DeviceSets.width250TopPadding11.contains(modelIdentifier) { + topPadding = 11 + } else { + topPadding = 14 + } + return DynamicIslandInfo( + hasDynamicIsland: true, + hasNotch: false, + width: width250, + height: dynamicIslandHeight, + topPadding: topPadding + ) + } + + static func makeNotch() -> DynamicIslandInfo { + DynamicIslandInfo( + hasDynamicIsland: false, + hasNotch: true, + width: 0, + height: 0, + topPadding: 0 + ) + } + + static func makeUnknown() -> DynamicIslandInfo { + DynamicIslandInfo( + hasDynamicIsland: false, + hasNotch: false, + width: 0, + height: 0, + topPadding: 0 + ) + } + + /// Memberwise initializer for factory methods + private init( + hasDynamicIsland: Bool, + hasNotch: Bool, + width: CGFloat, + height: CGFloat, + topPadding: CGFloat + ) { + self.hasDynamicIsland = hasDynamicIsland + self.hasNotch = hasNotch + self.width = width + self.height = height + self.topPadding = topPadding + } +} +#endif diff --git a/Sources/SuperwallKit/Misc/Extensions/UIDevice+ModelName.swift b/Sources/SuperwallKit/Misc/Extensions/UIDevice+ModelName.swift index 9fb643da3c..f027317dd8 100644 --- a/Sources/SuperwallKit/Misc/Extensions/UIDevice+ModelName.swift +++ b/Sources/SuperwallKit/Misc/Extensions/UIDevice+ModelName.swift @@ -9,6 +9,12 @@ import UIKit extension UIDevice { static var modelName: String { + // For simulators, get the simulated device model + if let simulatorModelIdentifier = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { + return simulatorModelIdentifier + } + + // For real devices, get the hardware identifier var systemInfo = utsname() uname(&systemInfo) let machineMirror = Mirror(reflecting: systemInfo.machine) diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 02d2f68bf4..e354b4ec9c 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -102,8 +102,8 @@ struct Paywall: Codable { /// Determines whether a free trial is available or not. var isFreeTrialAvailable = false - /// The source of the presentation request. Either 'implicit', 'getPaywall', 'register'. - var presentationSourceType: String? + /// The source of the presentation request. + var presentationSourceType: PresentationSourceType? /// The reason for closing the paywall. var closeReason: PaywallCloseReason = .none @@ -355,7 +355,7 @@ struct Paywall: Codable { paywalljsVersion: String, productVariables: [ProductVariable]? = [], isFreeTrialAvailable: Bool = false, - presentationSourceType: String? = nil, + presentationSourceType: PresentationSourceType? = nil, featureGating: FeatureGatingBehavior = .nonGated, onDeviceCache: OnDeviceCaching = .disabled, localNotifications: [LocalNotification] = [], @@ -423,7 +423,7 @@ struct Paywall: Codable { experiment: experiment, paywalljsVersion: paywalljsVersion, isFreeTrialAvailable: isFreeTrialAvailable, - presentationSourceType: presentationSourceType, + presentationSourceType: presentationSourceType?.rawValue, featureGatingBehavior: featureGating, closeReason: closeReason, localNotifications: localNotifications, diff --git a/Sources/SuperwallKit/Paywall/Presentation/Internal/Presentation Request/PresentationRequest.swift b/Sources/SuperwallKit/Paywall/Presentation/Internal/Presentation Request/PresentationRequest.swift index 5c9c649123..0a545473fc 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/Internal/Presentation Request/PresentationRequest.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/Internal/Presentation Request/PresentationRequest.swift @@ -104,6 +104,28 @@ enum PresentationRequestType: Equatable, CustomStringConvertible { } } +/// The source function type that initiated the presentation request. +enum PresentationSourceType: String { + /// Presented via an implicit trigger (e.g. app_launch). + case implicit + + /// Presented via ``Superwall/register(placement:params:handler:feature:)``. + case register + + /// Retrieved via ``Superwall/getPaywall(forPlacement:params:paywallOverrides:delegate:)``. + case getPaywall + + /// Whether Superwall is controlling the presentation (not the developer). + var isSuperwallPresenting: Bool { + switch self { + case .implicit, .register: + return true + case .getPaywall: + return false + } + } +} + /// Defines the information needed to request the presentation of a paywall. struct PresentationRequest { /// The type of trigger (implicit/explicit/fromIdentifier), and associated data. @@ -116,17 +138,17 @@ struct PresentationRequest { var paywallOverrides: PaywallOverrides? /// The source function type that initiated the presentation request. - var presentationSourceType: String? { + var presentationSourceType: PresentationSourceType? { switch presentationInfo { case .implicitTrigger: - return "implicit" + return .implicit case .explicitTrigger, .fromIdentifier: switch flags.type { case .getPaywall: - return "getPaywall" + return .getPaywall case .presentation: - return "register" + return .register case .paywallDeclineCheck, .handleImplicitTrigger, .getPresentationResult, diff --git a/Sources/SuperwallKit/Paywall/Request/PaywallRequest.swift b/Sources/SuperwallKit/Paywall/Request/PaywallRequest.swift index b34351a2a6..aea5d319ec 100644 --- a/Sources/SuperwallKit/Paywall/Request/PaywallRequest.swift +++ b/Sources/SuperwallKit/Paywall/Request/PaywallRequest.swift @@ -34,9 +34,7 @@ struct PaywallRequest { let isDebuggerLaunched: Bool /// The source function type that created the presentation request. - /// - /// e.g. implicit/register/getPaywall/nil - let presentationSourceType: String? + let presentationSourceType: PresentationSourceType? /// The number of times to retry the request. let retryCount: Int diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 2e4fd43ee1..04d5007845 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -76,6 +76,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { typealias Factory = TriggerFactory & RestoreAccessFactory & AppIdFactory + & FeatureFlagsFactory // MARK: - Private Properties /// Internal passthrough subject that emits ``PaywallState`` objects. These state objects feed back to @@ -166,6 +167,11 @@ public class PaywallViewController: UIViewController, LoadingDelegate { ) }() + #if !os(visionOS) + /// The Superwall logo view controller that appears under the notch for fullscreen paywalls. + private var logoViewController: SuperwallLogoViewController? + #endif + /// The push presentation animation transition delegate. private let transitionDelegate = PushTransitionDelegate() /// The popup presentation animation transition delegate. @@ -460,6 +466,13 @@ public class PaywallViewController: UIViewController, LoadingDelegate { showRefreshButtonAfterTimeout(false) hideLoadingView() + // Show logo for fullscreen presentations after initial load + #if !os(visionOS) + if oldValue == .loadingURL { + showLogoViewIfNeeded() + } + #endif + if !spinnerDidShow { UIView.animate( withDuration: 0.6, @@ -585,6 +598,40 @@ public class PaywallViewController: UIViewController, LoadingDelegate { loadingViewController.hide() } + #if !os(visionOS) + /// Shows the Superwall logo view on the paywall for fullscreen presentations. + /// Only shows when presentation animation is complete and content is ready. + private func showLogoViewIfNeeded() { + // Only show after presentation animation is complete + guard presentationDidFinishPrepare else { + return + } + + // Only show when content is ready + guard loadingState == .ready else { + return + } + + // Don't add again if already showing + guard logoViewController == nil else { + return + } + + logoViewController = SuperwallLogoViewController.showIfNeeded( + for: paywall, + presentationStyle: paywall.presentation.style, + in: view.window?.windowScene, + isEnabled: factory.makeFeatureFlags()?.enableSuperwallLogo == true + ) + } + + /// Hides the logo view during dismissal. + private func hideLogoView() { + SuperwallLogoViewController.hideAndRemove() + logoViewController = nil + } + #endif + // MARK: - Timeout private func showRefreshButtonAfterTimeout(_ isVisible: Bool) { @@ -1227,6 +1274,11 @@ extension PaywallViewController { } GameControllerManager.shared.setDelegate(self) presentationDidFinishPrepare = true + + // Show logo if content is already ready + #if !os(visionOS) + showLogoViewIfNeeded() + #endif } override public func viewWillDisappear(_ animated: Bool) { @@ -1238,9 +1290,13 @@ extension PaywallViewController { return } + #if !os(visionOS) + hideLogoView() + #endif willDismiss() } + override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) guard isPresented else { diff --git a/Sources/SuperwallKit/Paywall/View Controller/SuperwallLogoViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/SuperwallLogoViewController.swift new file mode 100644 index 0000000000..eaf1cb68d9 --- /dev/null +++ b/Sources/SuperwallKit/Paywall/View Controller/SuperwallLogoViewController.swift @@ -0,0 +1,273 @@ +// +// SuperwallLogoViewController.swift +// SuperwallKit +// +// Created by Yusuf Tör on 24/11/2024. +// + +#if !os(visionOS) +import UIKit + +/// Displays the Superwall logo behind the notch/Dynamic Island using a separate +/// portrait-locked window. This approach ensures the logo never rotates with the app +/// and naturally stays aligned with the physical notch during device rotation. +final class SuperwallLogoViewController: UIViewController { + // MARK: - Static Properties + + /// The shared instance managing the logo window. + private static var shared: SuperwallLogoViewController? + + /// The dedicated window for the logo (not a subclass to avoid detection). + private static var logoWindow: UIWindow? + + // MARK: - Static Methods + + /// Shows the logo if conditions are met. + /// - Parameters: + /// - paywall: The paywall to check presentation conditions. + /// - presentationStyle: The paywall presentation style. + /// - windowScene: The window scene to attach to. + /// - isEnabled: Whether the Superwall logo feature flag is enabled. + @discardableResult + static func showIfNeeded( + for paywall: Paywall, + presentationStyle: PaywallPresentationStyle, + in windowScene: UIWindowScene?, + isEnabled: Bool + ) -> SuperwallLogoViewController? { + #if os(visionOS) + return nil + #else + // Only show if feature flag is enabled + guard isEnabled else { + return nil + } + + // Don't show during UI tests + if ProcessInfo.processInfo.arguments.contains("SUPERWALL_UI_TESTS") { + return nil + } + + // Only show for fullscreen paywalls + switch presentationStyle { + case .fullscreen, + .fullscreenNoAnimation: + break + default: + return nil + } + + // Only show if device has a notch or Dynamic Island + guard DynamicIslandInfo.current.hasDynamicIslandOrNotch else { + return nil + } + + // Only show when Superwall is presenting (not when dev uses getPaywall) + guard paywall.presentationSourceType?.isSuperwallPresenting == true else { + return nil + } + + guard let windowScene = windowScene else { + return nil + } + + // Create the logo view controller + let logoVC = SuperwallLogoViewController() + shared = logoVC + + // Create the dedicated window + let window = UIWindow(windowScene: windowScene) + window.frame = windowScene.coordinateSpace.bounds + window.backgroundColor = .clear + // Just above normal window level, not excessively high + window.windowLevel = .normal + 1 + window.rootViewController = logoVC + window.isUserInteractionEnabled = false + // Don't use makeKeyAndVisible() - just show it + window.isHidden = false + logoWindow = window + + return logoVC + #endif + } + + /// Hides and removes the logo window. + static func hideAndRemove() { + guard let logoVC = shared else { return } + + UIView.animate( + withDuration: 0.2, + animations: { + logoVC.pillView.alpha = 0 + }, + completion: { _ in + logoWindow?.isHidden = true + logoWindow = nil + shared = nil + } + ) + } + + // MARK: - Orientation Locking + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + .portrait + } + + override var shouldAutorotate: Bool { + false + } + + // MARK: - Properties + + /// The pill-shaped container that holds the logo + private let pillView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(hexString: "#13151A") + view.layer.cornerCurve = .continuous + view.alpha = 0 // Start hidden + return view + }() + + private let logoImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.image = UIImage( + named: "SuperwallKit_superwall_logo", + in: Bundle.module, + compatibleWith: nil + ) + return imageView + }() + + private let dynamicIslandInfo = DynamicIslandInfo.current + + /// Padding around the logo inside the pill + private let logoPadding: CGFloat = 12 + + /// Work item for delayed show - can be cancelled if app backgrounds again + private var showWorkItem: DispatchWorkItem? + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.isUserInteractionEnabled = false + view.backgroundColor = .clear + setupPillView() + setupObservers() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // Delay initial show slightly to avoid flash during presentation + showWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.showPill() + } + showWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Setup + + private func setupPillView() { + view.addSubview(pillView) + pillView.addSubview(logoImageView) + + if dynamicIslandInfo.hasDynamicIsland { + setupDynamicIslandLayout() + } else if dynamicIslandInfo.hasNotch { + setupNotchLayout() + } + } + + private func setupDynamicIslandLayout() { + let pillWidth = dynamicIslandInfo.width + let pillHeight = dynamicIslandInfo.height + let topPadding = dynamicIslandInfo.topPadding + let screenWidth = UIScreen.main.bounds.width + + // Position pill centered at top + pillView.frame = CGRect( + x: (screenWidth - pillWidth) / 2, + y: topPadding, + width: pillWidth, + height: pillHeight + ) + + // Corner radius = half height for capsule shape + pillView.layer.cornerRadius = pillHeight / 2 + + // Logo with padding + logoImageView.frame = pillView.bounds.insetBy(dx: logoPadding, dy: logoPadding) + } + + private func setupNotchLayout() { + let pillWidth: CGFloat = 125 + let pillHeight: CGFloat = 30 + let screenWidth = UIScreen.main.bounds.width + + // Position pill centered at top + pillView.frame = CGRect( + x: (screenWidth - pillWidth) / 2, + y: 0, + width: pillWidth, + height: pillHeight + ) + + // Only round the bottom corners to match notch shape + pillView.layer.cornerRadius = 12 + pillView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + + // Logo with padding (less padding since it's smaller) + let notchLogoPadding: CGFloat = 8 + logoImageView.frame = pillView.bounds.insetBy(dx: notchLogoPadding, dy: notchLogoPadding) + } + + // MARK: - Observers + + private func setupObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + + @objc private func appWillResignActive() { + // Cancel any pending show operation and hide immediately + showWorkItem?.cancel() + showWorkItem = nil + pillView.alpha = 0 + } + + @objc private func appDidBecomeActive() { + // Cancel any existing work item + showWorkItem?.cancel() + + // Delay showing until app transition animation completes + let workItem = DispatchWorkItem { [weak self] in + self?.showPill() + } + showWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem) + } + + private func showPill() { + guard UIApplication.shared.applicationState == .active else { return } + pillView.alpha = 1 + } +} +#endif diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 1bf16261a2..1901c0fa8a 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -162,6 +162,7 @@ 591DCE67E64C63AACFFB604B /* IdentityLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3F04AC933701EE33F5F325 /* IdentityLogic.swift */; }; 59685CE55D34FA6A96A8F890 /* AssignmentLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3501D12845840D6CBA1F0081 /* AssignmentLogicTests.swift */; }; 5A6D06700C4E4E2C6C9BC1B2 /* ShimmerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D123D95036ED6CE3B097BBF0 /* ShimmerView.swift */; }; + 5AC470740936AEB32426374C /* DynamicIslandInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8941591BB2FAA730E0A2F5DC /* DynamicIslandInfo.swift */; }; 5C504112376B6E0798CA20CE /* Variables.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B75209DF76859131941CA0F /* Variables.swift */; }; 5D0DAFA97F75920FFB99DF6B /* PriceFormatterProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B5BF873B8D190097E8CFB5 /* PriceFormatterProvider.swift */; }; 5DDABDA8ECE4A96BDFCEF4B0 /* ArchivalManifestDownloaded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0895B5C0A26AA7FD3C0178 /* ArchivalManifestDownloaded.swift */; }; @@ -305,9 +306,11 @@ A9F9A35AEC72D17C7C15DAD4 /* PaywallArchiveManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB580546C647ED707C43FEC /* PaywallArchiveManager.swift */; }; AACC7BEE37DDDD7068A1E48C /* TransactionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646E6799FE4934BF76A06F34 /* TransactionManager.swift */; }; ABC17AE96AD396607E3CAB17 /* CoreDataStackMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E828EBAB18CCC0B236EF71D /* CoreDataStackMock.swift */; }; + ABD6EE6870FD0722ED9F39FC /* DynamicIslandInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B883AA7A00F577500BF3555 /* DynamicIslandInfoTests.swift */; }; AC0AF760E7EA2FFF5621955D /* PurchaseControllerObjcAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB8F4169717A629E15CEB9C7 /* PurchaseControllerObjcAdapter.swift */; }; AC7D527612F631AAADC7D225 /* FileManagerMigratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB3F2FC9FCCD4B912E61A1F /* FileManagerMigratorTests.swift */; }; AD26500C2B27829305F76859 /* EndpointKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15E6FBB3D0826827A04F87AE /* EndpointKind.swift */; }; + AD38C3B5A3D5C6ED6649A33B /* SuperwallLogoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F7080500BAA02B0E7B183A /* SuperwallLogoViewController.swift */; }; AD5EBB6DBA919E3CBC5B85B7 /* SessionEventsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D6FA6547CB5B9520B0B64 /* SessionEventsRequest.swift */; }; AE0555AA0B433427E5D17309 /* InternalGetPresentationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F57F454704875FFFC5CE1827 /* InternalGetPresentationResult.swift */; }; AE1D15070BC159212967CAD4 /* ConfirmHoldoutAssignmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018F5856F39FC33AFE9740D4 /* ConfirmHoldoutAssignmentTests.swift */; }; @@ -611,6 +614,7 @@ 35F538F901BAF97DDCA86F84 /* DeviceHelperMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelperMock.swift; sourceTree = ""; }; 368EF475049935105AF8154C /* Entitlement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Entitlement.swift; sourceTree = ""; }; 37B17A8801A2A9454E66D892 /* ASN1Decoder+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ASN1Decoder+Utils.swift"; sourceTree = ""; }; + 38F7080500BAA02B0E7B183A /* SuperwallLogoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallLogoViewController.swift; sourceTree = ""; }; 39B81D88316F06C0C2757F10 /* MockReceiptData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockReceiptData.swift; sourceTree = ""; }; 3A6728F289EC434B1C856BD2 /* V2Migrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2Migrator.swift; sourceTree = ""; }; 3A7BF742EBC950966662849F /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; @@ -633,6 +637,7 @@ 49E522F5BCABB3A95B97549E /* PurchaseError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseError.swift; sourceTree = ""; }; 4B001199B53C314F25788603 /* PassableValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassableValue.swift; sourceTree = ""; }; 4B1DC32C4ABB60B8323E5D28 /* AsyncSequence+Extract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncSequence+Extract.swift"; sourceTree = ""; }; + 4B883AA7A00F577500BF3555 /* DynamicIslandInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicIslandInfoTests.swift; sourceTree = ""; }; 4BE6E7AC2E38E0EB43C35AF5 /* V1Migrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V1Migrator.swift; sourceTree = ""; }; 4BFFA527207A52EB7C70CAD4 /* PopupTransitionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupTransitionTests.swift; sourceTree = ""; }; 4C2AC9214EA750436EF1FE11 /* SWWebViewLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLogicTests.swift; sourceTree = ""; }; @@ -765,6 +770,7 @@ 887834329A06971D86D5282F /* NotificationProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProtocols.swift; sourceTree = ""; }; 88EBF6FC3090E004EE1377B4 /* PaywallViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerDelegate.swift; sourceTree = ""; }; 891BDDF19DEC970709DDF4BB /* V3Migrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V3Migrator.swift; sourceTree = ""; }; + 8941591BB2FAA730E0A2F5DC /* DynamicIslandInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicIslandInfo.swift; sourceTree = ""; }; 8A7140A8B0B006F2080D8915 /* PaywallLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallLogicTests.swift; sourceTree = ""; }; 8B66B5A624F8A2E225EACA69 /* SurveyPresentationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyPresentationResult.swift; sourceTree = ""; }; 8BAEECE2DBFEB8817E6C36DA /* PaywallViewControllerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerCache.swift; sourceTree = ""; }; @@ -1443,6 +1449,7 @@ children = ( AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */, 7569A0893012A88442B7ED36 /* PaywallViewController.swift */, + 38F7080500BAA02B0E7B183A /* SuperwallLogoViewController.swift */, 6F9276EC956CE4A6C09949CE /* Delegates */, 1DE8CF6D5ACEFA6B6349B142 /* Loading */, 43AE802CA13E36803E2FF13E /* Popup Transition */, @@ -1605,6 +1612,7 @@ 4D7656D6A565958F58A644AF /* Misc */ = { isa = PBXGroup; children = ( + 4B883AA7A00F577500BF3555 /* DynamicIslandInfoTests.swift */, F2F75130AF54DFC4C7FA839A /* Extensions */, ); path = Misc; @@ -2607,6 +2615,7 @@ 643A346628DA026FEA092C27 /* ButtonFactory.swift */, 42956918D4FFA5FBA79F3AA5 /* Constants.swift */, 7ABC4A0048583B47040C498B /* DispatchQueueBacked.swift */, + 8941591BB2FAA730E0A2F5DC /* DynamicIslandInfo.swift */, E09C238ADC0B019047FAB1DF /* JSONToDict.swift */, 2E2027BFC214905CBE589AF2 /* KeypathWritable.swift */, 19F010DC597017F5BEAEDE86 /* SwiftyJSON.swift */, @@ -2884,6 +2893,7 @@ 01BE837B492223B76A95CB5D /* DeepLinkRouterTests.swift in Sources */, 0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */, 9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */, + ABD6EE6870FD0722ED9F39FC /* DynamicIslandInfoTests.swift in Sources */, A03AC977AD8110290DABECBD /* EntitlementPriorityTests.swift in Sources */, 6BA614F410A95F36CBA42F93 /* EntitlementProcessorTests.swift in Sources */, 1225B991B40D16B7FB4EF1A5 /* EvaluateRulesOperatorTests.swift in Sources */, @@ -3035,6 +3045,7 @@ 507E017DBEC2663F1B4727E0 /* Dictionary+Merging.swift in Sources */, DB6FF170AE90FF8623A31E14 /* DispatchQueueBacked.swift in Sources */, 61AE58F17230AE1578B9FB19 /* Documentation.docc in Sources */, + 5AC470740936AEB32426374C /* DynamicIslandInfo.swift in Sources */, 9EAE577E60052F5E1C7B9657 /* EmptyResponse.swift in Sources */, CB1E11FB74879A29DD1C9EB1 /* Encodable+Dictionary.swift in Sources */, 11477D1EB60D1FDA32F5099A /* Endpoint.swift in Sources */, @@ -3266,6 +3277,7 @@ CE1954BE6150CA19BE5F3476 /* SuperwallEventObjc.swift in Sources */, A78DC9BD71DD85831025D620 /* SuperwallGraveyard.swift in Sources */, 941F2296F5250A15DE6B5B70 /* SuperwallKit_Model.xcdatamodeld in Sources */, + AD38C3B5A3D5C6ED6649A33B /* SuperwallLogoViewController.swift in Sources */, 0A1366F15DD3C1761C095DF5 /* SuperwallOptions.swift in Sources */, F365F06BBA58055920FC751B /* SuperwallPlacementInfo.swift in Sources */, 776B122691BB67A703BB0DDD /* Survey.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Misc/DynamicIslandInfoTests.swift b/Tests/SuperwallKitTests/Misc/DynamicIslandInfoTests.swift new file mode 100644 index 0000000000..e44a141137 --- /dev/null +++ b/Tests/SuperwallKitTests/Misc/DynamicIslandInfoTests.swift @@ -0,0 +1,365 @@ +// +// DynamicIslandInfoTests.swift +// SuperwallKit +// +// Created by Yusuf Tör on 01/12/2024. +// +// swiftlint:disable type_body_length file_length + +#if !os(visionOS) +@testable import SuperwallKit +import XCTest + +final class DynamicIslandInfoTests: XCTestCase { + // MARK: - Constants + + /// Expected width for 230pt Dynamic Island devices + private let width230: CGFloat = 230 - (52.33 * 2) // 125.34pt + + /// Expected width for 250pt Dynamic Island devices + private let width250: CGFloat = 250 - (62.33 * 2) // 125.34pt + + /// Expected height for all Dynamic Island devices + private let dynamicIslandHeight: CGFloat = 36.67 + + // MARK: - 230pt Dynamic Island Devices + + func test_iPhone14Pro() { + let info = DynamicIslandInfo(for: "iPhone15,2") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width230) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 11) + } + + func test_iPhone15() { + let info = DynamicIslandInfo(for: "iPhone15,4") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width230) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 11) + } + + func test_iPhone15Pro() { + let info = DynamicIslandInfo(for: "iPhone16,1") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width230) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 11) + } + + func test_iPhone16Pro() { + let info = DynamicIslandInfo(for: "iPhone17,1") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width230) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 14) + } + + func test_iPhone16() { + let info = DynamicIslandInfo(for: "iPhone17,3") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width230) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 11) + } + + func test_iPhone17Pro() { + let info = DynamicIslandInfo(for: "iPhone18,1") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width230) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 14) + } + + func test_iPhone17() { + let info = DynamicIslandInfo(for: "iPhone18,3") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width230) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 14) + } + + // MARK: - 250pt Dynamic Island Devices + + func test_iPhone14ProMax() { + let info = DynamicIslandInfo(for: "iPhone15,3") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width250) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 11) + } + + func test_iPhone15Plus() { + let info = DynamicIslandInfo(for: "iPhone15,5") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width250) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 11) + } + + func test_iPhone15ProMax() { + let info = DynamicIslandInfo(for: "iPhone16,2") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width250) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 11) + } + + func test_iPhone16ProMax() { + let info = DynamicIslandInfo(for: "iPhone17,2") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width250) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 14) + } + + func test_iPhone16Plus() { + let info = DynamicIslandInfo(for: "iPhone17,4") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width250) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 11) + } + + func test_iPhone17ProMax() { + let info = DynamicIslandInfo(for: "iPhone18,2") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width250) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 14) + } + + func test_iPhoneAir() { + let info = DynamicIslandInfo(for: "iPhone18,4") + XCTAssertTrue(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, width250) + XCTAssertEqual(info.height, dynamicIslandHeight) + XCTAssertEqual(info.topPadding, 20) + } + + // MARK: - Notch Devices + + func test_iPhoneX() { + let info = DynamicIslandInfo(for: "iPhone10,3") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhoneXS() { + let info = DynamicIslandInfo(for: "iPhone11,2") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhoneXSMax() { + let info = DynamicIslandInfo(for: "iPhone11,4") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhoneXR() { + let info = DynamicIslandInfo(for: "iPhone11,8") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone11() { + let info = DynamicIslandInfo(for: "iPhone12,1") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone11Pro() { + let info = DynamicIslandInfo(for: "iPhone12,3") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone11ProMax() { + let info = DynamicIslandInfo(for: "iPhone12,5") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone12Mini() { + let info = DynamicIslandInfo(for: "iPhone13,1") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone12() { + let info = DynamicIslandInfo(for: "iPhone13,2") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone12Pro() { + let info = DynamicIslandInfo(for: "iPhone13,3") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone12ProMax() { + let info = DynamicIslandInfo(for: "iPhone13,4") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone13Mini() { + let info = DynamicIslandInfo(for: "iPhone14,4") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone13() { + let info = DynamicIslandInfo(for: "iPhone14,5") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone13Pro() { + let info = DynamicIslandInfo(for: "iPhone14,2") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone13ProMax() { + let info = DynamicIslandInfo(for: "iPhone14,3") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone14() { + let info = DynamicIslandInfo(for: "iPhone14,7") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone14Plus() { + let info = DynamicIslandInfo(for: "iPhone14,8") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_iPhone16e() { + let info = DynamicIslandInfo(for: "iPhone17,5") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertTrue(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + // MARK: - Unknown Devices + + func test_unknownDevice_iPad() { + let info = DynamicIslandInfo(for: "iPad13,1") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_unknownDevice_simulator() { + let info = DynamicIslandInfo(for: "x86_64") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + func test_unknownDevice_futureDevice() { + let info = DynamicIslandInfo(for: "iPhone99,1") + XCTAssertFalse(info.hasDynamicIsland) + XCTAssertFalse(info.hasNotch) + XCTAssertEqual(info.width, 0) + XCTAssertEqual(info.height, 0) + XCTAssertEqual(info.topPadding, 0) + } + + // MARK: - Computed Properties + + func test_hasDynamicIslandOrNotch_dynamicIsland() { + let info = DynamicIslandInfo(for: "iPhone15,2") + XCTAssertTrue(info.hasDynamicIslandOrNotch) + } + + func test_hasDynamicIslandOrNotch_notch() { + let info = DynamicIslandInfo(for: "iPhone14,7") + XCTAssertTrue(info.hasDynamicIslandOrNotch) + } + + func test_hasDynamicIslandOrNotch_neither() { + let info = DynamicIslandInfo(for: "iPad13,1") + XCTAssertFalse(info.hasDynamicIslandOrNotch) + } +} +#endif