From 23e96a7c0647d5cc44ddee9664e7cafe88a1bee6 Mon Sep 17 00:00:00 2001 From: James Borthwick <109382862+jamesrb1@users.noreply.github.com> Date: Fri, 5 Jul 2024 12:58:35 -0700 Subject: [PATCH] Paywalls tester with sandbox purchases (#4024) Restore's Paywall Tester's ability to make sandbox purchases. You must include the Paywalls test app's API key in `LocalConfigItems.swift`. If it doesn't work initially try deleting the app from your simulator (or device) and try again. ![image](https://github.com/RevenueCat/purchases-ios/assets/109382862/c3e81929-64f0-4ffc-8570-bc94c57951cf) --- .../PaywallsTester.xcodeproj/project.pbxproj | 8 +- .../UI/Views/AppContentView.swift | 7 + .../OfferingList/APIKeyDashboardList.swift | 199 ++++++++++++++++++ .../UI/Views/PaywallPresenter.swift | 22 +- 4 files changed, 214 insertions(+), 22 deletions(-) create mode 100644 Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/OfferingList/APIKeyDashboardList.swift diff --git a/Tests/TestingApps/PaywallsTester/PaywallsTester.xcodeproj/project.pbxproj b/Tests/TestingApps/PaywallsTester/PaywallsTester.xcodeproj/project.pbxproj index ef7aea52a4..032adc7703 100644 --- a/Tests/TestingApps/PaywallsTester/PaywallsTester.xcodeproj/project.pbxproj +++ b/Tests/TestingApps/PaywallsTester/PaywallsTester.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 4FCA01FB2A3A1CBD00B262C0 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4FCA01FA2A3A1CBD00B262C0 /* StoreKit.framework */; }; 4FDF11202A7270F3004F3680 /* SamplePaywallsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FDF111F2A7270F3004F3680 /* SamplePaywallsList.swift */; }; 880B2AF22BEC2D62006B9393 /* Preprocessor.sh in Resources */ = {isa = PBXBuildFile; fileRef = 880B2AF12BEC2D62006B9393 /* Preprocessor.sh */; }; + 88A543E92C387DB00039C6A5 /* APIKeyDashboardList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88A543E82C387DB00039C6A5 /* APIKeyDashboardList.swift */; }; 88B2F9882BE1943C00B43E0B /* ManagePaywallButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B2F9872BE1943C00B43E0B /* ManagePaywallButton.swift */; }; 88B2F98B2BE19B1200B43E0B /* OfferingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B2F98A2BE19B1200B43E0B /* OfferingButton.swift */; }; 88B2F98D2BE3F1E900B43E0B /* TemplateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B2F98C2BE3F1E900B43E0B /* TemplateInfo.swift */; }; @@ -95,6 +96,7 @@ 4FFD2A602AA154B4001F4B0C /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; 880B2AF12BEC2D62006B9393 /* Preprocessor.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = Preprocessor.sh; sourceTree = SOURCE_ROOT; }; 880B2AF32BEC35AA006B9393 /* Postprocessor.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Postprocessor.sh; sourceTree = SOURCE_ROOT; }; + 88A543E82C387DB00039C6A5 /* APIKeyDashboardList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIKeyDashboardList.swift; sourceTree = ""; }; 88B2F9872BE1943C00B43E0B /* ManagePaywallButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagePaywallButton.swift; sourceTree = ""; }; 88B2F98A2BE19B1200B43E0B /* OfferingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfferingButton.swift; sourceTree = ""; }; 88B2F98C2BE3F1E900B43E0B /* TemplateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateInfo.swift; sourceTree = ""; }; @@ -227,6 +229,7 @@ 88B2F9892BE1944200B43E0B /* OfferingList */ = { isa = PBXGroup; children = ( + 88A543E82C387DB00039C6A5 /* APIKeyDashboardList.swift */, 4FC6F8B12A7403D3002139B2 /* OfferingsList.swift */, 88B2F9872BE1943C00B43E0B /* ManagePaywallButton.swift */, 88B2F98A2BE19B1200B43E0B /* OfferingButton.swift */, @@ -424,6 +427,7 @@ 88BEB9292BF537F200A3D05F /* LocalConfigItems.swift in Sources */, 88DFC1882BCF3AB700273B6D /* LoginScreen.swift in Sources */, 88DFC1892BCF3AB700273B6D /* LoginWall.swift in Sources */, + 88A543E92C387DB00039C6A5 /* APIKeyDashboardList.swift in Sources */, 88BEB9262BF537F200A3D05F /* ConfigItem.swift in Sources */, 88BEB9272BF537F200A3D05F /* Configuration.swift in Sources */, 88DFC1932BCF490400273B6D /* OfferingsResponse.swift in Sources */, @@ -626,7 +630,7 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; OTHER_SWIFT_FLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = com.revenuecat.app; + PRODUCT_BUNDLE_IDENTIFIER = com.revenuecat.PaywallsTester; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator watchsimulator watchos"; @@ -676,7 +680,7 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; OTHER_SWIFT_FLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = com.revenuecat.app; + PRODUCT_BUNDLE_IDENTIFIER = com.revenuecat.PaywallsTester; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator watchsimulator watchos"; diff --git a/Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/AppContentView.swift b/Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/AppContentView.swift index b80a60a9cb..5028c0891d 100644 --- a/Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/AppContentView.swift +++ b/Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/AppContentView.swift @@ -31,6 +31,13 @@ struct AppContentView: View { Label("My Apps", systemImage: "network") } + if Purchases.isConfigured { + APIKeyDashboardList() + .tabItem { + Label("Sandbox Paywalls", systemImage: "testtube.2") + } + } + #if !DEBUG if !Purchases.isConfigured { Text("Purchases is not configured") diff --git a/Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/OfferingList/APIKeyDashboardList.swift b/Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/OfferingList/APIKeyDashboardList.swift new file mode 100644 index 0000000000..22b228adee --- /dev/null +++ b/Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/OfferingList/APIKeyDashboardList.swift @@ -0,0 +1,199 @@ +// +// APIKeyDashboardList.swift +// SimpleApp +// +// Created by Nacho Soto on 7/27/23. +// + +import RevenueCat +#if DEBUG +@testable import RevenueCatUI +#else +import RevenueCatUI +#endif +import SwiftUI + +struct APIKeyDashboardList: View { + + fileprivate struct Template: Hashable { + var name: String? + } + + fileprivate struct Data: Hashable { + var sections: [Template] + var offeringsBySection: [Template: [Offering]] + } + + fileprivate struct PresentedPaywall: Hashable { + var offering: Offering + var mode: PaywallViewMode + } + + @State + private var offerings: Result? + + @State + private var presentedPaywall: PresentedPaywall? + + var body: some View { + NavigationView { + self.content + .navigationTitle("Live Paywalls") + } + .task { + do { + let offerings = try await Purchases.shared.offerings() + .all + .map(\.value) + .sorted { $0.serverDescription > $1.serverDescription } + + let offeringsBySection = Dictionary( + grouping: offerings, + by: { Template(name: $0.paywall?.templateName) } + ) + + self.offerings = .success( + .init( + sections: Array(offeringsBySection.keys).sorted { $0.description < $1.description }, + offeringsBySection: offeringsBySection + ) + ) + } catch let error as NSError { + self.offerings = .failure(error) + } + } + } + + @ViewBuilder + private var content: some View { + switch self.offerings { + case let .success(data): + VStack { + Text(Self.modesInstructions) + .font(.footnote) + self.list(with: data) + } + + case let .failure(error): + Text(error.description) + + case .none: + SwiftUI.ProgressView() + } + } + + @ViewBuilder + private func list(with data: Data) -> some View { + List { + ForEach(data.sections, id: \.self) { template in + Section { + ForEach(data.offeringsBySection[template]!, id: \.id) { offering in + if let paywall = offering.paywall { + #if targetEnvironment(macCatalyst) + NavigationLink( + destination: PaywallPresenter(offering: offering, + mode: .default, + displayCloseButton: false), + tag: PresentedPaywall(offering: offering, mode: .default), + selection: self.$presentedPaywall + ) { + OfferButton(offering: offering, paywall: paywall) {} + .contextMenu { + self.contextMenu(for: offering) + } + } + #else + OfferButton(offering: offering, paywall: paywall) { + self.presentedPaywall = .init(offering: offering, mode: .default) + } + #if !os(watchOS) + .contextMenu { + self.contextMenu(for: offering) + } + #endif + #endif + } else { + Text(offering.serverDescription) + } + } + } header: { + Text(verbatim: template.description) + } + } + } + .sheet(item: self.$presentedPaywall) { paywall in + PaywallPresenter(offering: paywall.offering, mode: paywall.mode, introEligility: .eligible) + .onRestoreCompleted { _ in + self.presentedPaywall = nil + } + } + } + + #if !os(watchOS) + @ViewBuilder + private func contextMenu(for offering: Offering) -> some View { + ForEach(PaywallViewMode.allCases, id: \.self) { mode in + self.button(for: mode, offering: offering) + } + } + #endif + + @ViewBuilder + private func button(for selectedMode: PaywallViewMode, offering: Offering) -> some View { + Button { + self.presentedPaywall = .init(offering: offering, mode: selectedMode) + } label: { + Text(selectedMode.name) + Image(systemName: selectedMode.icon) + } + } + + private struct OfferButton: View { + let offering: Offering + let paywall: PaywallData + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(self.offering.serverDescription) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + } + } + + #if targetEnvironment(macCatalyst) + private static let modesInstructions = "Right click or ⌘ + click to open in different modes." + #else + private static let modesInstructions = "Press and hold to open in different modes." + #endif + +} + +extension APIKeyDashboardList.Template: CustomStringConvertible { + + var description: String { + if let name = self.name { + #if DEBUG + if let template = PaywallTemplate(rawValue: name) { + return template.name + } else { + return "Unrecognized template" + } + #else + return name + #endif + } else { + return "No paywall" + } + } + +} + +extension APIKeyDashboardList.PresentedPaywall: Identifiable { + + var id: String { + return "\(self.offering.id)-\(self.mode.name)" + } + +} diff --git a/Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/PaywallPresenter.swift b/Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/PaywallPresenter.swift index f6d3e08277..3bcc00b6ca 100644 --- a/Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/PaywallPresenter.swift +++ b/Tests/TestingApps/PaywallsTester/PaywallsTester/UI/Views/PaywallPresenter.swift @@ -20,23 +20,7 @@ struct PaywallPresenter: View { switch self.mode { case .fullScreen: - let handler = PurchaseHandler.default( - performPurchase: { package in - var userCancelled = false - var error: Error? - - // do stuff - - return (userCancelled: userCancelled, error: error) - - }, performRestore: { - var success = false - var error: Error? - - // do stuff - - return (success: success, error: error) - }) + let handler = PurchaseHandler.default() let configuration = PaywallViewConfiguration( offering: offering, @@ -48,8 +32,6 @@ struct PaywallPresenter: View { PaywallView(configuration: configuration) - - #if !os(watchOS) case .footer: CustomPaywallContent() @@ -64,7 +46,7 @@ struct PaywallPresenter: View { customerInfo: nil, condensed: true, introEligibility: .producing(eligibility: introEligility), - purchaseHandler: .default()) + purchaseHandler: .default()) #endif } }