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.xcconfig
Secrets.debug.xcconfig Secrets.debug.xcconfig
Secrets.release.xcconfig Secrets.release.xcconfig
**/Secrets.swift
# OS generated files # OS generated files
.DS_Store .DS_Store

View File

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

View File

@ -44,7 +44,15 @@ struct RootView: View {
} }
} }
.sheet(isPresented: $showPaywall) { .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 // Configuration for Debug builds
#include "Base.xcconfig" #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 // Configuration for Release builds
#include "Base.xcconfig" #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) { .sheet(isPresented: $showPaywall) {
PaywallPresenter() 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 import Bedrock
struct ProPaywallView: View { struct ProPaywallView: View {
/// Callback triggered when a purchase or restore is successful
var onPurchaseSuccess: (() -> Void)?
@State private var manager = PremiumManager() @State private var manager = PremiumManager()
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = Design.FontSize.body @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 { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
@ -41,12 +56,10 @@ struct ProPaywallView: View {
ProductPackageButton( ProductPackageButton(
package: package, package: package,
isPremiumUnlocked: manager.isPremiumUnlocked, isPremiumUnlocked: manager.isPremiumUnlocked,
isLoading: isPurchasing,
onPurchase: { onPurchase: {
Task { Task {
_ = try? await manager.purchase(package) await purchasePackage(package)
if manager.isPremiumUnlocked {
dismiss()
}
} }
} }
) )
@ -54,11 +67,21 @@ struct ProPaywallView: View {
} }
// Restore purchases // Restore purchases
Button(String(localized: "Restore Purchases")) { Button {
Task { try? await manager.restorePurchases() } Task {
await restorePurchases()
}
} label: {
if isRestoring {
ProgressView()
.tint(.secondary)
} else {
Text(String(localized: "Restore Purchases"))
}
} }
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.disabled(isRestoring || isPurchasing)
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
} }
@ -68,11 +91,73 @@ struct ProPaywallView: View {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "Cancel")) { dismiss() } Button(String(localized: "Cancel")) { dismiss() }
.foregroundStyle(.white) .foregroundStyle(.white)
.disabled(isPurchasing || isRestoring)
} }
} }
} }
.font(.system(size: bodyFontSize)) .font(.system(size: bodyFontSize))
.task { try? await manager.loadProducts() } .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 { private struct ProductPackageButton: View {
let package: Package let package: Package
let isPremiumUnlocked: Bool let isPremiumUnlocked: Bool
let isLoading: Bool
let onPurchase: () -> Void let onPurchase: () -> Void
private var isLifetime: Bool { private var isLifetime: Bool {
@ -142,7 +228,9 @@ private struct ProductPackageButton: View {
RoundedRectangle(cornerRadius: Design.CornerRadius.large) RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(borderColor, lineWidth: Design.LineWidth.thin) .strokeBorder(borderColor, lineWidth: Design.LineWidth.thin)
) )
.opacity(isLoading ? 0.6 : 1.0)
} }
.disabled(isLoading)
.accessibilityLabel(isLifetime .accessibilityLabel(isLifetime
? String(localized: "Purchase \(package.storeProduct.localizedTitle) for \(package.localizedPriceString), one-time payment") ? String(localized: "Purchase \(package.storeProduct.localizedTitle) for \(package.localizedPriceString), one-time payment")
: String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)")) : 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." : { "Locked. Tap to unlock with Pro." : {
"comment" : "A hint that appears when a user taps on a color preset button.", "comment" : "A hint that appears when a user taps on a color preset button.",
"isCommentAutoGenerated" : true, "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" : { "Maybe Later" : {
"comment" : "Text for a button that allows a user to dismiss a paywall without purchasing a premium subscription.", "comment" : "Text for a button that allows a user to dismiss a paywall without purchasing a premium subscription.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -1793,6 +1801,10 @@
"comment" : "A message displayed in an alert when the \"Reset Onboarding\" button is tapped.", "comment" : "A message displayed in an alert when the \"Reset Onboarding\" button is tapped.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"ONE TIME" : {
"comment" : "A label for a badge indicating a one-time purchase.",
"isCommentAutoGenerated" : true
},
"Open Settings" : { "Open Settings" : {
"comment" : "Text for an action button that opens the user's device settings to grant a denied permission.", "comment" : "Text for an action button that opens the user's device settings to grant a denied permission.",
"isCommentAutoGenerated" : true "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" : { "Perfect lighting for every shot" : {
"comment" : "Subtitle for a feature highlight that emphasizes the app's ability to provide perfect lighting for every selfie.", "comment" : "Subtitle for a feature highlight that emphasizes the app's ability to provide perfect lighting for every selfie.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -2007,10 +2023,34 @@
} }
} }
}, },
"Pro Active" : {
"comment" : "A label indicating that the user has an active Pro subscription.",
"isCommentAutoGenerated" : true
},
"Pro Features" : { "Pro Features" : {
"comment" : "Subtitle for a feature row in the \"Welcome\" view, highlighting the app's advanced features.", "comment" : "Subtitle for a feature row in the \"Welcome\" view, highlighting the app's advanced features.",
"isCommentAutoGenerated" : true "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." : { "Purchase successful! Pro features unlocked." : {
"comment" : "Announcement read out to the user when a premium purchase is successful.", "comment" : "Announcement read out to the user when a premium purchase is successful.",
"isCommentAutoGenerated" : true, "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" : { "Tap to collapse settings" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {

View File

@ -16,7 +16,10 @@ import Bedrock
/// Usage: /// Usage:
/// ```swift /// ```swift
/// .sheet(isPresented: $showPaywall) { /// .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 /// 2. If successful, display the native RevenueCat PaywallView
/// 3. If it fails (network error, no paywall configured), fall back to ProPaywallView /// 3. If it fails (network error, no paywall configured), fall back to ProPaywallView
struct PaywallPresenter: View { struct PaywallPresenter: View {
/// Callback triggered when a purchase or restore is successful
var onPurchaseSuccess: (() -> Void)?
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var offering: Offering? @State private var offering: Offering?
@State private var isLoading = true @State private var isLoading = true
@State private var useFallback = false @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 { var body: some View {
Group { Group {
@ -37,18 +51,29 @@ struct PaywallPresenter: View {
loadingView loadingView
} else if useFallback { } else if useFallback {
// Fallback to custom paywall on error // Fallback to custom paywall on error
ProPaywallView() ProPaywallView(onPurchaseSuccess: onPurchaseSuccess)
} else if let offering { } else if let offering {
// RevenueCat native paywall // RevenueCat native paywall
paywallView(for: offering) paywallView(for: offering)
} else { } else {
// No offering available, use fallback // No offering available, use fallback
ProPaywallView() ProPaywallView(onPurchaseSuccess: onPurchaseSuccess)
} }
} }
.task { .task {
await loadOffering() 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 // MARK: - Loading View
@ -72,17 +97,42 @@ struct PaywallPresenter: View {
private func paywallView(for offering: Offering) -> some View { private func paywallView(for offering: Offering) -> some View {
PaywallView(offering: offering) PaywallView(offering: offering)
.onPurchaseCompleted { customerInfo in .onPurchaseCompleted { _ in
#if DEBUG #if DEBUG
print("✅ [PaywallPresenter] Purchase completed") print("✅ [PaywallPresenter] Purchase completed")
#endif #endif
onPurchaseSuccess?()
dismiss() 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 .onRestoreCompleted { customerInfo in
#if DEBUG #if DEBUG
print("✅ [PaywallPresenter] Restore completed") print("✅ [PaywallPresenter] Restore completed")
#endif #endif
dismiss() // 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
} }
} }

View File

@ -14,13 +14,12 @@ final class PremiumManager: PremiumManaging {
/// Task for listening to customer info updates /// Task for listening to customer info updates
@ObservationIgnored private var customerInfoTask: Task<Void, Never>? @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 { private static var apiKey: String {
guard let key = Bundle.main.object(forInfoDictionaryKey: "RevenueCatAPIKey") as? String, let key = Secrets.revenueCatAPIKey
!key.isEmpty, guard !key.isEmpty, key != "YOUR_REVENUECAT_API_KEY_HERE" else {
key != "your_revenuecat_public_api_key_here" else {
#if DEBUG #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 #endif
return "" return ""
} }