using loal swift paywall

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
Matt Bruce 2026-02-03 16:11:12 -06:00
parent d62cc6e88e
commit c153b16593
5 changed files with 281 additions and 263 deletions

View File

@ -10,5 +10,11 @@ enum Secrets {
/// RevenueCat API Key
/// - Debug: Use your test/sandbox API key (starts with "test_")
/// - Release: Use your production API key (starts with "appl_")
static let revenueCatAPIKey = "YOUR_REVENUECAT_API_KEY_HERE"
static let revenueCatAPIKey: String = {
#if DEBUG
return "test_YOUR_TEST_KEY_HERE"
#else
return "appl_YOUR_PRODUCTION_KEY_HERE"
#endif
}()
}

View File

@ -22,51 +22,153 @@ struct ProPaywallView: View {
/// Whether a restore is in progress
@State private var isRestoring = false
/// Currently selected package
@State private var selectedPackage: Package?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Design.Spacing.xLarge) {
// Crown icon
header
benefitsCard
packageSelection
purchaseCTA
restoreButton
}
.padding(Design.Spacing.large)
}
.background(AppSurface.overlay)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "Cancel")) { dismiss() }
.foregroundStyle(.white)
.disabled(isPurchasing || isRestoring)
}
}
}
.font(.system(size: bodyFontSize))
.task {
try? await manager.loadProducts()
if selectedPackage == nil {
selectedPackage = preferredPackage(from: manager.availablePackages)
}
}
.onChange(of: manager.availablePackages) { _, newValue in
if selectedPackage == nil {
selectedPackage = preferredPackage(from: newValue)
}
}
.alert(
String(localized: "Purchase Error"),
isPresented: $showError,
presenting: errorMessage
) { _ in
Button(String(localized: "OK"), role: .cancel) {
errorMessage = nil
}
} message: { message in
Text(message)
}
}
// MARK: - Sections
private var header: some View {
VStack(spacing: Design.Spacing.medium) {
ZStack {
Circle()
.fill(AppAccent.primary.opacity(Design.Opacity.subtle))
.frame(width: 72, height: 72)
Image(systemName: "crown.fill")
.font(.system(size: Design.FontSize.hero))
.foregroundStyle(.yellow)
.font(.system(size: Design.FontSize.large, weight: .bold))
.foregroundStyle(AppStatus.warning)
}
Text(String(localized: "Go Pro"))
Text(String(localized: "Unlock SelfieCam Pro"))
.font(.system(size: Design.FontSize.title, weight: .bold))
.foregroundStyle(.white)
// Benefits list
Text(String(localized: "Premium tools for better selfies"))
.font(.system(size: Design.FontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.frame(maxWidth: .infinity)
}
private var benefitsCard: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
BenefitRow(image: "paintpalette.fill", text: String(localized: "Premium Colors + Custom Color Picker"))
BenefitRow(image: "sparkles", text: String(localized: "Skin Smoothing Beauty Filter"))
BenefitRow(image: "arrow.left.and.right.righttriangle.left.righttriangle.right.fill", text: String(localized: "True Mirror Mode"))
BenefitRow(image: "bolt.fill", text: String(localized: "Flash Sync with Ring Light"))
BenefitRow(image: "camera.filters", text: String(localized: "HDR Mode for Better Photos"))
BenefitRow(image: "person.crop.rectangle.fill", text: String(localized: "Center Stage Auto-Framing"))
BenefitRow(image: "timer", text: String(localized: "Extended Self-Timers (5s, 10s)"))
BenefitRow(image: "star.fill", text: String(localized: "High Quality Photo Export"))
}
.padding(Design.Spacing.large)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
)
}
// Product packages
private var packageSelection: some View {
VStack(spacing: Design.Spacing.medium) {
if manager.availablePackages.isEmpty {
ProgressView()
.padding()
} else {
ForEach(manager.availablePackages, id: \.identifier) { package in
ProductPackageButton(
PackageOptionRow(
package: package,
isPremiumUnlocked: manager.isPremiumUnlocked,
isLoading: isPurchasing,
onPurchase: {
Task {
await purchasePackage(package)
}
}
isSelected: selectedPackage?.identifier == package.identifier,
isDisabled: isPurchasing,
onSelect: { selectedPackage = package }
)
}
}
}
}
// Restore purchases
private var purchaseCTA: some View {
VStack(spacing: Design.Spacing.small) {
Button {
guard let selectedPackage else {
errorMessage = String(localized: "Please select a plan.")
showError = true
return
}
Task {
await purchasePackage(selectedPackage)
}
} label: {
HStack(spacing: Design.Spacing.small) {
if isPurchasing {
ProgressView()
.tint(.white)
}
Text(String(localized: "Continue"))
.font(.headline)
.foregroundStyle(.white)
}
.frame(maxWidth: .infinity)
.padding(Design.Spacing.large)
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
.disabled(isPurchasing || isRestoring || selectedPackage == nil)
Text(String(localized: "Cancel anytime. Payment will be charged to your Apple ID."))
.font(.caption)
.foregroundStyle(.white.opacity(Design.Opacity.medium))
.multilineTextAlignment(.center)
}
}
private var restoreButton: some View {
Button {
Task {
await restorePurchases()
@ -83,32 +185,6 @@ struct ProPaywallView: View {
.foregroundStyle(.secondary)
.disabled(isRestoring || isPurchasing)
}
.padding(Design.Spacing.large)
}
.background(AppSurface.overlay)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "Cancel")) { dismiss() }
.foregroundStyle(.white)
.disabled(isPurchasing || isRestoring)
}
}
}
.font(.system(size: bodyFontSize))
.task { try? await manager.loadProducts() }
.alert(
String(localized: "Purchase Error"),
isPresented: $showError,
presenting: errorMessage
) { _ in
Button(String(localized: "OK"), role: .cancel) {
errorMessage = nil
}
} message: { message in
Text(message)
}
}
// MARK: - Purchase Logic
@ -159,15 +235,27 @@ struct ProPaywallView: View {
showError = true
}
}
// MARK: - Helpers
private func preferredPackage(from packages: [Package]) -> Package? {
if let annual = packages.first(where: { $0.packageType == .annual }) {
return annual
}
if let lifetime = packages.first(where: { $0.packageType == .lifetime }) {
return lifetime
}
return packages.first
}
}
// MARK: - Product Package Button
// MARK: - Package Option Row
private struct ProductPackageButton: View {
private struct PackageOptionRow: View {
let package: Package
let isPremiumUnlocked: Bool
let isLoading: Bool
let onPurchase: () -> Void
let isSelected: Bool
let isDisabled: Bool
let onSelect: () -> Void
private var isLifetime: Bool {
package.packageType == .lifetime
@ -177,63 +265,78 @@ private struct ProductPackageButton: View {
package.packageType == .annual
}
/// Background color based on package type
private var backgroundColor: Color {
isLifetime ? AppStatus.warning.opacity(Design.Opacity.medium) : AppAccent.primary.opacity(Design.Opacity.medium)
}
/// Border color based on package type
private var borderColor: Color {
isLifetime ? AppStatus.warning : AppAccent.primary
}
var body: some View {
Button(action: onPurchase) {
VStack(spacing: Design.Spacing.small) {
// Badge for lifetime
if isLifetime {
Text(String(localized: "ONE TIME"))
.font(.caption2.bold())
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xxSmall)
.background(AppStatus.warning)
.clipShape(Capsule())
}
Button(action: onSelect) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(isSelected ? AppAccent.primary : .white.opacity(Design.Opacity.medium))
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.small) {
Text(package.storeProduct.localizedTitle)
.font(.headline)
.foregroundStyle(.white)
Text(package.localizedPriceString)
.font(.title2.bold())
.foregroundStyle(.white)
if isAnnual {
Text(String(localized: "Best Value"))
.font(.caption2.bold())
.foregroundStyle(AppStatus.warning)
.padding(.horizontal, Design.Spacing.xSmall)
.padding(.vertical, Design.Spacing.xxSmall)
.background(AppStatus.warning.opacity(Design.Opacity.subtle))
.clipShape(Capsule())
} else if isLifetime {
Text(String(localized: "One Time"))
.font(.caption2.bold())
.foregroundStyle(AppAccent.primary)
.padding(.horizontal, Design.Spacing.xSmall)
.padding(.vertical, Design.Spacing.xxSmall)
.background(AppAccent.primary.opacity(Design.Opacity.subtle))
.clipShape(Capsule())
}
}
// Subtitle based on type
if isLifetime {
Text(String(localized: "Pay once, own forever"))
Text(packageSubtitle)
.font(.caption)
.foregroundStyle(.white.opacity(Design.Opacity.accent))
} else if isAnnual {
Text(String(localized: "Best Value • Save 33%"))
.font(.caption)
.foregroundStyle(.white.opacity(Design.Opacity.accent))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Spacer()
Text(package.localizedPriceString)
.font(.title3.bold())
.foregroundStyle(.white)
}
.frame(maxWidth: .infinity)
.padding(Design.Spacing.large)
.background(backgroundColor)
.frame(maxWidth: .infinity)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(borderColor, lineWidth: Design.LineWidth.thin)
.strokeBorder(isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: Design.LineWidth.thin)
)
.opacity(isLoading ? 0.6 : 1.0)
.opacity(isDisabled ? 0.6 : 1.0)
}
.disabled(isLoading)
.accessibilityLabel(isLifetime
? String(localized: "Purchase \(package.storeProduct.localizedTitle) for \(package.localizedPriceString), one-time payment")
: String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)"))
.disabled(isDisabled)
.accessibilityLabel(accessibilityLabel)
}
private var packageSubtitle: String {
if isLifetime {
return String(localized: "Pay once, own forever")
}
if isAnnual {
return String(localized: "Best value yearly plan")
}
return String(localized: "Billed monthly")
}
private var accessibilityLabel: String {
if isLifetime {
return String(localized: "Select \(package.storeProduct.localizedTitle) for \(package.localizedPriceString), one-time payment")
}
return String(localized: "Select \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)")
}
}
@ -245,10 +348,14 @@ struct BenefitRow: View {
var body: some View {
HStack(spacing: Design.Spacing.medium) {
ZStack {
Circle()
.fill(AppAccent.primary.opacity(Design.Opacity.subtle))
.frame(width: 32, height: 32)
Image(systemName: image)
.font(.title2)
.font(.body.bold())
.foregroundStyle(AppAccent.primary)
.frame(width: Design.IconSize.xLarge)
}
Text(text)
.foregroundStyle(.white)

View File

@ -498,8 +498,13 @@
"comment" : "Subtitle of a premium feature card that offers skin smoothing.",
"isCommentAutoGenerated" : true
},
"Best Value" : {
"comment" : "A label indicating that a subscription is the best value for the user.",
"isCommentAutoGenerated" : true
},
"Best Value • Save 33%" : {
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"es-MX" : {
@ -522,9 +527,16 @@
}
}
},
"Best value yearly plan" : {
"comment" : "Subtitle for a package option row when the plan is billed annually.",
"isCommentAutoGenerated" : true
},
"Better lighting" : {
"comment" : "Subtitle for a premium feature card that allows users to take photos in better lighting.",
"isCommentAutoGenerated" : true
},
"Billed monthly" : {
},
"Boomerang" : {
"comment" : "Display name for the \"Boomerang\" capture mode.",
@ -653,6 +665,10 @@
}
}
},
"Cancel anytime. Payment will be charged to your Apple ID." : {
"comment" : "A footer text displayed below the \"Continue\" button in the Pro paywall.",
"isCommentAutoGenerated" : true
},
"Captured photo" : {
"comment" : "A label describing a captured photo.",
"extractionState" : "stale",
@ -751,6 +767,7 @@
},
"Center Stage Auto-Framing" : {
"comment" : "Benefit of the \"Go Pro\" premium package: Automatic centering of the subject in the photo.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"es-MX" : {
@ -1293,6 +1310,7 @@
},
"Flash Sync with Ring Light" : {
"comment" : "Benefit description for the \"Flash Sync with Ring Light\" feature.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"es-MX" : {
@ -1426,6 +1444,7 @@
},
"Go Pro" : {
"comment" : "The title of the \"Go Pro\" button in the Pro paywall.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"es-MX" : {
@ -1696,10 +1715,6 @@
}
}
},
"Loading..." : {
"comment" : "A placeholder text displayed while waiting for content to load.",
"isCommentAutoGenerated" : true
},
"Locked. Tap to unlock with Pro." : {
"comment" : "A hint that appears when a user taps on a color preset button.",
"isCommentAutoGenerated" : true,
@ -1801,8 +1816,8 @@
"comment" : "A message displayed in an alert when the \"Reset Onboarding\" button is tapped.",
"isCommentAutoGenerated" : true
},
"ONE TIME" : {
"comment" : "A label for a badge indicating a one-time purchase.",
"One Time" : {
"comment" : "A description of a one-time purchase option.",
"isCommentAutoGenerated" : true
},
"Open Settings" : {
@ -1951,6 +1966,10 @@
}
}
},
"Please select a plan." : {
"comment" : "Error message displayed when the user tries to purchase a package but hasn't selected one.",
"isCommentAutoGenerated" : true
},
"Premium color" : {
"comment" : "An accessibility hint for a premium color option in the color preset button.",
"isCommentAutoGenerated" : true,
@ -2022,6 +2041,9 @@
}
}
}
},
"Premium tools for better selfies" : {
},
"Pro Active" : {
"comment" : "A label indicating that the user has an active Pro subscription.",
@ -2035,18 +2057,6 @@
"comment" : "An accessibility label for the section of the settings view that displays that their Pro subscription is active.",
"isCommentAutoGenerated" : true
},
"Purchase %@ for %@, one-time payment" : {
"comment" : "An accessibility label for a button that purchases a product package. The label includes the product name and price, with a note that it's a one-time payment if the package is a lifetime subscription.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Purchase %1$@ for %2$@, one-time payment"
}
}
}
},
"Purchase Error" : {
"comment" : "The title of an alert that appears when there is an error during a purchase process.",
"isCommentAutoGenerated" : true
@ -2498,6 +2508,28 @@
}
}
},
"Select %@ for %@" : {
"comment" : "Accessibility label for a row in the \"Choose a package\" sheet. The first argument is the localized title of the package. The second argument is the localized price string of the package.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Select %1$@ for %2$@"
}
}
}
},
"Select %@ for %@, one-time payment" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Select %1$@ for %2$@, one-time payment"
}
}
}
},
"Select camera position" : {
"comment" : "A label describing the action of selecting a camera position.",
"isCommentAutoGenerated" : true,
@ -2938,6 +2970,7 @@
},
"Subscribe to %@ for %@" : {
"comment" : "A button that triggers a purchase of a premium content package. The label text is generated based on the package's title and price.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
@ -3300,6 +3333,10 @@
"comment" : "Title of a section in the onboarding soft paywall that describes the benefits of upgrading to a premium account.",
"isCommentAutoGenerated" : true
},
"Unlock SelfieCam Pro" : {
"comment" : "The title of the paywall view.",
"isCommentAutoGenerated" : true
},
"Upgrade to Pro" : {
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
"isCommentAutoGenerated" : true,

View File

@ -7,11 +7,9 @@
//
import SwiftUI
import RevenueCat
import RevenueCatUI
import Bedrock
/// Presents RevenueCat Paywall with fallback to custom paywall.
/// Presents the custom SelfieCam paywall.
///
/// Usage:
/// ```swift
@ -23,21 +21,10 @@ import Bedrock
/// }
/// ```
///
/// The presenter will:
/// 1. Attempt to load the RevenueCat paywall from your configured offering
/// 2. If successful, display the native RevenueCat PaywallView
/// 3. If it fails (network error, no paywall configured), fall back to ProPaywallView
struct PaywallPresenter: View {
/// Callback triggered when a purchase or restore is successful
var onPurchaseSuccess: (() -> Void)?
@Environment(\.dismiss) private var dismiss
@State private var offering: Offering?
@State private var isLoading = true
@State private var useFallback = false
@State private var errorMessage: String?
@State private var showError = false
/// Creates a PaywallPresenter with an optional success callback
/// - Parameter onPurchaseSuccess: Called when purchase or restore completes successfully
init(onPurchaseSuccess: (() -> Void)? = nil) {
@ -45,126 +32,7 @@ struct PaywallPresenter: View {
}
var body: some View {
Group {
if isLoading {
// Loading state while fetching offerings
loadingView
} else if useFallback {
// Fallback to custom paywall on error
ProPaywallView(onPurchaseSuccess: onPurchaseSuccess)
} else if let offering {
// RevenueCat native paywall
paywallView(for: offering)
} else {
// No offering available, use fallback
ProPaywallView(onPurchaseSuccess: onPurchaseSuccess)
}
}
.task {
await loadOffering()
}
.alert(
String(localized: "Purchase Error"),
isPresented: $showError,
presenting: errorMessage
) { _ in
Button(String(localized: "OK"), role: .cancel) {
errorMessage = nil
}
} message: { message in
Text(message)
}
}
// MARK: - Loading View
private var loadingView: some View {
VStack {
ProgressView()
.scaleEffect(1.5)
.tint(.white)
Text(String(localized: "Loading..."))
.font(.system(size: Design.FontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
.padding(.top, Design.Spacing.medium)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppSurface.primary)
}
// MARK: - Paywall View
private func paywallView(for offering: Offering) -> some View {
PaywallView(offering: offering)
.onPurchaseCompleted { _ in
#if DEBUG
print("✅ [PaywallPresenter] Purchase completed")
#endif
onPurchaseSuccess?()
dismiss()
}
.onPurchaseCancelled {
#if DEBUG
print(" [PaywallPresenter] Purchase cancelled by user")
#endif
// User cancelled - paywall stays open, no action needed
}
.onPurchaseFailure { error in
#if DEBUG
print("❌ [PaywallPresenter] Purchase failed: \(error.localizedDescription)")
#endif
errorMessage = error.localizedDescription
showError = true
}
.onRestoreCompleted { customerInfo in
#if DEBUG
print("✅ [PaywallPresenter] Restore completed")
#endif
// Only call success if user actually has entitlements after restore
if !customerInfo.entitlements.active.isEmpty {
onPurchaseSuccess?()
dismiss()
}
}
.onRestoreFailure { error in
#if DEBUG
print("❌ [PaywallPresenter] Restore failed: \(error.localizedDescription)")
#endif
errorMessage = error.localizedDescription
showError = true
}
}
// MARK: - Load Offering
private func loadOffering() async {
do {
let offerings = try await Purchases.shared.offerings()
// Check if current offering has a paywall configured
if let current = offerings.current {
offering = current
isLoading = false
#if DEBUG
print("✅ [PaywallPresenter] Loaded offering: \(current.identifier)")
#endif
} else {
// No current offering, use fallback
#if DEBUG
print("⚠️ [PaywallPresenter] No current offering available, using fallback")
#endif
useFallback = true
isLoading = false
}
} catch {
#if DEBUG
print("⚠️ [PaywallPresenter] Failed to load offerings: \(error)")
#endif
useFallback = true
isLoading = false
}
}
}