diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 9ed02e0f75..c7098e44ca 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -12,6 +12,11 @@ 030F918A2D55C1D20085103F /* LocaleFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030F91892D55C1AB0085103F /* LocaleFinder.swift */; }; 030F918C2D55C9DC0085103F /* LocaleFinderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030F918B2D55C9D80085103F /* LocaleFinderTests.swift */; }; 030F918E2D5664410085103F /* LocaleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030F918D2D5664410085103F /* LocaleExtensions.swift */; }; + 030F91E32D56C4FD0085103F /* PaywallNavigationBarComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030F91E22D56C4FD0085103F /* PaywallNavigationBarComponent.swift */; }; + 030F91E62D56F0850085103F /* NavigationBarComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030F91E52D56F07F0085103F /* NavigationBarComponentViewModel.swift */; }; + 030F91FA2D57F1E70085103F /* NavigationViewIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030F91F92D57F1E20085103F /* NavigationViewIfNeeded.swift */; }; + 030F91FC2D5945C10085103F /* NavigationBarPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030F91FB2D5945AF0085103F /* NavigationBarPreview.swift */; }; + 030F920D2D595FE60085103F /* NavigationBarModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030F920C2D595FDF0085103F /* NavigationBarModifier.swift */; }; 0313FD41268A506400168386 /* DateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313FD40268A506400168386 /* DateProvider.swift */; }; 0354AA462D4029C300F9E330 /* TabControlButtonComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0354AA3E2D4029C300F9E330 /* TabControlButtonComponentViewModel.swift */; }; 0354AA472D4029C300F9E330 /* TabControlComponentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0354AA402D4029C300F9E330 /* TabControlComponentViewModel.swift */; }; @@ -804,7 +809,6 @@ 777FB4882C661C0600CD4749 /* SemanticVersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777FB4872C661C0600CD4749 /* SemanticVersionTests.swift */; }; 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 */; }; 77BA1AB12CCBAB80009BF0EA /* RootViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77BA1AB02CCBAB80009BF0EA /* RootViewModel.swift */; }; 77BA1AB32CCBB6EE009BF0EA /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77BA1AB22CCBB6EE009BF0EA /* RootView.swift */; }; 805B60C97993B311CEC93EAF /* ProductsFetcherSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3628C1F100BB3C1782860D24 /* ProductsFetcherSK2.swift */; }; @@ -1289,6 +1293,11 @@ 030F91892D55C1AB0085103F /* LocaleFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocaleFinder.swift; sourceTree = "<group>"; }; 030F918B2D55C9D80085103F /* LocaleFinderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocaleFinderTests.swift; sourceTree = "<group>"; }; 030F918D2D5664410085103F /* LocaleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocaleExtensions.swift; sourceTree = "<group>"; }; + 030F91E22D56C4FD0085103F /* PaywallNavigationBarComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallNavigationBarComponent.swift; sourceTree = "<group>"; }; + 030F91E52D56F07F0085103F /* NavigationBarComponentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarComponentViewModel.swift; sourceTree = "<group>"; }; + 030F91F92D57F1E20085103F /* NavigationViewIfNeeded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewIfNeeded.swift; sourceTree = "<group>"; }; + 030F91FB2D5945AF0085103F /* NavigationBarPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarPreview.swift; sourceTree = "<group>"; }; + 030F920C2D595FDF0085103F /* NavigationBarModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarModifier.swift; sourceTree = "<group>"; }; 0313FD40268A506400168386 /* DateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateProvider.swift; sourceTree = "<group>"; }; 0354AA3D2D4029C300F9E330 /* TabControlButtonComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabControlButtonComponentView.swift; sourceTree = "<group>"; }; 0354AA3E2D4029C300F9E330 /* TabControlButtonComponentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabControlButtonComponentViewModel.swift; sourceTree = "<group>"; }; @@ -2101,7 +2110,6 @@ 777FB4872C661C0600CD4749 /* SemanticVersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersionTests.swift; sourceTree = "<group>"; }; 7783606C2CCA7E14000785B8 /* PaywallStickyFooterComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallStickyFooterComponent.swift; sourceTree = "<group>"; }; 778360782CCA85E4000785B8 /* StickyFooterComponentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyFooterComponentViewModel.swift; sourceTree = "<group>"; }; - 7783607A2CCA88E4000785B8 /* StickyFooterComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyFooterComponentView.swift; sourceTree = "<group>"; }; 77BA1AB02CCBAB80009BF0EA /* RootViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModel.swift; sourceTree = "<group>"; }; 77BA1AB22CCBB6EE009BF0EA /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; }; 80E80EF026970DC3008F245A /* ReceiptFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptFetcher.swift; sourceTree = "<group>"; }; @@ -2563,6 +2571,14 @@ path = Localizations; sourceTree = "<group>"; }; + 030F91E42D56F0750085103F /* NavigationBar */ = { + isa = PBXGroup; + children = ( + 030F91E52D56F07F0085103F /* NavigationBarComponentViewModel.swift */, + ); + path = NavigationBar; + sourceTree = "<group>"; + }; 0354AA452D4029C300F9E330 /* Tabs */ = { isa = PBXGroup; children = ( @@ -2637,6 +2653,7 @@ 2C2AEB0D2CA64DA900A50F38 /* TemplateComponentsViewPreviews */ = { isa = PBXGroup; children = ( + 030F91FB2D5945AF0085103F /* NavigationBarPreview.swift */, 03A98CF02D222F53009BCA61 /* FallbackComponentPreview.swift */, 2C2AEB0E2CA64E0E00A50F38 /* Template1Preview.swift */, 2C8EC6DA2CCC23B700D6CCF8 /* MultiTierPreview.swift */, @@ -2666,6 +2683,7 @@ 0354AA452D4029C300F9E330 /* Tabs */, 4D3BA5B12D47AB4400668AFC /* Timeline */, 88B1BADC2C813A3C001B7EE5 /* Text */, + 030F91E42D56F0750085103F /* NavigationBar */, 778360772CCA85D1000785B8 /* StickyFooter */, 778360742CCA84FA000785B8 /* Root */, ); @@ -2675,6 +2693,8 @@ 2C7457442CEA652B004ACE52 /* ViewHelpers */ = { isa = PBXGroup; children = ( + 030F920C2D595FDF0085103F /* NavigationBarModifier.swift */, + 030F91F92D57F1E20085103F /* NavigationViewIfNeeded.swift */, 2C7457872CEDF7AC004ACE52 /* BackgroundStyle.swift */, 77089F9D2CD39EC100848CD5 /* ShadowModifier.swift */, 4D6F4BCF2CF69DE300353AF6 /* ForegroundColorScheme.swift */, @@ -4585,7 +4605,6 @@ isa = PBXGroup; children = ( 778360782CCA85E4000785B8 /* StickyFooterComponentViewModel.swift */, - 7783607A2CCA88E4000785B8 /* StickyFooterComponentView.swift */, ); path = StickyFooter; sourceTree = "<group>"; @@ -4963,6 +4982,7 @@ 2C2AEB3A2CA7209F00A50F38 /* PaywallPackageComponent.swift */, 2C2AEB3E2CA7235300A50F38 /* PaywallPurchaseButtonComponent.swift */, 7783606C2CCA7E14000785B8 /* PaywallStickyFooterComponent.swift */, + 030F91E22D56C4FD0085103F /* PaywallNavigationBarComponent.swift */, ); path = Components; sourceTree = "<group>"; @@ -6199,6 +6219,7 @@ 5736267B2D3E76C1003C9665 /* ProductPaidPrice.swift in Sources */, 5712BE9029241EB500A83F15 /* TimingUtil.swift in Sources */, B3B5FBC1269E17CE00104A0C /* DeviceCache.swift in Sources */, + 030F91E32D56C4FD0085103F /* PaywallNavigationBarComponent.swift in Sources */, F5BE424226965F9F00254A30 /* ProductRequestData+Initialization.swift in Sources */, 2DDF41AD24F6F37C005BC22D /* ASN1ObjectIdentifier.swift in Sources */, 35549323269E298B005F9AE9 /* OfferingsFactory.swift in Sources */, @@ -6759,6 +6780,7 @@ 887A607F2C1D037000E1A461 /* Optional+Extensions.swift in Sources */, 2C08B3172CDA44440024857B /* ViewModelFactory.swift in Sources */, 356979E02CCFDAA100EE6A9E /* CustomerInfoFixtures.swift in Sources */, + 030F91FC2D5945C10085103F /* NavigationBarPreview.swift in Sources */, 3511088F2C47F6DA0048C4D8 /* CustomerInfo+CurrentEntitlement.swift in Sources */, 887A60752C1D037000E1A461 /* TemplateViewConfiguration.swift in Sources */, 03C72FC32D349BAE00297FEC /* IconComponentView.swift in Sources */, @@ -6779,6 +6801,7 @@ 574BA26B2D3E762E009B8EA6 /* PurchaseInfo.swift in Sources */, 887A60C92C1D037000E1A461 /* PurchaseButton.swift in Sources */, 88B1BAF02C813A3C001B7EE5 /* TextComponentViewModel.swift in Sources */, + 030F91E62D56F0850085103F /* NavigationBarComponentViewModel.swift in Sources */, 2D2AFE912C6A9EF500D1B0B4 /* Binding+Extensions.swift in Sources */, 2C7457242CE713E5004ACE52 /* PreviewMock.swift in Sources */, 7706ED3E2C6E374D0004B9F9 /* ButtonStyles.swift in Sources */, @@ -6797,6 +6820,7 @@ 03A98CF12D222F5F009BCA61 /* FallbackComponentPreview.swift in Sources */, 887A60862C1D037000E1A461 /* FooterHidingModifier.swift in Sources */, 4D3BA5882D432E5900668AFC /* Fill.swift in Sources */, + 030F920D2D595FE60085103F /* NavigationBarModifier.swift in Sources */, FDAD6AC72D132DD900FB047E /* CustomerCenterStoreKitUtilitiesType.swift in Sources */, 887A60C02C1D037000E1A461 /* AsyncButton.swift in Sources */, 887A60892C1D037000E1A461 /* PaywallPurchasesType.swift in Sources */, @@ -6839,7 +6863,6 @@ 2C4C36132C6FBA8B00AE959B /* CompatibilityTopBarTrailing.swift in Sources */, 3546355C2C391F38001D7E85 /* FeedbackSurveyViewModel.swift in Sources */, 353756692C382C2800A1B8D6 /* CustomerCenterViewState.swift in Sources */, - 7783607B2CCA88E4000785B8 /* StickyFooterComponentView.swift in Sources */, 887A608B2C1D037000E1A461 /* PurchaseHandler+TestData.swift in Sources */, 35F249CE2C493E3D0058993A /* CustomerCenterPurchases.swift in Sources */, 353756672C382C2800A1B8D6 /* PurchaseInformation.swift in Sources */, @@ -6877,6 +6900,7 @@ 887A60CE2C1D037000E1A461 /* View+PresentPaywall.swift in Sources */, 357CEC702C5940CE00A80837 /* ColorFromAppearance.swift in Sources */, 887A60832C1D037000E1A461 /* VersionDetector.swift in Sources */, + 030F91FA2D57F1E70085103F /* NavigationViewIfNeeded.swift in Sources */, 574D1C702D3E75F9005840CD /* PurchaseDetailView.swift in Sources */, 574D1C712D3E75F9005840CD /* PurchaseHistoryView.swift in Sources */, 574D1C722D3E75F9005840CD /* PurchaseLinkView.swift in Sources */, diff --git a/RevenueCatUI/Templates/V2/Components/ComponentsView.swift b/RevenueCatUI/Templates/V2/Components/ComponentsView.swift index 44bac1453d..e5aa9e296a 100644 --- a/RevenueCatUI/Templates/V2/Components/ComponentsView.swift +++ b/RevenueCatUI/Templates/V2/Components/ComponentsView.swift @@ -62,8 +62,6 @@ struct ComponentsView: View { PackageComponentView(viewModel: viewModel, onDismiss: onDismiss) case .purchaseButton(let viewModel): PurchaseButtonComponentView(viewModel: viewModel) - case .stickyFooter(let viewModel): - StickyFooterComponentView(viewModel: viewModel) case .timeline(let viewModel): TimelineComponentView(viewModel: viewModel) case .tabs(let viewModel): diff --git a/RevenueCatUI/Templates/V2/Components/NavigationBar/NavigationBarComponentViewModel.swift b/RevenueCatUI/Templates/V2/Components/NavigationBar/NavigationBarComponentViewModel.swift new file mode 100644 index 0000000000..5f6d610bcb --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/NavigationBar/NavigationBarComponentViewModel.swift @@ -0,0 +1,39 @@ +// +// 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 +// +// NavigationBarComponentViewModel.swift +// +// Created by Josh Holtz on 2/7/25. + +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, *) +class NavigationBarComponentViewModel { + + let component: PaywallComponent.NavigationBarComponent + + let leadingStackViewModel: StackComponentViewModel? + let trailingStackViewModel: StackComponentViewModel? + + init( + component: PaywallComponent.NavigationBarComponent, + leadingStackViewModel: StackComponentViewModel?, + trailingStackViewModel: StackComponentViewModel? + ) { + self.component = component + self.leadingStackViewModel = leadingStackViewModel + self.trailingStackViewModel = trailingStackViewModel + } + +} + +#endif diff --git a/RevenueCatUI/Templates/V2/Components/Root/RootViewModel.swift b/RevenueCatUI/Templates/V2/Components/Root/RootViewModel.swift index fd301103e1..99a6548837 100644 --- a/RevenueCatUI/Templates/V2/Components/Root/RootViewModel.swift +++ b/RevenueCatUI/Templates/V2/Components/Root/RootViewModel.swift @@ -25,15 +25,18 @@ class RootViewModel { } let stackViewModel: StackComponentViewModel + let navigationBarViewModel: NavigationBarComponentViewModel? let stickyFooterViewModel: StickyFooterComponentViewModel? let firstImageInfo: FirstImageInfo? init( stackViewModel: StackComponentViewModel, + navigationBarViewModel: NavigationBarComponentViewModel?, stickyFooterViewModel: StickyFooterComponentViewModel?, firstImageInfo: FirstImageInfo? ) { self.stackViewModel = stackViewModel + self.navigationBarViewModel = navigationBarViewModel self.stickyFooterViewModel = stickyFooterViewModel self.firstImageInfo = firstImageInfo } diff --git a/RevenueCatUI/Templates/V2/Components/StickyFooter/StickyFooterComponentView.swift b/RevenueCatUI/Templates/V2/Components/StickyFooter/StickyFooterComponentView.swift deleted file mode 100644 index 54a072c981..0000000000 --- a/RevenueCatUI/Templates/V2/Components/StickyFooter/StickyFooterComponentView.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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 -// -// StickyFooterComponentView.swift -// -// Created by Jay Shortway on 24/10/2024. - -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 StickyFooterComponentView: View { - private let viewModel: StickyFooterComponentViewModel - - internal init(viewModel: StickyFooterComponentViewModel) { - self.viewModel = viewModel - } - - var body: some View { - EmptyView() - } - -} - -#endif diff --git a/RevenueCatUI/Templates/V2/PaywallsV2View.swift b/RevenueCatUI/Templates/V2/PaywallsV2View.swift index a5ea77b263..0722b38d93 100644 --- a/RevenueCatUI/Templates/V2/PaywallsV2View.swift +++ b/RevenueCatUI/Templates/V2/PaywallsV2View.swift @@ -170,6 +170,8 @@ struct PaywallsV2View: View { .task { await self.introOfferEligibilityContext.computeEligibility(for: paywallState.packages) } + // Needs to be last since this can wrap the view in a NavigationView/NavigationStack if needed + .navigationBarIfNeeded(paywallState.rootViewModel.navigationBarViewModel, onDismiss: self.onDismiss) case .failure(let error): // Show fallback paywall and debug error message that // occurred while validating data and view models diff --git a/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/FamilySharingTogglePreview.swift b/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/FamilySharingTogglePreview.swift index edb85fe15c..44944e3989 100644 --- a/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/FamilySharingTogglePreview.swift +++ b/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/FamilySharingTogglePreview.swift @@ -333,6 +333,7 @@ private enum FamilySharingTogglePreview { .stack(stack) ] ), + navigationBar: nil, stickyFooter: nil, background: .color(.init(light: .hex("#ffffff"))) ) diff --git a/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/MultiTierPreview.swift b/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/MultiTierPreview.swift index 24d533cafc..2143ab30d0 100644 --- a/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/MultiTierPreview.swift +++ b/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/MultiTierPreview.swift @@ -342,6 +342,7 @@ private enum MultiTierPreview { .stack(stack) ] ), + navigationBar: nil, stickyFooter: nil, background: .color(.init(light: .hex("#ffffff"))) ) diff --git a/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/NavigationBarPreview.swift b/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/NavigationBarPreview.swift new file mode 100644 index 0000000000..35c2dcbdbd --- /dev/null +++ b/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/NavigationBarPreview.swift @@ -0,0 +1,293 @@ +// +// 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 +// +// NavigationBarPreview.swift +// +// Created by Josh Holtz on 2/9/25. + +import Foundation +import RevenueCat +import SwiftUI + +#if !os(macOS) && !os(tvOS) // For Paywalls V2 + +#if DEBUG + +private enum NavigationBarPreview { + + static let catUrl = URL(string: "https://assets.pawwalls.com/954459_1701163461.jpg")! + + static let catImage = PaywallComponent.ImageComponent( + source: .init( + light: .init( + width: 750, + height: 530, + original: catUrl, + heic: catUrl, + heicLowRes: catUrl + ) + ), + size: .init(width: .fill, height: .fixed(270)), + fitMode: .fill, + maskShape: .convex + ) + + static let title = PaywallComponent.TextComponent( + text: "title", + fontName: nil, + fontWeight: .black, + color: .init(light: .hex("#000000")), + backgroundColor: nil, + padding: .zero, + margin: .zero, + fontSize: 28, + horizontalAlignment: .center + ) + + static let body = PaywallComponent.TextComponent( + text: "body", + fontName: nil, + fontWeight: .regular, + color: .init(light: .hex("#000000")), + backgroundColor: nil, + padding: .zero, + margin: .zero, + fontSize: 15, + horizontalAlignment: .center + ) + + static var packageStack: PaywallComponent.StackComponent { + return .init( + components: [ + .text(.init( + text: "package_name", + fontWeight: .bold, + color: .init(light: .hex("#000000")), + padding: .zero, + margin: .zero + )), + .text(.init( + text: "package_detail", + color: .init(light: .hex("#000000")), + padding: .zero, + margin: .zero + )) + ], + dimension: .vertical(.center, .start), + spacing: 0, + backgroundColor: nil, + padding: .init(top: 0, + bottom: 0, + leading: 0, + trailing: 0) + ) + } + + static let package = PaywallComponent.PackageComponent( + packageID: "weekly", + isSelectedByDefault: false, + stack: packageStack + ) + + static let purchaseButton = PaywallComponent.PurchaseButtonComponent( + stack: .init( + components: [ + // WIP: Intro offer state with "cta_intro", + .text(.init( + text: "cta", + fontWeight: .bold, + color: .init(light: .hex("#ffffff")), + backgroundColor: .init(light: .hex("#e89d89")), + padding: .init(top: 10, + bottom: 10, + leading: 30, + trailing: 30) + )) + ], + shape: .pill + ) + ) + + static let contentStack = PaywallComponent.StackComponent( + components: [ + .text(title), + .text(body), + .package(package), + .purchaseButton(purchaseButton) + ], + spacing: 30, + backgroundColor: nil, + margin: .init(top: 0, + bottom: 0, + leading: 20, + trailing: 20) + ) + + static let stack = PaywallComponent.StackComponent( + components: [ + .image(catImage), + .stack(contentStack) + ], + spacing: 20, + backgroundColor: nil + ) + + static let paywallComponents: Offering.PaywallComponents = .init( + uiConfig: .init( + app: .init( + colors: [:], + fonts: [:] + ), + localizations: [:], + variableConfig: .init( + variableCompatibilityMap: [:], + functionCompatibilityMap: [:] + ) + ), + data: data + ) + + static let data: PaywallComponentsData = .init( + templateName: "components", + assetBaseURL: URL(string: "https://assets.pawwalls.com")!, + componentsConfig: .init( + base: .init( + stack: .init( + components: [ + .stack(stack) + ] + ), + navigationBar: .init( + trailingStack: .init( + components: [ + .button(.init( + action: .navigateBack, + stack: .init( + components: [ + .text( + .init( + text: "nav_close", + color: .init(light: .hex("#000000")) + ) + ) + ] + ) + )) + ] + ) + ), + stickyFooter: nil, + background: .color(.init( + light: .hex("#ffffff") + )) + ) + ), + componentsLocalizations: ["en_US": [ + "title": .string("Ignite your cat's curiosity"), + "body": .string("Get access to all of our educational content trusted by thousands of pet parents."), + "package_name": .string("Monthly"), + "package_detail": .string("Some price into"), + "cta": .string("Get Started"), + "cta_intro": .string("Claim Free Trial"), + "nav_close": .string("Close") + ]], + revision: 1, + defaultLocaleIdentifier: "en_US" + ) +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct NavigationBarPreview_Previews: PreviewProvider { + + static var package: Package { + return .init(identifier: "weekly", + packageType: .weekly, + storeProduct: .init(sk1Product: .init()), + offeringIdentifier: "default") + } + + // Need to wrap in VStack otherwise preview rerenders and images won't show + static var previews: some View { + ExampleView(package: self.package) + .previewRequiredEnvironmentProperties() + .previewLayout(.fixed(width: 400, height: 800)) + .previewDisplayName("Template 1") + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private struct ExampleView: View { + + let package: Package + + @State + private var showSheet = false + + @State + private var showFullCover = false + + var body: some View { + NavigationView { + VStack(spacing: 20) { + + NavigationLink(destination: ThePaywallView(package: self.package)) { + Text("Push") + } + + Button(action: { + self.showSheet = true + }, label: { + Text("Sheet") + }) + + Button(action: { + self.showFullCover = true + }, label: { + Text("Full Screen") + }) + + } + } + .sheet(isPresented: self.$showSheet) { + ThePaywallView(package: self.package) + } + .fullScreenCover(isPresented: self.$showFullCover) { + ThePaywallView(package: self.package) + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private struct ThePaywallView: View { + + @Environment(\.dismiss) private var dismiss + + let package: Package + + var body: some View { + PaywallsV2View( + paywallComponents: NavigationBarPreview.paywallComponents, + offering: .init(identifier: "default", + serverDescription: "", + availablePackages: [self.package]), + introEligibilityChecker: .default(), + showZeroDecimalPlacePrices: true, + onDismiss: { + self.dismiss() + }, + fallbackContent: .customView(AnyView(Text("Fallback paywall"))) + ) + } + +} + +#endif + +#endif diff --git a/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/Template1Preview.swift b/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/Template1Preview.swift index 8f38ad8614..23496312b7 100644 --- a/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/Template1Preview.swift +++ b/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/Template1Preview.swift @@ -163,6 +163,7 @@ private enum Template1Preview { .stack(stack) ] ), + navigationBar: nil, stickyFooter: nil, background: .color(.init( light: .hex("#ffffff") diff --git a/RevenueCatUI/Templates/V2/ViewHelpers/NavigationBarModifier.swift b/RevenueCatUI/Templates/V2/ViewHelpers/NavigationBarModifier.swift new file mode 100644 index 0000000000..ed42d9a1ee --- /dev/null +++ b/RevenueCatUI/Templates/V2/ViewHelpers/NavigationBarModifier.swift @@ -0,0 +1,74 @@ +// +// 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 +// +// NavigationBarModifier.swift +// +// Created by Josh Holtz on 2/9/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 NavigationBarIfNeededModifier: ViewModifier { + + let viewModel: NavigationBarComponentViewModel? + let onDismiss: () -> Void + + var needsToolbar: Bool { + return self.viewModel?.leadingStackViewModel != nil || self.viewModel?.trailingStackViewModel != nil + } + + func body(content: Content) -> some View { + if needsToolbar { + NavigationViewIfNeeded { + content + .applyIfLet(self.viewModel?.leadingStackViewModel) { view, stack in + view.toolbar(content: { + ToolbarItem(placement: .topBarLeading) { + ComponentsView( + componentViewModels: [.stack(stack)], + onDismiss: self.onDismiss + ) + } + }) + } + .applyIfLet(self.viewModel?.trailingStackViewModel) { view, stack in + view.toolbar(content: { + ToolbarItem(placement: .topBarTrailing) { + ComponentsView( + componentViewModels: [.stack(stack)], + onDismiss: self.onDismiss + ) + } + }) + } + } + } else { + content + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension View { + func navigationBarIfNeeded( + _ viewModel: NavigationBarComponentViewModel?, + onDismiss: @escaping () -> Void + ) -> some View { + self.modifier(NavigationBarIfNeededModifier( + viewModel: viewModel, + onDismiss: onDismiss + )) + } +} + +#endif diff --git a/RevenueCatUI/Templates/V2/ViewHelpers/NavigationViewIfNeeded.swift b/RevenueCatUI/Templates/V2/ViewHelpers/NavigationViewIfNeeded.swift new file mode 100644 index 0000000000..bc8d719324 --- /dev/null +++ b/RevenueCatUI/Templates/V2/ViewHelpers/NavigationViewIfNeeded.swift @@ -0,0 +1,100 @@ +// +// 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 +// +// NavigationViewIfNeeded.swift +// +// Created by Josh Holtz on 2/8/25. + +import SwiftUI + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct NavigationViewIfNeeded<Content: View>: View { + enum Status { + case unknown + case inNav + case notInNav + } + + @State private var status: Status = .unknown + + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + switch status { + case .unknown: + Rectangle() + .frame(width: 0, height: 0) + .toolbar { + // Zero-sized detection view: + ZeroFrameDetectionView { isInNav in + // The first time we know the answer, store it + if status == .unknown { + status = isInNav ? .inNav : .notInNav + } + } + } + case .inNav: + content + case .notInNav: + #if swift(>=5.7) + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + // Using NavigationStack is best generic solution if only need + // to show a toolbar + // NavigatonStack toolbars combine nicely in parent NavigationView + NavigationStack { + content + } + } else { + NavigationView { + content + } + } + #else + NavigationView { + content + } + #endif + } + } +} + +// This minimal subview does the environment check and calls back exactly once. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private struct ZeroFrameDetectionView: View { + @Environment(\.isPresented) private var isPresented + let didDetect: (Bool) -> Void + + @State private var hasReported = false + + var body: some View { + Rectangle() + .frame(width: 0, height: 0) + .onChange(of: isPresented) { newValue in + if newValue { + self.report(true) + } + } + .onAppear { + // Dispatch once after SwiftUI lay out + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self.report(false) + } + } + } + + private func report(_ value: Bool) { + guard !self.hasReported else { return } + self.hasReported = true + self.didDetect(value) + } +} diff --git a/RevenueCatUI/Templates/V2/ViewModelHelpers/PaywallComponentViewModel.swift b/RevenueCatUI/Templates/V2/ViewModelHelpers/PaywallComponentViewModel.swift index 21508a6276..37bfcea4d0 100644 --- a/RevenueCatUI/Templates/V2/ViewModelHelpers/PaywallComponentViewModel.swift +++ b/RevenueCatUI/Templates/V2/ViewModelHelpers/PaywallComponentViewModel.swift @@ -28,7 +28,6 @@ enum PaywallComponentViewModel { case button(ButtonComponentViewModel) case package(PackageComponentViewModel) case purchaseButton(PurchaseButtonComponentViewModel) - case stickyFooter(StickyFooterComponentViewModel) case timeline(TimelineComponentViewModel) case tabs(TabsComponentViewModel) diff --git a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift index 421e1148d3..e5d3e55b69 100644 --- a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift +++ b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift @@ -10,6 +10,7 @@ // ViewModelFactory.swift // // Created by Josh Holtz on 11/5/24. +// swiftlint:disable function_body_length type_body_length file_length import Foundation import RevenueCat @@ -17,11 +18,11 @@ import RevenueCat #if !os(macOS) && !os(tvOS) // For Paywalls V2 @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -// swiftlint:disable:next type_body_length struct ViewModelFactory { let packageValidator = PackageValidator() + // swiftlint:disable:next function_body_length func toRootViewModel( componentsConfig: PaywallComponentsData.PaywallComponentsConfig, offering: Offering, @@ -55,8 +56,39 @@ struct ViewModelFactory { ) } + let navigationBarViewModel = try componentsConfig.navigationBar.flatMap { + let leadingStackViewModel = try $0.leadingStack.flatMap { stackComponent in + return try toStackViewModel( + component: stackComponent, + packageValidator: self.packageValidator, + firstImageInfo: nil, + localizationProvider: localizationProvider, + uiConfigProvider: uiConfigProvider, + offering: offering + ) + } + + let trailingStackViewModel = try $0.trailingStack.flatMap { stackComponent in + return try toStackViewModel( + component: stackComponent, + packageValidator: self.packageValidator, + firstImageInfo: nil, + localizationProvider: localizationProvider, + uiConfigProvider: uiConfigProvider, + offering: offering + ) + } + + return NavigationBarComponentViewModel( + component: $0, + leadingStackViewModel: leadingStackViewModel, + trailingStackViewModel: trailingStackViewModel + ) + } + return RootViewModel( stackViewModel: rootStackViewModel, + navigationBarViewModel: navigationBarViewModel, stickyFooterViewModel: stickyFooterViewModel, firstImageInfo: firstImageInfo ) @@ -159,22 +191,6 @@ struct ViewModelFactory { return .purchaseButton( PurchaseButtonComponentViewModel(stackViewModel: stackViewModel) ) - case .stickyFooter(let component): - let stackViewModel = try toStackViewModel( - component: component.stack, - packageValidator: packageValidator, - firstImageInfo: firstImageInfo, - localizationProvider: localizationProvider, - uiConfigProvider: uiConfigProvider, - offering: offering - ) - - return .stickyFooter( - StickyFooterComponentViewModel( - component: component, - stackViewModel: stackViewModel - ) - ) case .timeline(let component): let models = try component.items.map { item in var description: TextComponentViewModel? @@ -331,7 +347,7 @@ struct ViewModelFactory { ) } - // swiftlint:disable cyclomatic_complexity function_body_length + // swiftlint:disable cyclomatic_complexity private func findFullWidthImageViewIfItsTheFirst( _ component: PaywallComponent ) -> RootViewModel.FirstImageInfo? { @@ -374,8 +390,6 @@ struct ViewModelFactory { return self.findFullWidthImageViewIfItsTheFirst(first) case .purchaseButton: return nil - case .stickyFooter: - return nil case .timeline: return nil case .tabs(let tabs): diff --git a/Sources/Networking/Responses/RevenueCatUI/PaywallComponentsData.swift b/Sources/Networking/Responses/RevenueCatUI/PaywallComponentsData.swift index 1621aab511..98c625f430 100644 --- a/Sources/Networking/Responses/RevenueCatUI/PaywallComponentsData.swift +++ b/Sources/Networking/Responses/RevenueCatUI/PaywallComponentsData.swift @@ -28,16 +28,19 @@ public struct PaywallComponentsData: Codable, Equatable, Sendable { public struct PaywallComponentsConfig: Codable, Equatable, Sendable { - public var stack: PaywallComponent.StackComponent + public let stack: PaywallComponent.StackComponent + public let navigationBar: PaywallComponent.NavigationBarComponent? public let stickyFooter: PaywallComponent.StickyFooterComponent? - public var background: PaywallComponent.Background + public let background: PaywallComponent.Background public init( stack: PaywallComponent.StackComponent, + navigationBar: PaywallComponent.NavigationBarComponent?, stickyFooter: PaywallComponent.StickyFooterComponent?, background: PaywallComponent.Background ) { self.stack = stack + self.navigationBar = navigationBar self.stickyFooter = stickyFooter self.background = background } @@ -144,6 +147,7 @@ extension PaywallComponentsData { errors["componentsConfig"] = .init(error) componentsConfig = ComponentsConfig(base: PaywallComponentsConfig( stack: .init(components: []), + navigationBar: nil, stickyFooter: nil, background: .color(.init(light: .hex("#ffffff"))) )) diff --git a/Sources/Paywalls/Components/Common/PaywallComponentBase.swift b/Sources/Paywalls/Components/Common/PaywallComponentBase.swift index 941820ec78..2bebb49087 100644 --- a/Sources/Paywalls/Components/Common/PaywallComponentBase.swift +++ b/Sources/Paywalls/Components/Common/PaywallComponentBase.swift @@ -18,7 +18,6 @@ public enum PaywallComponent: PaywallComponentBase { case button(ButtonComponent) case package(PackageComponent) case purchaseButton(PurchaseButtonComponent) - case stickyFooter(StickyFooterComponent) case timeline(TimelineComponent) case tabs(TabsComponent) @@ -35,7 +34,6 @@ public enum PaywallComponent: PaywallComponentBase { case button case package case purchaseButton = "purchase_button" - case stickyFooter = "sticky_footer" case timeline case tabs @@ -89,9 +87,6 @@ extension PaywallComponent: Codable { case .purchaseButton(let component): try container.encode(ComponentType.purchaseButton, forKey: .type) try component.encode(to: encoder) - case .stickyFooter(let component): - try container.encode(ComponentType.stickyFooter, forKey: .type) - try component.encode(to: encoder) case .timeline(let component): try container.encode(ComponentType.timeline, forKey: .type) try component.encode(to: encoder) @@ -174,8 +169,6 @@ extension PaywallComponent: Codable { return .package(try PackageComponent(from: decoder)) case .purchaseButton: return .purchaseButton(try PurchaseButtonComponent(from: decoder)) - case .stickyFooter: - return .stickyFooter(try StickyFooterComponent(from: decoder)) case .timeline: return .timeline(try TimelineComponent(from: decoder)) case .tabs: diff --git a/Sources/Paywalls/Components/PaywallNavigationBarComponent.swift b/Sources/Paywalls/Components/PaywallNavigationBarComponent.swift new file mode 100644 index 0000000000..726e97e065 --- /dev/null +++ b/Sources/Paywalls/Components/PaywallNavigationBarComponent.swift @@ -0,0 +1,44 @@ +// +// 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 +// +// PaywallStickyFooterComponent.swift +// +// Created by Jay Shortway on 24/10/2024. +// +// swiftlint:disable missing_docs + +import Foundation + +public extension PaywallComponent { + + final class NavigationBarComponent: PaywallComponentBase { + + public let leadingStack: PaywallComponent.StackComponent? + public let trailingStack: PaywallComponent.StackComponent? + + public init( + leadingStack: PaywallComponent.StackComponent? = nil, + trailingStack: PaywallComponent.StackComponent? = nil + ) { + self.leadingStack = leadingStack + self.trailingStack = trailingStack + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(leadingStack) + hasher.combine(trailingStack) + } + + public static func == (lhs: NavigationBarComponent, rhs: NavigationBarComponent) -> Bool { + return lhs.leadingStack == rhs.leadingStack && + lhs.trailingStack == rhs.trailingStack + } + } + +} diff --git a/Sources/Paywalls/Components/PaywallV2CacheWarming.swift b/Sources/Paywalls/Components/PaywallV2CacheWarming.swift index aeb15c767d..cefbe1b697 100644 --- a/Sources/Paywalls/Components/PaywallV2CacheWarming.swift +++ b/Sources/Paywalls/Components/PaywallV2CacheWarming.swift @@ -40,7 +40,10 @@ extension PaywallComponentsData.PaywallComponentsConfig { let rootStackImageURLs = self.collectAllImageURLs(in: self.stack) let stickFooterImageURLs = self.stickyFooter.flatMap { self.collectAllImageURLs(in: $0.stack) } ?? [] - return rootStackImageURLs + stickFooterImageURLs + let navBarLeading = self.navigationBar?.leadingStack.flatMap { self.collectAllImageURLs(in: $0) } ?? [] + let navBarTrailing = self.navigationBar?.trailingStack.flatMap { self.collectAllImageURLs(in: $0) } ?? [] + + return rootStackImageURLs + stickFooterImageURLs + navBarLeading + navBarTrailing } // swiftlint:disable:next cyclomatic_complexity @@ -67,10 +70,8 @@ extension PaywallComponentsData.PaywallComponentsConfig { urls += self.collectAllImageURLs(in: package.stack) case .purchaseButton(let purchaseButton): urls += self.collectAllImageURLs(in: purchaseButton.stack) - case .stickyFooter(let stickyFooter): - urls += self.collectAllImageURLs(in: stickyFooter.stack) - case .timeline(let component): - for item in component.items { + case .timeline(let timeline): + for item in timeline.items { urls += item.icon.imageUrls } case .tabs(let tabs):