Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-03 14:51:54 -06:00
parent 3c7f8a39db
commit a94404d93d
12 changed files with 224 additions and 34 deletions

1
.gitignore vendored
View File

@ -25,6 +25,7 @@ Pods/
Secrets.xcconfig
Secrets.debug.xcconfig
Secrets.release.xcconfig
**/Secrets.swift
# OS generated files
.DS_Store

View File

@ -78,7 +78,7 @@
<EnvironmentVariable
key = "ENABLE_DEBUG_PREMIUM"
value = "1"
isEnabled = "YES">
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>

View File

@ -44,7 +44,15 @@ struct RootView: View {
}
}
.sheet(isPresented: $showPaywall) {
PaywallPresenter()
PaywallPresenter {
// Purchase successful during onboarding - complete it
if !hasCompletedOnboarding {
onboardingViewModel.completeOnboarding(settingsViewModel: settingsViewModel)
withAnimation(.easeInOut(duration: Design.Animation.standard)) {
hasCompletedOnboarding = true
}
}
}
}
}
}

View File

@ -2,11 +2,3 @@
// Configuration for Debug builds
#include "Base.xcconfig"
#include? "Secrets.debug.xcconfig"
// If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values
// CI/CD should set these via environment variables
REVENUECAT_API_KEY = $(REVENUECAT_API_KEY)
// Expose the API key to Info.plist
REVENUECAT_API_KEY_PLIST = $(REVENUECAT_API_KEY)

View File

@ -2,11 +2,3 @@
// Configuration for Release builds
#include "Base.xcconfig"
#include? "Secrets.release.xcconfig"
// If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values
// CI/CD should set these via environment variables
REVENUECAT_API_KEY = $(REVENUECAT_API_KEY)
// Expose the API key to Info.plist
REVENUECAT_API_KEY_PLIST = $(REVENUECAT_API_KEY)

View File

@ -0,0 +1,14 @@
// Secrets.swift
// ⚠️ DO NOT COMMIT THE ACTUAL Secrets.swift FILE TO VERSION CONTROL
//
// Copy this file to Secrets.swift and fill in your actual values.
// Secrets.swift is gitignored.
import Foundation
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"
}

View File

@ -114,6 +114,8 @@ struct ContentView: View {
}
.sheet(isPresented: $showPaywall) {
PaywallPresenter()
// No callback needed - paywall auto-dismisses on success
// and premium status updates automatically via PremiumManager
}
}

View File

