Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
3c7f8a39db
commit
a94404d93d
1
.gitignore
vendored
1
.gitignore
vendored
@ -25,6 +25,7 @@ Pods/
|
||||
Secrets.xcconfig
|
||||
Secrets.debug.xcconfig
|
||||
Secrets.release.xcconfig
|
||||
**/Secrets.swift
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
|
||||
Binary file not shown.
@ -78,7 +78,7 @@
|
||||
<EnvironmentVariable
|
||||
key = "ENABLE_DEBUG_PREMIUM"
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
14
SelfieCam/Configuration/Secrets.swift.template
Normal file
14
SelfieCam/Configuration/Secrets.swift.template
Normal 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"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)"))
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user