diff --git a/.gitignore b/.gitignore
index 79e0e61..8234e3f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@ Pods/
Secrets.xcconfig
Secrets.debug.xcconfig
Secrets.release.xcconfig
+**/Secrets.swift
# OS generated files
.DS_Store
diff --git a/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate b/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate
index 65cd1d7..95d34f0 100644
Binary files a/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate and b/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/SelfieCam.xcodeproj/xcshareddata/xcschemes/SelfieCam.xcscheme b/SelfieCam.xcodeproj/xcshareddata/xcschemes/SelfieCam.xcscheme
index 971051f..cc5f39d 100644
--- a/SelfieCam.xcodeproj/xcshareddata/xcschemes/SelfieCam.xcscheme
+++ b/SelfieCam.xcodeproj/xcshareddata/xcschemes/SelfieCam.xcscheme
@@ -78,7 +78,7 @@
+ isEnabled = "NO">
diff --git a/SelfieCam/App/RootView.swift b/SelfieCam/App/RootView.swift
index 3d9af52..5739ae8 100644
--- a/SelfieCam/App/RootView.swift
+++ b/SelfieCam/App/RootView.swift
@@ -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
+ }
+ }
+ }
}
}
}
diff --git a/SelfieCam/Configuration/Debug.xcconfig b/SelfieCam/Configuration/Debug.xcconfig
index 3ad9177..8464628 100644
--- a/SelfieCam/Configuration/Debug.xcconfig
+++ b/SelfieCam/Configuration/Debug.xcconfig
@@ -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)
diff --git a/SelfieCam/Configuration/Release.xcconfig b/SelfieCam/Configuration/Release.xcconfig
index be2a1ef..28a3a5a 100644
--- a/SelfieCam/Configuration/Release.xcconfig
+++ b/SelfieCam/Configuration/Release.xcconfig
@@ -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)
diff --git a/SelfieCam/Configuration/Secrets.swift.template b/SelfieCam/Configuration/Secrets.swift.template
new file mode 100644
index 0000000..0bd741e
--- /dev/null
+++ b/SelfieCam/Configuration/Secrets.swift.template
@@ -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"
+}
diff --git a/SelfieCam/Features/Camera/Views/ContentView.swift b/SelfieCam/Features/Camera/Views/ContentView.swift
index 7e753ed..a3c7c8d 100644
--- a/SelfieCam/Features/Camera/Views/ContentView.swift
+++ b/SelfieCam/Features/Camera/Views/ContentView.swift
@@ -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
}
}
diff --git a/SelfieCam/Features/Paywall/Views/ProPaywallView.swift b/SelfieCam/Features/Paywall/Views/ProPaywallView.swift
index 75d7a60..55e9e5a 100644
--- a/SelfieCam/Features/Paywall/Views/ProPaywallView.swift
+++ b/SelfieCam/Features/Paywall/Views/ProPaywallView.swift
@@ -3,9 +3,24 @@ 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 {
@@ -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)"))
diff --git a/SelfieCam/Resources/Localizable.xcstrings b/SelfieCam/Resources/Localizable.xcstrings
index 6067634..9d72fcb 100644
--- a/SelfieCam/Resources/Localizable.xcstrings
+++ b/SelfieCam/Resources/Localizable.xcstrings
@@ -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" : {
diff --git a/SelfieCam/Shared/Premium/PaywallPresenter.swift b/SelfieCam/Shared/Premium/PaywallPresenter.swift
index 3e7a554..7686be8 100644
--- a/SelfieCam/Shared/Premium/PaywallPresenter.swift
+++ b/SelfieCam/Shared/Premium/PaywallPresenter.swift
@@ -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,17 +97,42 @@ 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
- 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
}
}
diff --git a/SelfieCam/Shared/Premium/PremiumManager.swift b/SelfieCam/Shared/Premium/PremiumManager.swift
index 65ac5c8..1519049 100644
--- a/SelfieCam/Shared/Premium/PremiumManager.swift
+++ b/SelfieCam/Shared/Premium/PremiumManager.swift
@@ -14,13 +14,12 @@ final class PremiumManager: PremiumManaging {
/// Task for listening to customer info updates
@ObservationIgnored private var customerInfoTask: Task?
- /// 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 ""
}