diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index ba44c4b724..379714a7aa 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -47,6 +47,13 @@ 03C72FC32D349BAE00297FEC /* IconComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C72FBF2D349BAE00297FEC /* IconComponentView.swift */; }; 03C7305B2D35985900297FEC /* TextComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C7305A2D35985500297FEC /* TextComponentTests.swift */; }; 03C730F32D35F14600297FEC /* PaywallV2CacheWarming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C730F22D35F13F00297FEC /* PaywallV2CacheWarming.swift */; }; + 03E1325D2E5007F70028D9FD /* PaywallSlotComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E1325C2E5007F70028D9FD /* PaywallSlotComponent.swift */; }; + 03E132602E500A570028D9FD /* SlotComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E1325F2E500A540028D9FD /* SlotComponentView.swift */; }; + 03E132622E500A600028D9FD /* SlotComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E132612E500A5B0028D9FD /* SlotComponentViewModel.swift */; }; + 03E132D92E510B7B0028D9FD /* PaywallSlotLottieComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E132D82E510B7B0028D9FD /* PaywallSlotLottieComponent.swift */; }; + 03E132E02E510C220028D9FD /* SlotLottieComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E132DE2E510C220028D9FD /* SlotLottieComponentView.swift */; }; + 03E132E12E510C220028D9FD /* SlotLottieComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E132DF2E510C220028D9FD /* SlotLottieComponentViewModel.swift */; }; + 03E133DA2E5343390028D9FD /* ViewRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E133D92E5343390028D9FD /* ViewRegistry.swift */; }; 03E37BEA2D30B32200CD9678 /* PaywallTabsComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E37BE92D30B32200CD9678 /* PaywallTabsComponent.swift */; }; 03F0B6512DD5700D00E82199 /* LocalizationDictionaryExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F0B6502DD5700D00E82199 /* LocalizationDictionaryExtensions.swift */; }; 03F0B6712DD6364100E82199 /* NavigatetoURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F0B6702DD6363B00E82199 /* NavigatetoURL.swift */; }; @@ -1496,6 +1503,13 @@ 03C7305A2D35985500297FEC /* TextComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextComponentTests.swift; sourceTree = ""; }; 03C730F22D35F13F00297FEC /* PaywallV2CacheWarming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallV2CacheWarming.swift; sourceTree = ""; }; 03CCFC172DE94FF70052B764 /* Paywall-Screenshots.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Paywall-Screenshots.xctestplan"; sourceTree = ""; }; + 03E1325C2E5007F70028D9FD /* PaywallSlotComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallSlotComponent.swift; sourceTree = ""; }; + 03E1325F2E500A540028D9FD /* SlotComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlotComponentView.swift; sourceTree = ""; }; + 03E132612E500A5B0028D9FD /* SlotComponentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlotComponentViewModel.swift; sourceTree = ""; }; + 03E132D82E510B7B0028D9FD /* PaywallSlotLottieComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallSlotLottieComponent.swift; sourceTree = ""; }; + 03E132DE2E510C220028D9FD /* SlotLottieComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlotLottieComponentView.swift; sourceTree = ""; }; + 03E132DF2E510C220028D9FD /* SlotLottieComponentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlotLottieComponentViewModel.swift; sourceTree = ""; }; + 03E133D92E5343390028D9FD /* ViewRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewRegistry.swift; sourceTree = ""; }; 03E37BE92D30B32200CD9678 /* PaywallTabsComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallTabsComponent.swift; sourceTree = ""; }; 03F0B6502DD5700D00E82199 /* LocalizationDictionaryExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationDictionaryExtensions.swift; sourceTree = ""; }; 03F0B6702DD6363B00E82199 /* NavigatetoURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigatetoURL.swift; sourceTree = ""; }; @@ -2913,6 +2927,31 @@ path = __PreviewResources__; sourceTree = ""; }; + 03E1325E2E500A420028D9FD /* Slot */ = { + isa = PBXGroup; + children = ( + 03E1325F2E500A540028D9FD /* SlotComponentView.swift */, + 03E132612E500A5B0028D9FD /* SlotComponentViewModel.swift */, + ); + path = Slot; + sourceTree = ""; + }; + 03E132DD2E510C1D0028D9FD /* SlotLottie */ = { + isa = PBXGroup; + children = ( + 03E132DE2E510C220028D9FD /* SlotLottieComponentView.swift */, + 03E132DF2E510C220028D9FD /* SlotLottieComponentViewModel.swift */, + ); + path = SlotLottie; + sourceTree = ""; + }; + 03E133542E5331990028D9FD /* RevenueCatUILottie */ = { + isa = PBXGroup; + children = ( + ); + path = RevenueCatUILottie; + sourceTree = ""; + }; 03F446222D2FE0B90046129A /* Properties */ = { isa = PBXGroup; children = ( @@ -2977,6 +3016,7 @@ 2C7457432CEA6470004ACE52 /* Components */ = { isa = PBXGroup; children = ( + 03E132DD2E510C1D0028D9FD /* SlotLottie */, 2C7457472CEA66AB004ACE52 /* ComponentsView.swift */, 7707A94A2CAD936A006E0313 /* Button */, 03C06FC82D479C6300600693 /* Carousel */, @@ -2987,6 +3027,7 @@ 0354AA452D4029C300F9E330 /* Tabs */, 4D3BA5B12D47AB4400668AFC /* Timeline */, 88B1BADC2C813A3C001B7EE5 /* Text */, + 03E1325E2E500A420028D9FD /* Slot */, 778360772CCA85D1000785B8 /* StickyFooter */, 778360742CCA84FA000785B8 /* Root */, ); @@ -3953,6 +3994,7 @@ 7707A93B2CAD8AA2006E0313 /* Local.xcconfig */, 77607FD72CAD87D60066C23C /* Global.xcconfig */, 887A60652C1D037000E1A461 /* RevenueCatUI */, + 03E133542E5331990028D9FD /* RevenueCatUILottie */, 2DC5621724EC63420031F69B /* Sources */, 2DDE870027E5238500D8B390 /* Tests */, 887A5FBB2C1D036200E1A461 /* RevenueCatUIDev */, @@ -5457,6 +5499,8 @@ 03C730F22D35F13F00297FEC /* PaywallV2CacheWarming.swift */, 2CC791612CC0493600FBE120 /* Common */, 7707A94B2CAD93AC006E0313 /* PaywallButtonComponent.swift */, + 03E1325C2E5007F70028D9FD /* PaywallSlotComponent.swift */, + 03E132D82E510B7B0028D9FD /* PaywallSlotLottieComponent.swift */, 88AD01032C740CF400AA1F2B /* PaywallImageComponent.swift */, 03C72FBD2D34949600297FEC /* PaywallIconComponent.swift */, 88E679462C7503C1007E69D5 /* PaywallStackComponent.swift */, @@ -5474,6 +5518,7 @@ 88AD01352C74196600AA1F2B /* V2 */ = { isa = PBXGroup; children = ( + 03E133D92E5343390028D9FD /* ViewRegistry.swift */, 88B1BAE32C813A3C001B7EE5 /* PaywallsV2View.swift */, 2C7457492CEA6B06004ACE52 /* EnvironmentObjects */, 030F91882D55C0FF0085103F /* Localizations */, @@ -6557,6 +6602,7 @@ FD3A85FC2DDE7532005F3C79 /* VirtualCurrencies.swift in Sources */, 4F6BEDE22A26B69500CD9322 /* DebugContentViews.swift in Sources */, B3B5FBBC269D121B00104A0C /* Offerings.swift in Sources */, + 03E1325D2E5007F70028D9FD /* PaywallSlotComponent.swift in Sources */, 9A65E03B25918B0900DE00B0 /* CustomerInfoStrings.swift in Sources */, 5721360F28B4602C006C46BE /* Purchases+nonasync.swift in Sources */, 57DE806D28074976008D6C6F /* Storefront.swift in Sources */, @@ -6758,6 +6804,7 @@ F5BE424226965F9F00254A30 /* ProductRequestData+Initialization.swift in Sources */, 2DDF41AD24F6F37C005BC22D /* ASN1ObjectIdentifier.swift in Sources */, 75B6DFFB2E30EC86009F0A99 /* GetWebOfferingProductsOperation.swift in Sources */, + 03E132D92E510B7B0028D9FD /* PaywallSlotLottieComponent.swift in Sources */, 35549323269E298B005F9AE9 /* OfferingsFactory.swift in Sources */, 57536A28278522B400E2AE7F /* SK2StoreTransaction.swift in Sources */, 2D9C7BB326D838FC006838BE /* UIApplication+RCExtensions.swift in Sources */, @@ -7390,6 +7437,7 @@ 778360792CCA85E4000785B8 /* StickyFooterComponentViewModel.swift in Sources */, 2C7457482CEA66AB004ACE52 /* ComponentsView.swift in Sources */, 03C06FCA2D479C7400600693 /* CarouselComponentView.swift in Sources */, + 03E132602E500A570028D9FD /* SlotComponentView.swift in Sources */, 353FDC0F2CA446FA0055F328 /* StoreProductDiscount+Extensions.swift in Sources */, 03A98CF12D222F5F009BCA61 /* FallbackComponentPreview.swift in Sources */, 887A60862C1D037000E1A461 /* FooterHidingModifier.swift in Sources */, @@ -7430,6 +7478,7 @@ FD9BCEBC2DC946C400408A5D /* BottomSheetView.swift in Sources */, 03F0B6512DD5700D00E82199 /* LocalizationDictionaryExtensions.swift in Sources */, 7783607B2CCA88E4000785B8 /* StickyFooterComponentView.swift in Sources */, + 03E132622E500A600028D9FD /* SlotComponentViewModel.swift in Sources */, FD15AF5B2DF9A8F30062467E /* VirtualCurrencyBalanceListRow.swift in Sources */, FD15AF5C2DF9A8F30062467E /* VirtualCurrencyBalancesScreen.swift in Sources */, FD15AF5D2DF9A8F30062467E /* VirtualCurrenciesScrollViewWithOSBackgroundSection.swift in Sources */, @@ -7539,6 +7588,7 @@ 5798C96F2DF1985700F44400 /* PurchaseDetailView.swift in Sources */, 5798C9702DF1985700F44400 /* SubscriptionDetailViewModel.swift in Sources */, 5798C9712DF1985700F44400 /* PurchaseInformation+History.swift in Sources */, + 03E133DA2E5343390028D9FD /* ViewRegistry.swift in Sources */, 5798C9722DF1985700F44400 /* DiscountsHandler.swift in Sources */, 75D7048B2DFC33A900F02803 /* Purchases+RevenueCatUI.swift in Sources */, 5798C9732DF1985700F44400 /* PurchaseHistoryViewModel.swift in Sources */, @@ -7558,6 +7608,8 @@ 887A60BF2C1D037000E1A461 /* PaywallViewController.swift in Sources */, 2CC791552CC0452100FBE120 /* PurchaseButtonComponentViewModel.swift in Sources */, 2CC791562CC0452100FBE120 /* PackageComponentView.swift in Sources */, + 03E132E02E510C220028D9FD /* SlotLottieComponentView.swift in Sources */, + 03E132E12E510C220028D9FD /* SlotLottieComponentViewModel.swift in Sources */, 2CC791592CC0452100FBE120 /* PurchaseButtonComponentView.swift in Sources */, 2CC7915A2CC0452100FBE120 /* PackageComponentViewModel.swift in Sources */, 887A60772C1D037000E1A461 /* TemplateViewConfiguration+Images.swift in Sources */, diff --git a/RevenueCatUI/Templates/V2/Components/ComponentsView.swift b/RevenueCatUI/Templates/V2/Components/ComponentsView.swift index a89cf2faab..6136f526e1 100644 --- a/RevenueCatUI/Templates/V2/Components/ComponentsView.swift +++ b/RevenueCatUI/Templates/V2/Components/ComponentsView.swift @@ -76,6 +76,10 @@ struct ComponentsView: View { TabControlToggleComponentView(viewModel: viewModel, onDismiss: onDismiss) case .carousel(let viewModel): CarouselComponentView(viewModel: viewModel, onDismiss: onDismiss) + case .slot(let viewModel): + SlotComponentView(viewModel: viewModel) + case .slotLottie(let viewModel): + SlotLottieComponentView(viewModel: viewModel) } } // Applies a top padding to mimmic safe area insets diff --git a/RevenueCatUI/Templates/V2/Components/Slot/SlotComponentView.swift b/RevenueCatUI/Templates/V2/Components/Slot/SlotComponentView.swift new file mode 100644 index 0000000000..2731ac9e5f --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/Slot/SlotComponentView.swift @@ -0,0 +1,106 @@ +// +// 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 +// +// SlotComponentView.swift +// +// Created by Josh Holtz on 8/15/25. + +import Foundation +import RevenueCat +import SwiftUI + +#if !os(macOS) && !os(tvOS) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct SlotComponentView: View { + + @EnvironmentObject + private var packageContext: PackageContext + + @EnvironmentObject + private var introOfferEligibilityContext: IntroOfferEligibilityContext + + @EnvironmentObject + private var paywallPromoOfferCache: PaywallPromoOfferCache + + @Environment(\.componentViewState) + private var componentViewState + + @Environment(\.screenCondition) + private var screenCondition + + @EnvironmentObject + private var viewRegistry: ViewRegistry + + let viewModel: SlotComponentViewModel + + var body: some View { + self.viewModel.styles( + state: self.componentViewState, + condition: self.screenCondition, + isEligibleForIntroOffer: self.introOfferEligibilityContext.isEligible( + package: self.packageContext.package + ), + isEligibleForPromoOffer: self.paywallPromoOfferCache.isMostLikelyEligible( + for: self.packageContext.package + ) + ) { style in + self.viewRegistry.makeView(identifier: viewModel.identifier) + // Style the carousel + .size(style.size) + .padding(style.padding) + .padding(style.margin) + } + } + +} + +#if DEBUG + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct SlotComponentView_Previews: PreviewProvider { + + static let viewRegistry: ViewRegistry = { + let viewRegistry = ViewRegistry() + viewRegistry.register { identifier in + Text("Preview Slot \(identifier)") + } + viewRegistry.register(type: .slotLottie) { _ in + Text("Lottie goes here") + } + return viewRegistry + }() + + // Need to wrap in VStack otherwise preview rerenders and images won't show + static var previews: some View { + + // Default + VStack { + SlotComponentView( + // swiftlint:disable:next force_try + viewModel: try! .init( + localizationProvider: .init( + locale: Locale.current, + localizedStrings: [:] + ), + component: .init(identifier: "test_slot_1") + ) + ) + } + .environmentObject(viewRegistry) + .previewRequiredPaywallsV2Properties() + .previewLayout(.fixed(width: 100, height: 100)) + .previewDisplayName("Default") + + } +} + +#endif + +#endif diff --git a/RevenueCatUI/Templates/V2/Components/Slot/SlotComponentViewModel.swift b/RevenueCatUI/Templates/V2/Components/Slot/SlotComponentViewModel.swift new file mode 100644 index 0000000000..5fb35e2d6f --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/Slot/SlotComponentViewModel.swift @@ -0,0 +1,115 @@ +// +// 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 +// +// SlotComponentViewModel.swift +// +// Created by Josh Holtz on 8/15/25. + +import RevenueCat +import SwiftUI + +#if !os(macOS) && !os(tvOS) // For Paywalls V2 + +typealias PresentedSlotPartial = PaywallComponent.PartialSlotComponent + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +class SlotComponentViewModel { + + private let localizationProvider: LocalizationProvider + private let component: PaywallComponent.SlotComponent + + private let presentedOverrides: PresentedOverrides? + + init( + localizationProvider: LocalizationProvider, + component: PaywallComponent.SlotComponent + ) throws { + self.localizationProvider = localizationProvider + self.component = component + + self.presentedOverrides = try self.component.overrides?.toPresentedOverrides { $0 } + } + + var identifier: String { + return component.identifier + } + + @ViewBuilder + func styles( + state: ComponentViewState, + condition: ScreenCondition, + isEligibleForIntroOffer: Bool, + isEligibleForPromoOffer: Bool, + @ViewBuilder apply: @escaping (SlotComponentStyle) -> some View + ) -> some View { + let partial = PresentedSlotPartial.buildPartial( + state: state, + condition: condition, + isEligibleForIntroOffer: isEligibleForIntroOffer, + isEligibleForPromoOffer: isEligibleForPromoOffer, + with: self.presentedOverrides + ) + + let style = SlotComponentStyle( + visible: partial?.visible ?? self.component.visible ?? true, + size: partial?.size ?? self.component.size, + padding: partial?.padding ?? self.component.padding, + margin: partial?.margin ?? self.component.margin + ) + + apply(style) + } + +} + +extension PresentedSlotPartial: PresentedPartial { + + static func combine( + _ base: PaywallComponent.PartialSlotComponent?, + with other: PaywallComponent.PartialSlotComponent? + ) -> Self { + + let visible = other?.visible ?? base?.visible + let size = other?.size ?? base?.size + let padding = other?.padding ?? base?.padding + let margin = other?.margin ?? base?.margin + + return .init( + visible: visible, + size: size, + padding: padding, + margin: margin + ) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct SlotComponentStyle { + + let visible: Bool + let size: PaywallComponent.Size + let padding: EdgeInsets + let margin: EdgeInsets + + init( + visible: Bool, + size: PaywallComponent.Size?, + padding: PaywallComponent.Padding?, + margin: PaywallComponent.Padding? + ) { + self.visible = visible + self.size = size ?? .init(width: .fit, height: .fit) + self.padding = (padding ?? .zero).edgeInsets + self.margin = (margin ?? .zero).edgeInsets + } + +} + +#endif diff --git a/RevenueCatUI/Templates/V2/Components/SlotLottie/SlotLottieComponentView.swift b/RevenueCatUI/Templates/V2/Components/SlotLottie/SlotLottieComponentView.swift new file mode 100644 index 0000000000..c631a549fe --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/SlotLottie/SlotLottieComponentView.swift @@ -0,0 +1,107 @@ +// +// 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 +// +// SlotComponentView.swift +// +// Created by Josh Holtz on 8/15/25. + +import Foundation +import SwiftUI + +#if !os(macOS) && !os(tvOS) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct SlotLottieComponentView: View { + + @EnvironmentObject + private var packageContext: PackageContext + + @EnvironmentObject + private var introOfferEligibilityContext: IntroOfferEligibilityContext + + @EnvironmentObject + private var paywallPromoOfferCache: PaywallPromoOfferCache + + @Environment(\.componentViewState) + private var componentViewState + + @Environment(\.screenCondition) + private var screenCondition + + @EnvironmentObject + private var viewRegistry: ViewRegistry + + let viewModel: SlotLottieComponentViewModel + + var body: some View { + self.viewModel.styles( + state: self.componentViewState, + condition: self.screenCondition, + isEligibleForIntroOffer: self.introOfferEligibilityContext.isEligible( + package: self.packageContext.package + ), + isEligibleForPromoOffer: self.paywallPromoOfferCache.isMostLikelyEligible( + for: self.packageContext.package + ) + ) { style in + self.viewRegistry.makeView( + component: .slotLottie(viewModel.component) + ) + // Style the carousel + .size(style.size) + .padding(style.padding) + .padding(style.margin) + } + } + +} + +#if DEBUG + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct SlotLottieComponentView_Previews: PreviewProvider { + + static let viewRegistry: ViewRegistry = { + let viewRegister = ViewRegistry() + viewRegister.register(type: .slotLottie) { _ in + Text("Lottie goes here") + } + return viewRegister + }() + + // Need to wrap in VStack otherwise preview rerenders and images won't show + static var previews: some View { + + // Default + VStack { + SlotLottieComponentView( + // swiftlint:disable:next force_try + viewModel: try! .init( + localizationProvider: .init( + locale: Locale.current, + localizedStrings: [:] + ), + component: .init( + identifier: "", + value: .url(URL(string: "https://something.com")!) + ) + ) + ) + } + .environmentObject(viewRegistry) + .previewRequiredPaywallsV2Properties() + .previewLayout(.fixed(width: 100, height: 100)) + .previewDisplayName("Default") + + } +} + +#endif + +#endif diff --git a/RevenueCatUI/Templates/V2/Components/SlotLottie/SlotLottieComponentViewModel.swift b/RevenueCatUI/Templates/V2/Components/SlotLottie/SlotLottieComponentViewModel.swift new file mode 100644 index 0000000000..5bd4caa27a --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/SlotLottie/SlotLottieComponentViewModel.swift @@ -0,0 +1,117 @@ +// +// 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 +// +// SlotComponentViewModel.swift +// +// Created by Josh Holtz on 8/15/25. + +import RevenueCat +import SwiftUI + +#if !os(macOS) && !os(tvOS) // For Paywalls V2 + +typealias PresentedSlotLottiePartial = PaywallComponent.PartialSlotLottieComponent + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +class SlotLottieComponentViewModel { + + private let localizationProvider: LocalizationProvider + let component: PaywallComponent.SlotLottieComponent + + private let presentedOverrides: PresentedOverrides? + + init( + localizationProvider: LocalizationProvider, + component: PaywallComponent.SlotLottieComponent + ) throws { + self.localizationProvider = localizationProvider + self.component = component + + self.presentedOverrides = try self.component.overrides?.toPresentedOverrides { $0 } + } + + @ViewBuilder + func styles( + state: ComponentViewState, + condition: ScreenCondition, + isEligibleForIntroOffer: Bool, + isEligibleForPromoOffer: Bool, + @ViewBuilder apply: @escaping (SlotLottieComponentStyle) -> some View + ) -> some View { + let partial = PresentedSlotLottiePartial.buildPartial( + state: state, + condition: condition, + isEligibleForIntroOffer: isEligibleForIntroOffer, + isEligibleForPromoOffer: isEligibleForPromoOffer, + with: self.presentedOverrides + ) + + let style = SlotLottieComponentStyle( + visible: partial?.visible ?? self.component.visible ?? true, + value: partial?.value ?? self.component.value, + size: partial?.size ?? self.component.size, + padding: partial?.padding ?? self.component.padding, + margin: partial?.margin ?? self.component.margin + ) + + apply(style) + } + +} + +extension PresentedSlotLottiePartial: PresentedPartial { + + static func combine( + _ base: PaywallComponent.PartialSlotLottieComponent?, + with other: PaywallComponent.PartialSlotLottieComponent? + ) -> Self { + + let visible = other?.visible ?? base?.visible + let value = other?.value ?? base?.value + let size = other?.size ?? base?.size + let padding = other?.padding ?? base?.padding + let margin = other?.margin ?? base?.margin + + return .init( + visible: visible, + value: value, + size: size, + padding: padding, + margin: margin + ) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct SlotLottieComponentStyle { + + let visible: Bool + let value: PaywallComponent.SlotLottieComponent.Value + let size: PaywallComponent.Size + let padding: EdgeInsets + let margin: EdgeInsets + + init( + visible: Bool, + value: PaywallComponent.SlotLottieComponent.Value, + size: PaywallComponent.Size?, + padding: PaywallComponent.Padding?, + margin: PaywallComponent.Padding? + ) { + self.visible = visible + self.value = value + self.size = size ?? .init(width: .fit, height: .fit) + self.padding = (padding ?? .zero).edgeInsets + self.margin = (margin ?? .zero).edgeInsets + } + +} + +#endif diff --git a/RevenueCatUI/Templates/V2/PaywallsV2View.swift b/RevenueCatUI/Templates/V2/PaywallsV2View.swift index 89293558a1..d172db023f 100644 --- a/RevenueCatUI/Templates/V2/PaywallsV2View.swift +++ b/RevenueCatUI/Templates/V2/PaywallsV2View.swift @@ -107,6 +107,9 @@ struct PaywallsV2View: View { @StateObject private var paywallPromoOfferCache: PaywallPromoOfferCache + @StateObject + private var viewRegistery = ViewRegistry.shared + public init( paywallComponents: Offering.PaywallComponents, offering: Offering, @@ -176,6 +179,7 @@ struct PaywallsV2View: View { .environmentObject(self.purchaseHandler) .environmentObject(self.introOfferEligibilityContext) .environmentObject(self.paywallPromoOfferCache) + .environmentObject(self.viewRegistery) .disabled(self.purchaseHandler.actionInProgress) .onAppear { self.purchaseHandler.trackPaywallImpression( diff --git a/RevenueCatUI/Templates/V2/ViewModelHelpers/PaywallComponentViewModel.swift b/RevenueCatUI/Templates/V2/ViewModelHelpers/PaywallComponentViewModel.swift index 77eec1b5c9..7150a93790 100644 --- a/RevenueCatUI/Templates/V2/ViewModelHelpers/PaywallComponentViewModel.swift +++ b/RevenueCatUI/Templates/V2/ViewModelHelpers/PaywallComponentViewModel.swift @@ -38,6 +38,9 @@ enum PaywallComponentViewModel { case carousel(CarouselComponentViewModel) + case slot(SlotComponentViewModel) + case slotLottie(SlotLottieComponentViewModel) + } #endif diff --git a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift index 5c8cdc1d3b..b1d548b3f0 100644 --- a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift +++ b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift @@ -404,6 +404,20 @@ struct ViewModelFactory { pageStackViewModels: pageStackViewModels ) ) + case .slot(let slot): + return .slot( + try SlotComponentViewModel( + localizationProvider: localizationProvider, + component: slot + ) + ) + case .slotLottie(let slotLottie): + return .slotLottie( + try SlotLottieComponentViewModel( + localizationProvider: localizationProvider, + component: slotLottie + ) + ) } } @@ -518,6 +532,10 @@ struct ViewModelFactory { return nil } return self.findFullWidthImageViewIfItsTheFirst(first) + case .slot: + return nil + case .slotLottie: + return nil } } diff --git a/RevenueCatUI/Templates/V2/ViewRegistry.swift b/RevenueCatUI/Templates/V2/ViewRegistry.swift new file mode 100644 index 0000000000..3d0ffaf69c --- /dev/null +++ b/RevenueCatUI/Templates/V2/ViewRegistry.swift @@ -0,0 +1,140 @@ +// +// 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 +// +// ViewRegistry.swift +// +// Created by Josh Holtz on 8/18/25. + +import Combine +import RevenueCat +import SwiftUI + +/// A plugin that can register custom UI components with PurchasesUI. +public protocol PurchasesUIPlugin: Equatable { + /// Register the plugin's custom UI components. + func register() +} + +/// Main entry point for registering custom UI components and plugins with RevenueCatUI. +public enum PurchasesUI { + + /// Register a custom view for a specific paywall component type (Internal SPI). + @_spi(Internal) public static func register( + type: PaywallComponent.ComponentType, + @ViewBuilder _ callback: @escaping (PaywallComponent) -> Content + ) { + ViewRegistry.shared.register(type: type, callback) + } + + /// Register multiple plugins at once. + /// ```swift + /// PurchasesUI.register([PurchasesUI.Lottie]) + /// ``` + public static func register(_ plugins: [any PurchasesUIPlugin]) { + plugins.forEach { $0.register() } + } + + /// Register a single plugin. + /// ```swift + /// PurchasesUI.register(PurchasesUI.Lottie) + /// ``` + public static func register(_ plugin: any PurchasesUIPlugin) { + plugin.register() + } + + /// Register a custom view builder for slot components (Internal SPI). + @_spi(Internal) public static func register(@ViewBuilder _ callback: @escaping (String) -> Content) { + ViewRegistry.shared.register(callback) + } + +} + +// Returns AnyView so we can store any SwiftUI view +private typealias ViewProvider = (String) -> AnyView +private typealias ViewOtherProvider = (PaywallComponent) -> AnyView + +final class ViewRegistry: ObservableObject { + + static let shared = ViewRegistry() + + private var viewCallback: ViewProvider? + + private var otherCallback: [PaywallComponent.ComponentType: ViewOtherProvider] = [:] + + internal init() {} + + // Register a view-producing callback + func register( + type: PaywallComponent.ComponentType, + @ViewBuilder _ callback: @escaping (PaywallComponent) -> Content + ) { + + self.otherCallback[type] = { component in + AnyView(callback(component)) + } + } + + // Register a view-producing callback + func register(@ViewBuilder _ callback: @escaping (String) -> Content) { + self.viewCallback = { id in + AnyView(callback(id)) + } + } + + // Build the view; safe fallback wrapped as AnyView + func makeView(identifier: String) -> AnyView { + viewCallback?(identifier) ?? AnyView(EmptyView()) + } + + func makeView(component: PaywallComponent) -> AnyView { + self.otherCallback[component.type]?(component) ?? AnyView(EmptyView()) + } + +} + +private extension PaywallComponent { + + var type: PaywallComponent.ComponentType { + switch self { + case .text: + return .text + case .image: + return .image + case .icon: + return .icon + case .stack: + return .stack + case .button: + return .button + case .package: + return .package + case .purchaseButton: + return .purchaseButton + case .stickyFooter: + return .stickyFooter + case .timeline: + return .timeline + case .tabs: + return .tabs + case .tabControl: + return .tabControl + case .tabControlButton: + return .tabControlButton + case .tabControlToggle: + return .tabControlToggle + case .carousel: + return .carousel + case .slot: + return .slot + case .slotLottie: + return .slotLottie + } + } + +} diff --git a/Sources/Paywalls/Components/Common/PaywallComponentBase.swift b/Sources/Paywalls/Components/Common/PaywallComponentBase.swift index cb4a07af04..289ad28165 100644 --- a/Sources/Paywalls/Components/Common/PaywallComponentBase.swift +++ b/Sources/Paywalls/Components/Common/PaywallComponentBase.swift @@ -28,6 +28,9 @@ public enum PaywallComponent: Codable, Sendable, Hashable, Equatable { case carousel(CarouselComponent) + case slot(SlotComponent) + case slotLottie(SlotLottieComponent) + public enum ComponentType: String, Codable, Sendable { case text @@ -47,6 +50,9 @@ public enum PaywallComponent: Codable, Sendable, Hashable, Equatable { case carousel + case slot + case slotLottie = "lottie" + } } @@ -67,7 +73,7 @@ extension PaywallComponent { } - // swiftlint:disable:next cyclomatic_complexity + // swiftlint:disable:next cyclomatic_complexity function_body_length public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -114,6 +120,12 @@ extension PaywallComponent { case .carousel(let component): try container.encode(ComponentType.carousel, forKey: .type) try component.encode(to: encoder) + case .slot(let component): + try container.encode(ComponentType.slot, forKey: .type) + try component.encode(to: encoder) + case .slotLottie(let component): + try container.encode(ComponentType.slotLottie, forKey: .type) + try component.encode(to: encoder) } } @@ -195,6 +207,10 @@ extension PaywallComponent { return .tabControlToggle(try TabControlToggleComponent(from: decoder)) case .carousel: return .carousel(try CarouselComponent(from: decoder)) + case .slot: + return .slot(try SlotComponent(from: decoder)) + case .slotLottie: + return .slotLottie(try SlotLottieComponent(from: decoder)) } } diff --git a/Sources/Paywalls/Components/PaywallSlotComponent.swift b/Sources/Paywalls/Components/PaywallSlotComponent.swift new file mode 100644 index 0000000000..40b22545b4 --- /dev/null +++ b/Sources/Paywalls/Components/PaywallSlotComponent.swift @@ -0,0 +1,112 @@ +// +// 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 +// +// PaywallButtonComponent.swift +// +// Created by Jay Shortway on 02/10/2024. +// +// swiftlint:disable missing_docs nesting + +import Foundation + +public extension PaywallComponent { + + final class SlotComponent: PaywallComponentBase { + + let type: ComponentType + + public let visible: Bool? + public let identifier: String + public let size: Size? + public let padding: Padding? + public let margin: Padding? + + public let overrides: ComponentOverrides? + + public init( + identifier: String, + visible: Bool? = nil, + size: PaywallComponent.Size? = nil, + padding: PaywallComponent.Padding? = .zero, + margin: PaywallComponent.Padding? = .zero, + overrides: ComponentOverrides? = nil + ) { + self.type = .slot + self.visible = visible + self.identifier = identifier + self.size = size + self.padding = padding + self.margin = margin + self.overrides = overrides + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(visible) + hasher.combine(identifier) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(overrides) + } + + public static func == (lhs: SlotComponent, rhs: SlotComponent) -> Bool { + return lhs.type == rhs.type && + lhs.visible == rhs.visible && + lhs.identifier == rhs.identifier && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.overrides == rhs.overrides + } + + } + + final class PartialSlotComponent: PaywallPartialComponent { + + public let visible: Bool? + public let size: Size? + public let padding: Padding? + public let margin: Padding? + + public init( + visible: Bool? = true, + size: Size? = nil, + padding: Padding? = nil, + margin: Padding? = nil + ) { + self.visible = visible + self.size = size + self.padding = padding + self.margin = margin + } + + private enum CodingKeys: String, CodingKey { + case visible + case size + case padding + case margin + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(visible) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + } + + public static func == (lhs: PartialSlotComponent, rhs: PartialSlotComponent) -> Bool { + return lhs.visible == rhs.visible && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin + } + } + +} diff --git a/Sources/Paywalls/Components/PaywallSlotLottieComponent.swift b/Sources/Paywalls/Components/PaywallSlotLottieComponent.swift new file mode 100644 index 0000000000..03d31bc217 --- /dev/null +++ b/Sources/Paywalls/Components/PaywallSlotLottieComponent.swift @@ -0,0 +1,156 @@ +// +// 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 +// +// PaywallButtonComponent.swift +// +// Created by Jay Shortway on 02/10/2024. +// +// swiftlint:disable missing_docs nesting + +import Foundation + +public extension PaywallComponent { + + final class SlotLottieComponent: PaywallComponentBase { + + let type: ComponentType + + public let visible: Bool? + public let value: Value + public let size: Size? + public let padding: Padding? + public let margin: Padding? + + public let overrides: ComponentOverrides? + + public init( + visible: Bool? = nil, + identifier: String, + value: Value, + size: PaywallComponent.Size? = nil, + padding: PaywallComponent.Padding? = .zero, + margin: PaywallComponent.Padding? = .zero, + overrides: ComponentOverrides? = nil + ) { + self.type = .slotLottie + self.visible = visible + self.value = value + self.size = size + self.padding = padding + self.margin = margin + self.overrides = overrides + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(visible) + hasher.combine(value) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + hasher.combine(overrides) + } + + public static func == (lhs: SlotLottieComponent, rhs: SlotLottieComponent) -> Bool { + return lhs.type == rhs.type && + lhs.visible == rhs.visible && + lhs.value == rhs.value && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin && + lhs.overrides == rhs.overrides + } + + public enum Value: Codable, Sendable, Hashable, Equatable { + case url(URL) + + case unknown + + private enum CodingKeys: String, CodingKey { + case type + case url + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .url: + try container.encode("restore_purchases", forKey: .type) + case .unknown: + try container.encode("unknown", forKey: .type) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "url": + let url = try container.decode(URL.self, forKey: .url) + self = .url(url) + case "unknown": + self = .unknown + default: + self = .unknown + } + } + } + + } + + final class PartialSlotLottieComponent: PaywallPartialComponent { + + public let visible: Bool? + public let value: SlotLottieComponent.Value? + public let size: Size? + public let padding: Padding? + public let margin: Padding? + + public init( + visible: Bool? = true, + value: SlotLottieComponent.Value? = nil, + size: Size? = nil, + padding: Padding? = nil, + margin: Padding? = nil + ) { + self.visible = visible + self.value = value + self.size = size + self.padding = padding + self.margin = margin + } + + private enum CodingKeys: String, CodingKey { + case visible + case value + case size + case padding + case margin + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(visible) + hasher.combine(value) + hasher.combine(size) + hasher.combine(padding) + hasher.combine(margin) + } + + public static func == (lhs: PartialSlotLottieComponent, rhs: PartialSlotLottieComponent) -> Bool { + return lhs.visible == rhs.visible && + lhs.value == rhs.value && + lhs.size == rhs.size && + lhs.padding == rhs.padding && + lhs.margin == rhs.margin + } + } + +} diff --git a/Sources/Paywalls/Components/PaywallV2CacheWarming.swift b/Sources/Paywalls/Components/PaywallV2CacheWarming.swift index 9b492cdb72..47901ac904 100644 --- a/Sources/Paywalls/Components/PaywallV2CacheWarming.swift +++ b/Sources/Paywalls/Components/PaywallV2CacheWarming.swift @@ -87,6 +87,10 @@ extension PaywallComponentsData.PaywallComponentsConfig { urls += carousel.pages.flatMap({ stack in self.collectAllImageURLs(in: stack) }) + case .slot: + break + case .slotLottie: + break } }