@ -3,10 +3,25 @@ import RevenueCat
import Bedrock
struct ProPaywallView: View {
/// Callback triggered when a purchase or restore is successful
var onPurchaseSuccess: (() -> Void)?
@State private var manager = PremiumManager()
@Environment(\.dismiss) private var dismiss
@ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = Design.FontSize.body
/// Error message to display
@State private var errorMessage: String?
/// Whether to show the error alert
@State private var showError = false
/// Whether a purchase is in progress
@State private var isPurchasing = false
/// Whether a restore is in progress
@State private var isRestoring = false
var body: some View {
NavigationStack {
ScrollView {
@ -41,12 +56,10 @@ struct ProPaywallView: View {
ProductPackageButton(
package: package,
isPremiumUnlocked: manager.isPremiumUnlocked,
isLoading: isPurchasing,
onPurchase: {
Task {
_ = try? await manager.purchase(package)
if manager.isPremiumUnlocked {
dismiss()
}
await purchasePackage(package)
}
}
)
@ -54,11 +67,21 @@ struct ProPaywallView: View {
}
// Restore purchases
Button(String(localized: "Restore Purchases")) {
Task { try? await manager.restorePurchases() }
Button {
Task {
await restorePurchases()
}
} label: {
if isRestoring {
ProgressView()
.tint(.secondary)
} else {
Text(String(localized: "Restore Purchases"))
}
}
.font(.footnote)
.foregroundStyle(.secondary)
.disabled(isRestoring || isPurchasing)
}
.padding(Design.Spacing.large)
}
@ -68,11 +91,73 @@ struct ProPaywallView: View {
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
private func purchasePackage(_ package: Package) async {
isPurchasing = true
defer { isPurchasing = false }
do {
let success = try await manager.purchase(package)
if success {
#if DEBUG
print("✅ [ProPaywallView] Purchase completed")
#endif
onPurchaseSuccess?()
dismiss()
}
} catch {
#if DEBUG
print("❌ [ProPaywallView] Purchase failed: \(error.localizedDescription)")
#endif
// Check if user cancelled (don't show error for cancellation)
let nsError = error as NSError
if nsError.code != 2 { // SKError.paymentCancelled = 2
errorMessage = error.localizedDescription
showError = true
}
}
}
private func restorePurchases() async {
isRestoring = true
defer { isRestoring = false }
do {
try await manager.restorePurchases()
if manager.isPremiumUnlocked {
#if DEBUG
print("✅ [ProPaywallView] Restore completed - premium unlocked")
#endif
onPurchaseSuccess?()
dismiss()
}
} catch {
#if DEBUG
print("❌ [ProPaywallView] Restore failed: \(error.localizedDescription)")
#endif
errorMessage = error.localizedDescription
showError = true
}
}
}
@ -81,6 +166,7 @@ struct ProPaywallView: View {
private struct ProductPackageButton: View {
let package: Package
let isPremiumUnlocked: Bool
let isLoading: Bool
let onPurchase: () -> Void
private var isLifetime: Bool {
@ -142,7 +228,9 @@ private struct ProductPackageButton: View {
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(borderColor, lineWidth: Design.LineWidth.thin)
)
.opacity(isLoading ? 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)"))

View File

@ -1696,6 +1696,10 @@
}
}
},
"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,
@ -1720,6 +1724,10 @@
}
}
},
"Manage Subscription" : {
"comment" : "A button label that, when tapped, allows a user to manage their subscription.",
"isCommentAutoGenerated" : true
},
"Maybe Later" : {
"comment" : "Text for a button that allows a user to dismiss a paywall without purchasing a premium subscription.",
"isCommentAutoGenerated" : true
@ -1793,6 +1801,10 @@
"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.",
"isCommentAutoGenerated" : true
},
"Open Settings" : {
"comment" : "Text for an action button that opens the user's device settings to grant a denied permission.",
"isCommentAutoGenerated" : true
@ -1845,6 +1857,10 @@
}
}
},
"Pay once, own forever" : {
"comment" : "A subtitle displayed below a lifetime product package button, describing the permanence of the purchase.",
"isCommentAutoGenerated" : true
},
"Perfect lighting for every shot" : {
"comment" : "Subtitle for a feature highlight that emphasizes the app's ability to provide perfect lighting for every selfie.",
"isCommentAutoGenerated" : true
@ -2007,10 +2023,34 @@
}
}
},
"Pro Active" : {
"comment" : "A label indicating that the user has an active Pro subscription.",
"isCommentAutoGenerated" : true
},
"Pro Features" : {
"comment" : "Subtitle for a feature row in the \"Welcome\" view, highlighting the app's advanced features.",
"isCommentAutoGenerated" : true
},
"Pro subscription active" : {
"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
},
"Purchase successful! Pro features unlocked." : {
"comment" : "Announcement read out to the user when a premium purchase is successful.",
"isCommentAutoGenerated" : true,
@ -3122,6 +3162,10 @@
}
}
},
"Tap Manage Subscription to view or cancel" : {
"comment" : "A hint that appears when the user interacts with the \"Pro Active\" view, instructing them to tap the \"Manage Subscription\" button to view or cancel their subscription.",
"isCommentAutoGenerated" : true
},
"Tap to collapse settings" : {
"extractionState" : "stale",
"localizations" : {

View File

@ -16,7 +16,10 @@ import Bedrock
/// Usage:
/// ```swift
/// .sheet(isPresented: $showPaywall) {
/// PaywallPresenter()
/// PaywallPresenter {
/// // Called on successful purchase or restore
/// print("Purchase successful!")
/// }
/// }
/// ```
///
@ -25,10 +28,21 @@ import Bedrock
/// 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) {
self.onPurchaseSuccess = onPurchaseSuccess
}
var body: some View {
Group {
@ -37,18 +51,29 @@ struct PaywallPresenter: View {
loadingView
} else if useFallback {
// Fallback to custom paywall on error
ProPaywallView()
ProPaywallView(onPurchaseSuccess: onPurchaseSuccess)
} else if let offering {
// RevenueCat native paywall
paywallView(for: offering)
} else {
// No offering available, use fallback
ProPaywallView()
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
@ -72,19 +97,44 @@ struct PaywallPresenter: View {
private func paywallView(for offering: Offering) -> some View {
PaywallView(offering: offering)
.onPurchaseCompleted { customerInfo in
.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

View File

@ -14,13 +14,12 @@ final class PremiumManager: PremiumManaging {
/// Task for listening to customer info updates
@ObservationIgnored private var customerInfoTask: Task<Void, Never>?
/// Reads the RevenueCat API key from Info.plist (injected at build time from Secrets.xcconfig)
/// Reads the RevenueCat API key from the Secrets configuration file
private static var apiKey: String {
guard let key = Bundle.main.object(forInfoDictionaryKey: "RevenueCatAPIKey") as? String,
!key.isEmpty,
key != "your_revenuecat_public_api_key_here" else {
let key = Secrets.revenueCatAPIKey
guard !key.isEmpty, key != "YOUR_REVENUECAT_API_KEY_HERE" else {
#if DEBUG
print("⚠️ [PremiumManager] RevenueCat API key not configured. See Configuration/Secrets.xcconfig.template")
print("⚠️ [PremiumManager] RevenueCat API key not configured. See Configuration/Secrets.swift.template")
#endif
return ""
}