diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 0568ae5d6c..fffdbcdf83 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -1012,6 +1012,11 @@ 7783606D2CCA7E14000785B8 /* PaywallStickyFooterComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7783606C2CCA7E14000785B8 /* PaywallStickyFooterComponent.swift */; }; 778360792CCA85E4000785B8 /* StickyFooterComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 778360782CCA85E4000785B8 /* StickyFooterComponentViewModel.swift */; }; 7783607B2CCA88E4000785B8 /* StickyFooterComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7783607A2CCA88E4000785B8 /* StickyFooterComponentView.swift */; }; + E1C0A001BEEF0001CCDD0001 /* PaywallHeaderComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C0A006BEEF0001CCDD0001 /* PaywallHeaderComponent.swift */; }; + E1C0A002BEEF0001CCDD0001 /* HeaderComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C0A007BEEF0001CCDD0001 /* HeaderComponentViewModel.swift */; }; + E1C0A003BEEF0001CCDD0001 /* HeaderComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C0A008BEEF0001CCDD0001 /* HeaderComponentView.swift */; }; + E1C0A004BEEF0001CCDD0001 /* HeaderComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C0A009BEEF0001CCDD0001 /* HeaderComponentTests.swift */; }; + E1C0A005BEEF0001CCDD0001 /* PaywallComponentsConfigHeaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C0A00ABEEF0001CCDD0001 /* PaywallComponentsConfigHeaderTests.swift */; }; 77AABEA32F02B3340018C1D3 /* CustomerCenterViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AABEA22F02B3340018C1D3 /* CustomerCenterViewControllerDelegate.swift */; }; 77AABEB82F0C23450018C1D3 /* PurchasesStoreMessagesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AABEB72F0C23450018C1D3 /* PurchasesStoreMessagesTests.swift */; }; 77BA1AB12CCBAB80009BF0EA /* RootViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77BA1AB02CCBAB80009BF0EA /* RootViewModel.swift */; }; @@ -1353,6 +1358,7 @@ FA6ABC7C2A81673F00353AF7 /* SystemFontRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA6ABC7B2A81673F00353AF8 /* SystemFontRegistryTests.swift */; }; FACD00022F61A1230073D2DE /* TextComponentLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACD00012F61A1230073D2DE /* TextComponentLocalizationTests.swift */; }; FACD00042F61A1230073D2DE /* ViewModelFactoryBadgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACD00032F61A1230073D2DE /* ViewModelFactoryBadgeTests.swift */; }; + FB5A72022F65F5B9005C64A1 /* ViewModelFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5A72012F65F5B9005C64A1 /* ViewModelFactoryTests.swift */; }; FACD00082F61A1230073D2DE /* PackageComponentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACD00072F61A1230073D2DE /* PackageComponentViewTests.swift */; }; FACD000A2F61A1230073D2DE /* PackageValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACD00092F61A1230073D2DE /* PackageValidatorTests.swift */; }; FD05A71A2EE2430E00FE671F /* StoreKit2PromotionalOfferPurchaseOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD05A7192EE2430E00FE671F /* StoreKit2PromotionalOfferPurchaseOptions.swift */; }; @@ -2551,6 +2557,11 @@ 7783606C2CCA7E14000785B8 /* PaywallStickyFooterComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallStickyFooterComponent.swift; sourceTree = ""; }; 778360782CCA85E4000785B8 /* StickyFooterComponentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyFooterComponentViewModel.swift; sourceTree = ""; }; 7783607A2CCA88E4000785B8 /* StickyFooterComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyFooterComponentView.swift; sourceTree = ""; }; + E1C0A006BEEF0001CCDD0001 /* PaywallHeaderComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallHeaderComponent.swift; sourceTree = ""; }; + E1C0A007BEEF0001CCDD0001 /* HeaderComponentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderComponentViewModel.swift; sourceTree = ""; }; + E1C0A008BEEF0001CCDD0001 /* HeaderComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderComponentView.swift; sourceTree = ""; }; + E1C0A009BEEF0001CCDD0001 /* HeaderComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderComponentTests.swift; sourceTree = ""; }; + E1C0A00ABEEF0001CCDD0001 /* PaywallComponentsConfigHeaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallComponentsConfigHeaderTests.swift; sourceTree = ""; }; 77AABEA22F02B3340018C1D3 /* CustomerCenterViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterViewControllerDelegate.swift; sourceTree = ""; }; 77AABEB72F0C23450018C1D3 /* PurchasesStoreMessagesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesStoreMessagesTests.swift; sourceTree = ""; }; 77BA1AB02CCBAB80009BF0EA /* RootViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModel.swift; sourceTree = ""; }; @@ -2884,6 +2895,7 @@ FA6ABC7B2A81673F00353AF8 /* SystemFontRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemFontRegistryTests.swift; sourceTree = ""; }; FACD00012F61A1230073D2DE /* TextComponentLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextComponentLocalizationTests.swift; sourceTree = ""; }; FACD00032F61A1230073D2DE /* ViewModelFactoryBadgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelFactoryBadgeTests.swift; sourceTree = ""; }; + FB5A72012F65F5B9005C64A1 /* ViewModelFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelFactoryTests.swift; sourceTree = ""; }; FACD00072F61A1230073D2DE /* PackageComponentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageComponentViewTests.swift; sourceTree = ""; }; FACD00092F61A1230073D2DE /* PackageValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageValidatorTests.swift; sourceTree = ""; }; FD05A7192EE2430E00FE671F /* StoreKit2PromotionalOfferPurchaseOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2PromotionalOfferPurchaseOptions.swift; sourceTree = ""; }; @@ -3075,7 +3087,8 @@ children = ( FACD00012F61A1230073D2DE /* TextComponentLocalizationTests.swift */, FACD00032F61A1230073D2DE /* ViewModelFactoryBadgeTests.swift */, - FACD00072F61A1230073D2DE /* PackageComponentViewTests.swift */, + FB5A72012F65F5B9005C64A1 /* ViewModelFactoryTests.swift */, + FACD00072F61A1230073D2DE /* PackageComponentViewTests.swift */, FACD00092F61A1230073D2DE /* PackageValidatorTests.swift */, FD4253DD2F1DBD7E0073D2DE /* FileImageLoaderTests.swift */, 57BFCD0F2EEB1234009E5844 /* TabsPackageSelectionResolverTests.swift */, @@ -3281,6 +3294,7 @@ isa = PBXGroup; children = ( 0387D4AD2EC58AA5008E4A6B /* Countdown */, + E1C0A00BBEEF0001CCDD0001 /* Header */, 164681CD2E6B577600854AA5 /* Video */, 2C7457472CEA66AB004ACE52 /* ComponentsView.swift */, 7707A94A2CAD936A006E0313 /* Button */, @@ -3353,6 +3367,7 @@ 16DA8EC82E4EE24100283940 /* JsonLoader.swift */, 16DA8EC72E4EE22A00283940 /* Helpers */, 03F446222D2FE0B90046129A /* Properties */, + E1C0A009BEEF0001CCDD0001 /* HeaderComponentTests.swift */, 03F446202D2F73210046129A /* StackComponentTests.swift */, 03C7305A2D35985500297FEC /* TextComponentTests.swift */, 03A98CEE2D1EE040009BCA61 /* FallbackComponentTests.swift */, @@ -4950,6 +4965,7 @@ 574A2F3E282D75E300150D40 /* OfferingsDecodingTests.swift */, 03A98D312D2441B2009BCA61 /* PaywallDataDecodingTests.swift */, 75425E0E2D5DFA9F00E25F60 /* PaywallComponentsDataTests.swift */, + E1C0A00ABEEF0001CCDD0001 /* PaywallComponentsConfigHeaderTests.swift */, 03A98D352D244321009BCA61 /* UIConfigDecodingTests.swift */, 574A2F4E282D7B9E00150D40 /* PostOfferDecodingTests.swift */, 5766C61F282DA3D50067D886 /* GetIntroEligibilityDecodingTests.swift */, @@ -5326,6 +5342,15 @@ path = Button; sourceTree = ""; }; + E1C0A00BBEEF0001CCDD0001 /* Header */ = { + isa = PBXGroup; + children = ( + E1C0A007BEEF0001CCDD0001 /* HeaderComponentViewModel.swift */, + E1C0A008BEEF0001CCDD0001 /* HeaderComponentView.swift */, + ); + path = Header; + sourceTree = ""; + }; 778360742CCA84FA000785B8 /* Root */ = { isa = PBXGroup; children = ( @@ -5732,6 +5757,7 @@ 03C730F22D35F13F00297FEC /* PaywallV2CacheWarming.swift */, 2CC791612CC0493600FBE120 /* Common */, 7707A94B2CAD93AC006E0313 /* PaywallButtonComponent.swift */, + E1C0A006BEEF0001CCDD0001 /* PaywallHeaderComponent.swift */, 88AD01032C740CF400AA1F2B /* PaywallImageComponent.swift */, 03C72FBD2D34949600297FEC /* PaywallIconComponent.swift */, 88E679462C7503C1007E69D5 /* PaywallStackComponent.swift */, @@ -7238,6 +7264,7 @@ B35042C626CDD3B100905B95 /* PurchasesDelegate.swift in Sources */, 0313FD41268A506400168386 /* DateProvider.swift in Sources */, 2C8EC6E12CCD7BA700D6CCF8 /* ComponentOverrides.swift in Sources */, + E1C0A001BEEF0001CCDD0001 /* PaywallHeaderComponent.swift in Sources */, 7783606D2CCA7E14000785B8 /* PaywallStickyFooterComponent.swift in Sources */, 57FDAABA284937A0009A48F1 /* SandboxEnvironmentDetector.swift in Sources */, 353DE0072CCA506100A8F632 /* EventsRequest+Paywall.swift in Sources */, @@ -7530,7 +7557,9 @@ 351B51C126D450E800BD2BD7 /* OfferingsManagerTests.swift in Sources */, 5796A39927D6C1E000653165 /* BackendPostSubscriberAttributesTests.swift in Sources */, 35D8330A262FBA9A00E60AC5 /* MockUserDefaults.swift in Sources */, + E1C0A004BEEF0001CCDD0001 /* HeaderComponentTests.swift in Sources */, 75425E0F2D5DFA9F00E25F60 /* PaywallComponentsDataTests.swift in Sources */, + E1C0A005BEEF0001CCDD0001 /* PaywallComponentsConfigHeaderTests.swift in Sources */, 2DDF41DF24F6F527005BC22D /* MockProductsManager.swift in Sources */, FD18BF492DF0D9C100140FD6 /* VirtualCurrencyManagerTests.swift in Sources */, 351B514F26D44ACE00BD2BD7 /* PurchasesSubscriberAttributesTests.swift in Sources */, @@ -7832,6 +7861,8 @@ 2C8EC6DD2CCC7C5B00D6CCF8 /* PackageValidator.swift in Sources */, 4DEB9BC52D08CA1700D33E36 /* BadgeModifier.swift in Sources */, 358DECA32D9BEF85003B1CC0 /* EmergeRenderingMode.swift in Sources */, + E1C0A002BEEF0001CCDD0001 /* HeaderComponentViewModel.swift in Sources */, + E1C0A003BEEF0001CCDD0001 /* HeaderComponentView.swift in Sources */, 778360792CCA85E4000785B8 /* StickyFooterComponentViewModel.swift in Sources */, 2C7457482CEA66AB004ACE52 /* ComponentsView.swift in Sources */, 03C06FCA2D479C7400600693 /* CarouselComponentView.swift in Sources */, @@ -8113,7 +8144,8 @@ 030890842D2B77E70069677B /* VariableHandlerV2Tests.swift in Sources */, FACD00022F61A1230073D2DE /* TextComponentLocalizationTests.swift in Sources */, FACD00042F61A1230073D2DE /* ViewModelFactoryBadgeTests.swift in Sources */, - FACD00082F61A1230073D2DE /* PackageComponentViewTests.swift in Sources */, + FB5A72022F65F5B9005C64A1 /* ViewModelFactoryTests.swift in Sources */, + FACD00082F61A1230073D2DE /* PackageComponentViewTests.swift in Sources */, FACD000A2F61A1230073D2DE /* PackageValidatorTests.swift in Sources */, DBD2432F2EBDF4BC0066AC6F /* PromotionalOfferViewTests.swift in Sources */, 577782B52D9182A200F97EB4 /* CustomerCenterActionWrapperTests.swift in Sources */, diff --git a/RevenueCatUI/Templates/V2/Components/Header/HeaderComponentView.swift b/RevenueCatUI/Templates/V2/Components/Header/HeaderComponentView.swift new file mode 100644 index 0000000000..624626e6c9 --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/Header/HeaderComponentView.swift @@ -0,0 +1,50 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HeaderComponentView.swift +// +// Created by Facundo Menzella on 02/04/2026. + +import SwiftUI + +#if !os(tvOS) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct HeaderComponentView: View { + + @Environment(\.safeAreaInsets) + private var safeAreaInsets + + private let viewModel: HeaderComponentViewModel + private let onDismiss: () -> Void + + init( + viewModel: HeaderComponentViewModel, + onDismiss: @escaping () -> Void + ) { + self.viewModel = viewModel + self.onDismiss = onDismiss + } + + var body: some View { + StackComponentView( + viewModel: self.viewModel.stackViewModel, + onDismiss: self.onDismiss, + additionalPadding: .init( + top: self.viewModel.firstItemIgnoresSafeArea ? 0 : self.safeAreaInsets.top, + leading: 0, + bottom: 0, + trailing: 0 + ) + ) + } + +} + +#endif diff --git a/RevenueCatUI/Templates/V2/Components/Header/HeaderComponentViewModel.swift b/RevenueCatUI/Templates/V2/Components/Header/HeaderComponentViewModel.swift new file mode 100644 index 0000000000..9268c32ed6 --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/Header/HeaderComponentViewModel.swift @@ -0,0 +1,38 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// HeaderComponentViewModel.swift +// +// Created by Facundo Menzella on 02/04/2026. + +@_spi(Internal) import RevenueCat +import SwiftUI + +#if !os(tvOS) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +class HeaderComponentViewModel { + + let component: PaywallComponent.HeaderComponent + let stackViewModel: StackComponentViewModel + let firstItemIgnoresSafeArea: Bool + + init( + component: PaywallComponent.HeaderComponent, + stackViewModel: StackComponentViewModel, + firstItemIgnoresSafeArea: Bool + ) { + self.component = component + self.stackViewModel = stackViewModel + self.firstItemIgnoresSafeArea = firstItemIgnoresSafeArea + } + +} + +#endif diff --git a/RevenueCatUI/Templates/V2/Components/Root/RootView.swift b/RevenueCatUI/Templates/V2/Components/Root/RootView.swift index 5575ac4873..6c74b3d127 100644 --- a/RevenueCatUI/Templates/V2/Components/Root/RootView.swift +++ b/RevenueCatUI/Templates/V2/Components/Root/RootView.swift @@ -44,11 +44,27 @@ struct RootView: View { var body: some View { VStack(alignment: .center, spacing: 0) { - StackComponentView( - viewModel: viewModel.stackViewModel, - isScrollableByDefault: true, - onDismiss: onDismiss - ) + ZStack(alignment: .top) { + StackComponentView( + viewModel: viewModel.stackViewModel, + isScrollableByDefault: true, + onDismiss: onDismiss, + additionalPadding: EdgeInsets( + top: 0, + leading: 0, + bottom: viewModel.stickyFooterViewModel == nil ? safeAreaInsets.bottom : 0, + trailing: 0 + ) + ) + + if let headerViewModel = viewModel.headerViewModel { + HeaderComponentView( + viewModel: headerViewModel, + onDismiss: onDismiss + ) + .fixedSize(horizontal: false, vertical: true) + } + } if let stickyFooterViewModel = viewModel.stickyFooterViewModel { StackComponentView( diff --git a/RevenueCatUI/Templates/V2/Components/Root/RootViewModel.swift b/RevenueCatUI/Templates/V2/Components/Root/RootViewModel.swift index 4179c8b2de..f2babb61b4 100644 --- a/RevenueCatUI/Templates/V2/Components/Root/RootViewModel.swift +++ b/RevenueCatUI/Templates/V2/Components/Root/RootViewModel.swift @@ -25,17 +25,20 @@ class RootViewModel { let parentZStack: PaywallComponent.StackComponent? } + let headerViewModel: HeaderComponentViewModel? let stackViewModel: StackComponentViewModel let stickyFooterViewModel: StickyFooterComponentViewModel? let firstItemIgnoresSafeAreaInfo: FirstItemShouldIgnoreSafeAreaInfo? let localizationProvider: LocalizationProvider init( + headerViewModel: HeaderComponentViewModel?, stackViewModel: StackComponentViewModel, stickyFooterViewModel: StickyFooterComponentViewModel?, firstItemIgnoresSafeAreaInfo: FirstItemShouldIgnoreSafeAreaInfo?, localizationProvider: LocalizationProvider ) { + self.headerViewModel = headerViewModel self.stackViewModel = stackViewModel self.stickyFooterViewModel = stickyFooterViewModel self.firstItemIgnoresSafeAreaInfo = firstItemIgnoresSafeAreaInfo diff --git a/RevenueCatUI/Templates/V2/PaywallsV2View.swift b/RevenueCatUI/Templates/V2/PaywallsV2View.swift index e5a6805567..8054dad31e 100644 --- a/RevenueCatUI/Templates/V2/PaywallsV2View.swift +++ b/RevenueCatUI/Templates/V2/PaywallsV2View.swift @@ -331,7 +331,10 @@ private struct LoadedPaywallsV2View: View { // we will ignore safe area pass the safe area insets in to environment // If the image is in a ZStack, the ZStack will push non-images // down with the inset - .applyIf(paywallState.rootViewModel.firstItemIgnoresSafeAreaInfo != nil, apply: { view in + .applyIf( + paywallState.rootViewModel.headerViewModel != nil + || paywallState.rootViewModel.firstItemIgnoresSafeAreaInfo != nil, + apply: { view in view .edgesIgnoringSafeArea(.top) }) diff --git a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift index cb409c5cc0..a66c4f421f 100644 --- a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift +++ b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift @@ -43,14 +43,41 @@ struct ViewModelFactory { ) throws -> RootViewModel { // Compute global flag: if ANY component has unsupported conditions, discard all rule overrides self.discardRules = componentsConfig.stack.containsUnsupportedConditions() || + componentsConfig.header?.stack.containsUnsupportedConditions() == true || componentsConfig.stickyFooter?.stack.containsUnsupportedConditions() == true - let firstItemIgnoresSafeAreaInfo = self.findFullWidthImageViewIfItsTheFirst(.stack(componentsConfig.stack)) + let headerFirstItemSafeAreaInfo: RootViewModel.FirstItemShouldIgnoreSafeAreaInfo? + if let header = componentsConfig.header, + !header.stack.components.isEmpty { + headerFirstItemSafeAreaInfo = self.findFullWidthImageViewIfItsTheFirst(.stack(header.stack)) + } else { + headerFirstItemSafeAreaInfo = nil + } + let rootFirstItemSafeAreaInfo = self.findFullWidthImageViewIfItsTheFirst(.stack(componentsConfig.stack)) + + let headerViewModel = try componentsConfig.header.flatMap { + let stackViewModel = try toStackViewModel( + component: $0.stack, + packageValidator: self.packageValidator, + firstItemIgnoresSafeAreaInfo: headerFirstItemSafeAreaInfo, + purchaseButtonCollector: nil, + localizationProvider: localizationProvider, + uiConfigProvider: uiConfigProvider, + offering: offering, + colorScheme: colorScheme + ) + + return HeaderComponentViewModel( + component: $0, + stackViewModel: stackViewModel, + firstItemIgnoresSafeArea: headerFirstItemSafeAreaInfo != nil + ) + } let rootStackViewModel = try toStackViewModel( component: componentsConfig.stack, packageValidator: self.packageValidator, - firstItemIgnoresSafeAreaInfo: firstItemIgnoresSafeAreaInfo, + firstItemIgnoresSafeAreaInfo: rootFirstItemSafeAreaInfo, purchaseButtonCollector: nil, localizationProvider: localizationProvider, uiConfigProvider: uiConfigProvider, @@ -77,9 +104,10 @@ struct ViewModelFactory { } return RootViewModel( + headerViewModel: headerViewModel, stackViewModel: rootStackViewModel, stickyFooterViewModel: stickyFooterViewModel, - firstItemIgnoresSafeAreaInfo: firstItemIgnoresSafeAreaInfo, + firstItemIgnoresSafeAreaInfo: rootFirstItemSafeAreaInfo, localizationProvider: localizationProvider ) } diff --git a/Sources/Networking/Responses/RevenueCatUI/PaywallComponentsData.swift b/Sources/Networking/Responses/RevenueCatUI/PaywallComponentsData.swift index d55d1c2dc9..e443873db4 100644 --- a/Sources/Networking/Responses/RevenueCatUI/PaywallComponentsData.swift +++ b/Sources/Networking/Responses/RevenueCatUI/PaywallComponentsData.swift @@ -29,6 +29,7 @@ import Foundation public struct PaywallComponentsConfig: Codable, Equatable, Sendable { public var stack: PaywallComponent.StackComponent + @_spi(Internal) public let header: PaywallComponent.HeaderComponent? public let stickyFooter: PaywallComponent.StickyFooterComponent? public var background: PaywallComponent.Background @@ -37,11 +38,24 @@ import Foundation stickyFooter: PaywallComponent.StickyFooterComponent?, background: PaywallComponent.Background ) { + self.header = nil self.stack = stack self.stickyFooter = stickyFooter self.background = background } + @_spi(Internal) public init( + stack: PaywallComponent.StackComponent, + header: PaywallComponent.HeaderComponent?, + stickyFooter: PaywallComponent.StickyFooterComponent?, + background: PaywallComponent.Background + ) { + self.stack = stack + self.header = header + self.stickyFooter = stickyFooter + self.background = background + } + } public enum LocalizationData: Codable, Equatable, Sendable { diff --git a/Sources/Paywalls/Components/PaywallHeaderComponent.swift b/Sources/Paywalls/Components/PaywallHeaderComponent.swift new file mode 100644 index 0000000000..393850d85c --- /dev/null +++ b/Sources/Paywalls/Components/PaywallHeaderComponent.swift @@ -0,0 +1,63 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallHeaderComponent.swift +// +// Created by Facundo Menzella on 02/04/2026. +// +// swiftlint:disable missing_docs + +import Foundation + +@_spi(Internal) public extension PaywallComponent { + + private enum HeaderCodingKeys: String, CodingKey { + case type + case stack + } + + private enum HeaderType: String, Codable { + case header + } + + final class HeaderComponent: PaywallComponentBase { + + @_spi(Internal) public let stack: PaywallComponent.StackComponent + + @_spi(Internal) public init( + stack: PaywallComponent.StackComponent + ) { + self.stack = stack + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(stack) + } + + public static func == (lhs: HeaderComponent, rhs: HeaderComponent) -> Bool { + return lhs.stack == rhs.stack + } + + @_spi(Internal) public convenience init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: HeaderCodingKeys.self) + let stack = try container.decode(PaywallComponent.StackComponent.self, forKey: .stack) + + self.init(stack: stack) + } + + @_spi(Internal) public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: HeaderCodingKeys.self) + + try container.encode(HeaderType.header, forKey: .type) + try container.encode(self.stack, forKey: .stack) + } + + } + +} diff --git a/Sources/Paywalls/Components/PaywallV2CacheWarming.swift b/Sources/Paywalls/Components/PaywallV2CacheWarming.swift index 156aa74366..504c878607 100644 --- a/Sources/Paywalls/Components/PaywallV2CacheWarming.swift +++ b/Sources/Paywalls/Components/PaywallV2CacheWarming.swift @@ -42,18 +42,22 @@ extension PaywallComponentsData.PaywallComponentsConfig { var allImageURLs: [URL] { let rootStackImageURLs = self.collectAllImageURLs(in: self.stack) + let headerImageURLs = self.header.flatMap { + self.collectAllImageURLs(in: $0.stack) + } ?? [] let stickFooterImageURLs = self.stickyFooter.flatMap { self.collectAllImageURLs(in: $0.stack) } ?? [] - return rootStackImageURLs + stickFooterImageURLs + self.background.allImageURLS + return rootStackImageURLs + headerImageURLs + stickFooterImageURLs + self.background.allImageURLS } var allLowResVideoUrls: [URLWithValidation] { let rootStackVideoURLs = self.collectAllVideoURLs(in: self.stack) + let headerVideoURLs = self.header.flatMap { self.collectAllVideoURLs(in: $0.stack) } ?? [] let stickFooterVideoURLs = self.stickyFooter.flatMap { self.collectAllVideoURLs(in: $0.stack) } ?? [] - return rootStackVideoURLs + stickFooterVideoURLs + self.background.lowResVideoUrls + return rootStackVideoURLs + headerVideoURLs + stickFooterVideoURLs + self.background.lowResVideoUrls } // swiftlint:disable:next cyclomatic_complexity function_body_length diff --git a/Tests/RevenueCatUITests/PaywallsV2/ViewModelFactoryBadgeTests.swift b/Tests/RevenueCatUITests/PaywallsV2/ViewModelFactoryBadgeTests.swift index 4674ade9b1..27e49902d2 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/ViewModelFactoryBadgeTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/ViewModelFactoryBadgeTests.swift @@ -202,315 +202,6 @@ class ViewModelFactoryBadgeTests: TestCase { expect(viewModel.badgeViewModels).toNot(beEmpty()) } - @MainActor - func testTabsOverrideWithSelectedPackageCondition_DoesNotThrowUnsupportedCondition() throws { - let tabs = PaywallComponent.TabsComponent( - control: .init( - type: .buttons, - stack: .init( - components: [ - .tabControlButton(.init( - tabId: "tab_1", - stack: .init(components: []) - )) - ] - ) - ), - tabs: [ - .init( - id: "tab_1", - stack: .init(components: []) - ) - ], - overrides: [ - .init( - extendedConditions: [ - .selectedPackage(operator: .in, packages: ["annual"]) - ], - properties: .init(visible: false) - ) - ] - ) - - let factory = ViewModelFactory() - let packageValidator = PackageValidator() - - expect { - _ = try factory.toViewModel( - component: .tabs(tabs), - packageValidator: packageValidator, - firstItemIgnoresSafeAreaInfo: nil, - purchaseButtonCollector: nil, - offering: Self.mockOffering, - localizationProvider: .init(locale: .current, localizedStrings: [:]), - uiConfigProvider: try Self.createUIConfigProvider(), - colorScheme: .light - ) - }.toNot(throwError()) - } - - // MARK: - Global discardRules Tests (Cross-Component Unsupported Condition Propagation) - - @MainActor - func testGlobalUnsupported_DiscardsRulesFromComponentWithoutUnsupported() throws { - // Component A (text): has an unsupported condition - let textWithUnsupported = PaywallComponent.TextComponent( - text: "badge_text_lid", - color: Self.black, - overrides: [ - .init(extendedConditions: [.unsupported], properties: .init()) - ] - ) - - // Component B (text): has only a rule condition (no unsupported locally) - let textWithRule = PaywallComponent.TextComponent( - text: "badge_text_lid", - color: Self.black, - overrides: [ - .init(extendedConditions: [ - .selectedPackage(operator: .in, packages: ["monthly"]) - ], properties: .init(fontWeight: .bold)) - ] - ) - - // Root stack contains both - let rootStack = PaywallComponent.StackComponent( - components: [.text(textWithUnsupported), .text(textWithRule)] - ) - - let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( - stack: rootStack, - stickyFooter: nil, - background: .color(.init(light: .hex("#FFFFFF"))) - ) - - var factory = ViewModelFactory() - let root = try factory.toRootViewModel( - componentsConfig: componentsConfig, - offering: Self.mockOffering, - localizationProvider: .init(locale: .current, localizedStrings: [ - "badge_text_lid": .string("Text") - ]), - uiConfigProvider: try Self.createUIConfigProvider(), - colorScheme: .light - ) - - // The factory should have detected unsupported conditions globally - expect(factory.discardRules).to(beTrue()) - - // Component B's rule override should have been discarded globally - // We verify by checking the root's child view models - let stackVM = root.stackViewModel - expect(stackVM.viewModels.count).to(equal(2)) - } - - @MainActor - func testGlobalUnsupported_InStickyFooter_DiscardsRulesFromMainStack() throws { - // Main stack text: has only a rule condition (no unsupported locally) - let textWithRule = PaywallComponent.TextComponent( - text: "badge_text_lid", - color: Self.black, - overrides: [ - .init(extendedConditions: [ - .variable(operator: .equals, variable: "plan", value: .string("pro")) - ], properties: .init(fontWeight: .bold)) - ] - ) - - let rootStack = PaywallComponent.StackComponent( - components: [.text(textWithRule)] - ) - - // Sticky footer: has unsupported condition - let footerText = PaywallComponent.TextComponent( - text: "badge_text_lid", - color: Self.black, - overrides: [ - .init(extendedConditions: [.unsupported], properties: .init()) - ] - ) - let footerStack = PaywallComponent.StackComponent( - components: [.text(footerText)] - ) - let stickyFooter = PaywallComponent.StickyFooterComponent( - stack: footerStack - ) - - let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( - stack: rootStack, - stickyFooter: stickyFooter, - background: .color(.init(light: .hex("#FFFFFF"))) - ) - - var factory = ViewModelFactory() - _ = try factory.toRootViewModel( - componentsConfig: componentsConfig, - offering: Self.mockOffering, - localizationProvider: .init(locale: .current, localizedStrings: [ - "badge_text_lid": .string("Text") - ]), - uiConfigProvider: try Self.createUIConfigProvider(), - colorScheme: .light - ) - - // Unsupported in sticky footer should trigger global discard - expect(factory.discardRules).to(beTrue()) - } - - @MainActor - func testNoUnsupported_DiscardRulesIsFalse() throws { - let textWithRule = PaywallComponent.TextComponent( - text: "badge_text_lid", - color: Self.black, - overrides: [ - .init(extendedConditions: [ - .selectedPackage(operator: .in, packages: ["monthly"]) - ], properties: .init(fontWeight: .bold)) - ] - ) - - let rootStack = PaywallComponent.StackComponent( - components: [.text(textWithRule)] - ) - - let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( - stack: rootStack, - stickyFooter: nil, - background: .color(.init(light: .hex("#FFFFFF"))) - ) - - var factory = ViewModelFactory() - _ = try factory.toRootViewModel( - componentsConfig: componentsConfig, - offering: Self.mockOffering, - localizationProvider: .init(locale: .current, localizedStrings: [ - "badge_text_lid": .string("Text") - ]), - uiConfigProvider: try Self.createUIConfigProvider(), - colorScheme: .light - ) - - expect(factory.discardRules).to(beFalse()) - } - - @MainActor - func testGlobalUnsupported_InButtonSheetDestination_DiscardsRulesFromRootStack() throws { - // Root stack text: has only a rule condition (no unsupported locally) - let textWithRule = PaywallComponent.TextComponent( - text: "badge_text_lid", - color: Self.black, - overrides: [ - .init(extendedConditions: [ - .selectedPackage(operator: .in, packages: ["monthly"]) - ], properties: .init(fontWeight: .bold)) - ] - ) - - // Button with sheet destination containing unsupported condition - let sheetText = PaywallComponent.TextComponent( - text: "badge_text_lid", - color: Self.black, - overrides: [ - .init(extendedConditions: [.unsupported], properties: .init()) - ] - ) - let button = PaywallComponent.ButtonComponent( - action: .navigateTo(destination: .sheet(sheet: .init( - id: "sheet_1", - name: nil, - stack: .init(components: [.text(sheetText)]), - backgroundBlur: false, - size: nil - ))), - stack: .init(components: [ - .text(.init(text: "badge_text_lid", color: Self.black)) - ]) - ) - - let rootStack = PaywallComponent.StackComponent( - components: [.text(textWithRule), .button(button)] - ) - - let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( - stack: rootStack, - stickyFooter: nil, - background: .color(.init(light: .hex("#FFFFFF"))) - ) - - var factory = ViewModelFactory() - _ = try factory.toRootViewModel( - componentsConfig: componentsConfig, - offering: Self.mockOffering, - localizationProvider: .init(locale: .current, localizedStrings: [ - "badge_text_lid": .string("Text") - ]), - uiConfigProvider: try Self.createUIConfigProvider(), - colorScheme: .light - ) - - // Unsupported in button sheet should trigger global discard - expect(factory.discardRules).to(beTrue()) - } - - @MainActor - func testGlobalUnsupported_InNestedTabsComponent_DiscardsRulesFromSibling() throws { - // Sibling text: has only a rule condition (no unsupported locally) - let textWithRule = PaywallComponent.TextComponent( - text: "badge_text_lid", - color: Self.black, - overrides: [ - .init(extendedConditions: [ - .variable(operator: .equals, variable: "tier", value: .string("pro")) - ], properties: .init(fontWeight: .bold)) - ] - ) - - // Tabs component: tab contains unsupported condition in its stack - let tabText = PaywallComponent.TextComponent( - text: "badge_text_lid", - color: Self.black, - overrides: [ - .init(extendedConditions: [.unsupported], properties: .init()) - ] - ) - let tabs = PaywallComponent.TabsComponent( - control: .init( - type: .buttons, - stack: .init(components: [ - .tabControlButton(.init(tabId: "tab_1", stack: .init(components: []))) - ]) - ), - tabs: [.init( - id: "tab_1", - stack: .init(components: [.text(tabText)]) - )] - ) - - let rootStack = PaywallComponent.StackComponent( - components: [.text(textWithRule), .tabs(tabs)] - ) - - let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( - stack: rootStack, - stickyFooter: nil, - background: .color(.init(light: .hex("#FFFFFF"))) - ) - - var factory = ViewModelFactory() - _ = try factory.toRootViewModel( - componentsConfig: componentsConfig, - offering: Self.mockOffering, - localizationProvider: .init(locale: .current, localizedStrings: [ - "badge_text_lid": .string("Text") - ]), - uiConfigProvider: try Self.createUIConfigProvider(), - colorScheme: .light - ) - - // Unsupported in tabs subtree should trigger global discard - expect(factory.discardRules).to(beTrue()) - } - // MARK: - Helpers private static let black = PaywallComponent.ColorScheme( diff --git a/Tests/RevenueCatUITests/PaywallsV2/ViewModelFactoryTests.swift b/Tests/RevenueCatUITests/PaywallsV2/ViewModelFactoryTests.swift new file mode 100644 index 0000000000..025f39cffa --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/ViewModelFactoryTests.swift @@ -0,0 +1,575 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// ViewModelFactoryTests.swift +// +// Created by Facundo Menzella on 4/9/26. + +import Nimble +@_spi(Internal) import RevenueCat +@testable import RevenueCatUI +import XCTest + +#if !os(tvOS) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +class ViewModelFactoryTests: TestCase { + + // MARK: - Unsupported Condition Tests + + @MainActor + func testTabsOverrideWithSelectedPackageCondition_DoesNotThrowUnsupportedCondition() throws { + let tabs = PaywallComponent.TabsComponent( + control: .init( + type: .buttons, + stack: .init( + components: [ + .tabControlButton(.init( + tabId: "tab_1", + stack: .init(components: []) + )) + ] + ) + ), + tabs: [ + .init( + id: "tab_1", + stack: .init(components: []) + ) + ], + overrides: [ + .init( + extendedConditions: [ + .selectedPackage(operator: .in, packages: ["annual"]) + ], + properties: .init(visible: false) + ) + ] + ) + + let factory = ViewModelFactory() + let packageValidator = PackageValidator() + + expect { + _ = try factory.toViewModel( + component: .tabs(tabs), + packageValidator: packageValidator, + firstItemIgnoresSafeAreaInfo: nil, + purchaseButtonCollector: nil, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [:]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + }.toNot(throwError()) + } + + @MainActor + func testGlobalUnsupported_DiscardsRulesFromComponentWithoutUnsupported() throws { + let textWithUnsupported = PaywallComponent.TextComponent( + text: "badge_text_lid", + color: Self.black, + overrides: [ + .init(extendedConditions: [.unsupported], properties: .init()) + ] + ) + + let textWithRule = PaywallComponent.TextComponent( + text: "badge_text_lid", + color: Self.black, + overrides: [ + .init(extendedConditions: [ + .selectedPackage(operator: .in, packages: ["monthly"]) + ], properties: .init(fontWeight: .bold)) + ] + ) + + let rootStack = PaywallComponent.StackComponent( + components: [.text(textWithUnsupported), .text(textWithRule)] + ) + + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: rootStack, + stickyFooter: nil, + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + let root = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [ + "badge_text_lid": .string("Text") + ]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(factory.discardRules).to(beTrue()) + expect(root.stackViewModel.viewModels.count).to(equal(2)) + } + + @MainActor + func testGlobalUnsupported_InStickyFooter_DiscardsRulesFromMainStack() throws { + let textWithRule = PaywallComponent.TextComponent( + text: "badge_text_lid", + color: Self.black, + overrides: [ + .init(extendedConditions: [ + .variable(operator: .equals, variable: "plan", value: .string("pro")) + ], properties: .init(fontWeight: .bold)) + ] + ) + + let footerText = PaywallComponent.TextComponent( + text: "badge_text_lid", + color: Self.black, + overrides: [ + .init(extendedConditions: [.unsupported], properties: .init()) + ] + ) + let stickyFooter = PaywallComponent.StickyFooterComponent( + stack: .init(components: [.text(footerText)]) + ) + + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: .init(components: [.text(textWithRule)]), + stickyFooter: stickyFooter, + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + _ = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [ + "badge_text_lid": .string("Text") + ]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(factory.discardRules).to(beTrue()) + } + + @MainActor + func testGlobalUnsupported_InHeader_DiscardsRulesFromMainStack() throws { + let textWithRule = PaywallComponent.TextComponent( + text: "badge_text_lid", + color: Self.black, + overrides: [ + .init(extendedConditions: [ + .variable(operator: .equals, variable: "plan", value: .string("pro")) + ], properties: .init(fontWeight: .bold)) + ] + ) + + let headerText = PaywallComponent.TextComponent( + text: "badge_text_lid", + color: Self.black, + overrides: [ + .init(extendedConditions: [.unsupported], properties: .init()) + ] + ) + + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: .init(components: [.text(textWithRule)]), + header: .init(stack: .init(components: [.text(headerText)])), + stickyFooter: nil, + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + _ = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [ + "badge_text_lid": .string("Text") + ]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(factory.discardRules).to(beTrue()) + } + + @MainActor + func testNoUnsupported_DiscardRulesIsFalse() throws { + let textWithRule = PaywallComponent.TextComponent( + text: "badge_text_lid", + color: Self.black, + overrides: [ + .init(extendedConditions: [ + .selectedPackage(operator: .in, packages: ["monthly"]) + ], properties: .init(fontWeight: .bold)) + ] + ) + + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: .init(components: [.text(textWithRule)]), + stickyFooter: nil, + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + _ = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [ + "badge_text_lid": .string("Text") + ]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(factory.discardRules).to(beFalse()) + } + + @MainActor + func testGlobalUnsupported_InButtonSheetDestination_DiscardsRulesFromRootStack() throws { + let textWithRule = PaywallComponent.TextComponent( + text: "badge_text_lid", + color: Self.black, + overrides: [ + .init(extendedConditions: [ + .selectedPackage(operator: .in, packages: ["monthly"]) + ], properties: .init(fontWeight: .bold)) + ] + ) + + let sheetText = PaywallComponent.TextComponent( + text: "badge_text_lid", + color: Self.black, + overrides: [ + .init(extendedConditions: [.unsupported], properties: .init()) + ] + ) + let button = PaywallComponent.ButtonComponent( + action: .navigateTo(destination: .sheet(sheet: .init( + id: "sheet_1", + name: nil, + stack: .init(components: [.text(sheetText)]), + backgroundBlur: false, + size: nil + ))), + stack: .init(components: [ + .text(.init(text: "badge_text_lid", color: Self.black)) + ]) + ) + + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: .init(components: [.text(textWithRule), .button(button)]), + stickyFooter: nil, + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + _ = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [ + "badge_text_lid": .string("Text") + ]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(factory.discardRules).to(beTrue()) + } + + @MainActor + func testGlobalUnsupported_InNestedTabsComponent_DiscardsRulesFromSibling() throws { + let textWithRule = PaywallComponent.TextComponent( + text: "badge_text_lid", + color: Self.black, + overrides: [ + .init(extendedConditions: [ + .variable(operator: .equals, variable: "tier", value: .string("pro")) + ], properties: .init(fontWeight: .bold)) + ] + ) + + let tabText = PaywallComponent.TextComponent( + text: "badge_text_lid", + color: Self.black, + overrides: [ + .init(extendedConditions: [.unsupported], properties: .init()) + ] + ) + let tabs = PaywallComponent.TabsComponent( + control: .init( + type: .buttons, + stack: .init(components: [ + .tabControlButton(.init(tabId: "tab_1", stack: .init(components: []))) + ]) + ), + tabs: [.init( + id: "tab_1", + stack: .init(components: [.text(tabText)]) + )] + ) + + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: .init(components: [.text(textWithRule), .tabs(tabs)]), + stickyFooter: nil, + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + _ = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [ + "badge_text_lid": .string("Text") + ]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(factory.discardRules).to(beTrue()) + } + + // MARK: - Header Tests + + @MainActor + func testRootViewModelCreatesHeaderViewModelWhenHeaderPresent() throws { + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: .init(components: []), + header: .init(stack: .init(components: [ + .text(.init(text: "badge_text_lid", color: Self.black)) + ])), + stickyFooter: nil, + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + let root = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [ + "badge_text_lid": .string("Text") + ]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(root.headerViewModel).toNot(beNil()) + expect(root.headerViewModel?.stackViewModel.viewModels).to(haveCount(1)) + } + + @MainActor + func testNonImageHeaderDoesNotBlockRootSafeAreaIgnoreInfo() throws { + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: .init(components: [ + .image( + .init( + source: .init(light: .init( + width: 1, + height: 1, + original: Self.sampleURL, + heic: Self.sampleURL, + heicLowRes: Self.sampleURL + )) + ) + ) + ]), + header: .init(stack: .init(components: [ + .text(.init(text: "badge_text_lid", color: Self.black)) + ])), + stickyFooter: nil, + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + let root = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [ + "badge_text_lid": .string("Text") + ]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(root.firstItemIgnoresSafeAreaInfo).toNot(beNil()) + expect(root.headerViewModel?.firstItemIgnoresSafeArea).to(beFalse()) + } + + @MainActor + func testHeaderHeroUsesHeaderSafeAreaIgnoreInfoWithoutAffectingRootHeroInfo() throws { + let headerStack = PaywallComponent.StackComponent( + components: [ + .image( + .init( + source: .init(light: .init( + width: 1, + height: 1, + original: Self.sampleURL, + heic: Self.sampleURL, + heicLowRes: Self.sampleURL + )), + size: .init(width: .fill, height: .fit) + ) + ), + .text(.init(text: "badge_text_lid", color: Self.black)) + ], + dimension: .zlayer(.top) + ) + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: .init(components: []), + header: .init(stack: headerStack), + stickyFooter: nil, + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + let root = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [:]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(root.firstItemIgnoresSafeAreaInfo).to(beNil()) + expect(root.headerViewModel?.firstItemIgnoresSafeArea).to(beTrue()) + expect(root.headerViewModel?.stackViewModel.shouldApplySafeAreaInset).to(beTrue()) + } + + // MARK: - Layout Tests + + @MainActor + func testLayout_HeaderAndStickyFooter_BothViewModelsPresent() throws { + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: .init(components: []), + header: .init(stack: .init(components: [])), + stickyFooter: .init(stack: .init(components: [])), + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + let root = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [:]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(root.headerViewModel).toNot(beNil()) + expect(root.stickyFooterViewModel).toNot(beNil()) + } + + @MainActor + func testLayout_HeaderOnly_StickyFooterViewModelNil() throws { + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: .init(components: []), + header: .init(stack: .init(components: [])), + stickyFooter: nil, + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + let root = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [:]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(root.headerViewModel).toNot(beNil()) + expect(root.stickyFooterViewModel).to(beNil()) + } + + @MainActor + func testLayout_StickyFooterOnly_HeaderViewModelNil() throws { + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: .init(components: []), + stickyFooter: .init(stack: .init(components: [])), + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + let root = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [:]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(root.headerViewModel).to(beNil()) + expect(root.stickyFooterViewModel).toNot(beNil()) + } + + @MainActor + func testLayout_BodyOnly_BothViewModelsNil() throws { + let componentsConfig = PaywallComponentsData.PaywallComponentsConfig( + stack: .init(components: []), + stickyFooter: nil, + background: .color(.init(light: .hex("#FFFFFF"))) + ) + + var factory = ViewModelFactory() + let root = try factory.toRootViewModel( + componentsConfig: componentsConfig, + offering: Self.mockOffering, + localizationProvider: .init(locale: .current, localizedStrings: [:]), + uiConfigProvider: try Self.createUIConfigProvider(), + colorScheme: .light + ) + + expect(root.headerViewModel).to(beNil()) + expect(root.stickyFooterViewModel).to(beNil()) + } + + // MARK: - Helpers + + private static let black = PaywallComponent.ColorScheme( + light: .hex("#000000") + ) + + // swiftlint:disable:next force_unwrapping + private static let sampleURL = URL(string: "https://revenuecat.com/image.heic")! + + private static func createUIConfigProvider() throws -> UIConfigProvider { + let json = """ + { + "app": { + "colors": {}, + "fonts": {} + }, + "localizations": {}, + "variable_config": { + "variable_compatibility_map": {}, + "function_compatibility_map": {} + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let uiConfig = try decoder.decode(UIConfig.self, from: jsonData) + return UIConfigProvider(uiConfig: uiConfig) + } + + private static var mockOffering: Offering { + return .init( + identifier: "test_offering", + serverDescription: "Test Offering", + metadata: [:], + availablePackages: [], + webCheckoutUrl: nil + ) + } + +} + +#endif diff --git a/Tests/UnitTests/Networking/Responses/PaywallComponentsConfigHeaderTests.swift b/Tests/UnitTests/Networking/Responses/PaywallComponentsConfigHeaderTests.swift new file mode 100644 index 0000000000..b352c93565 --- /dev/null +++ b/Tests/UnitTests/Networking/Responses/PaywallComponentsConfigHeaderTests.swift @@ -0,0 +1,126 @@ +import Nimble +@_spi(Internal) @testable import RevenueCat +import XCTest + +class PaywallComponentsConfigHeaderTests: TestCase { + + private let backgroundJSON = """ + { + "type": "color", + "value": { + "light": { + "type": "hex", + "value": "#FFFFFF" + } + } + } + """ + + private let stackJSON = """ + { + "type": "stack", + "dimension": { + "type": "vertical", + "alignment": "center", + "distribution": "start" + }, + "size": { + "width": { "type": "fill" }, + "height": { "type": "fit" } + }, + "padding": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "margin": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "components": [] + } + """ + + func testDecodesHeaderWhenPresent() throws { + let json = """ + { + "stack": \(self.stackJSON), + "background": \(self.backgroundJSON), + "header": { + "type": "header", + "stack": \(self.stackJSON) + } + } + """ + + let decoded = try JSONDecoder.default.decode( + PaywallComponentsData.PaywallComponentsConfig.self, + from: json.data(using: .utf8)! + ) + + expect(decoded.header).toNot(beNil()) + expect(decoded.header?.stack.components).to(beEmpty()) + } + + func testDecodesHeaderWhenAbsent() throws { + let json = """ + { + "stack": \(self.stackJSON), + "background": \(self.backgroundJSON) + } + """ + + let decoded = try JSONDecoder.default.decode( + PaywallComponentsData.PaywallComponentsConfig.self, + from: json.data(using: .utf8)! + ) + + expect(decoded.header).to(beNil()) + } + + func testDecodesHeaderWhenNull() throws { + let json = """ + { + "stack": \(self.stackJSON), + "background": \(self.backgroundJSON), + "header": null + } + """ + + let decoded = try JSONDecoder.default.decode( + PaywallComponentsData.PaywallComponentsConfig.self, + from: json.data(using: .utf8)! + ) + + expect(decoded.header).to(beNil()) + } + + func testDecodesHeaderAndStickyFooterTogether() throws { + let json = """ + { + "stack": \(self.stackJSON), + "background": \(self.backgroundJSON), + "header": { + "type": "header", + "stack": \(self.stackJSON) + }, + "sticky_footer": { + "type": "sticky_footer", + "stack": \(self.stackJSON) + } + } + """ + + let decoded = try JSONDecoder.default.decode( + PaywallComponentsData.PaywallComponentsConfig.self, + from: json.data(using: .utf8)! + ) + + expect(decoded.header).toNot(beNil()) + expect(decoded.stickyFooter).toNot(beNil()) + } + +} diff --git a/Tests/UnitTests/Paywalls/Components/HeaderComponentTests.swift b/Tests/UnitTests/Paywalls/Components/HeaderComponentTests.swift new file mode 100644 index 0000000000..f0ff52f33b --- /dev/null +++ b/Tests/UnitTests/Paywalls/Components/HeaderComponentTests.swift @@ -0,0 +1,159 @@ +import Nimble +@_spi(Internal) @testable import RevenueCat +import XCTest + +#if !os(tvOS) // For Paywalls V2 + +class HeaderComponentTests: TestCase { + + private let defaultStackJSON = """ + { + "type": "stack", + "dimension": { + "type": "vertical", + "alignment": "center", + "distribution": "start" + }, + "size": { + "width": { "type": "fill" }, + "height": { "type": "fit" } + }, + "padding": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "margin": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "components": [] + } + """ + + func testDecodesHeaderComponentWithNonEmptyStack() throws { + let json = """ + { + "type": "header", + "stack": { + "type": "stack", + "dimension": { + "type": "vertical", + "alignment": "center", + "distribution": "start" + }, + "size": { + "width": { "type": "fill" }, + "height": { "type": "fit" } + }, + "padding": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "margin": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "components": [ + { + "type": "text", + "text_lid": "header_text", + "font_weight": "regular", + "color": { + "light": { + "type": "hex", + "value": "#000000" + } + }, + "font_size": "body_m", + "horizontal_alignment": "center", + "size": { + "width": { "type": "fill" }, + "height": { "type": "fit" } + }, + "padding": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "margin": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + } + } + ] + } + } + """ + + let decoded = try JSONDecoder.default.decode( + PaywallComponent.HeaderComponent.self, + from: json.data(using: .utf8)! + ) + + expect(decoded.stack.components).to(haveCount(1)) + } + + func testDecodesHeaderComponentWithEmptyStack() throws { + let json = """ + { + "type": "header", + "stack": \(self.defaultStackJSON) + } + """ + + let decoded = try JSONDecoder.default.decode( + PaywallComponent.HeaderComponent.self, + from: json.data(using: .utf8)! + ) + + expect(decoded.stack.components).to(beEmpty()) + } + + func testDecodesHeaderComponentIgnoringExtraFields() throws { + let json = """ + { + "type": "header", + "id": "header_1", + "name": "Header", + "stack": \(self.defaultStackJSON) + } + """ + + let decoded = try JSONDecoder.default.decode( + PaywallComponent.HeaderComponent.self, + from: json.data(using: .utf8)! + ) + + expect(decoded.stack.components).to(beEmpty()) + } + + func testEncodesHeaderComponentType() throws { + let component = PaywallComponent.HeaderComponent( + stack: .init( + components: [], + dimension: .vertical(.center, .start), + size: .init(width: .fill, height: .fit) + ) + ) + + let data = try JSONEncoder.default.encode(component) + let jsonObject = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + expect(jsonObject["type"] as? String) == "header" + expect(jsonObject["stack"]).toNot(beNil()) + } + +} + +#endif