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.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
|
||||||
|
|||||||
Binary file not shown.
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
|
|||||||
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) {
|
.sheet(isPresented: $showPaywall) {
|
||||||
PaywallPresenter()
|
PaywallPresenter()
|
||||||
|
// No callback needed - paywall auto-dismisses on success
|
||||||
|
// and premium status updates automatically via PremiumManager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)"))
|
||||||
|
|||||||
@ -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" : {
|
||||||
|
|||||||
@ -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,19 +97,44 @@ 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
|
||||||
|
// Only call success if user actually has entitlements after restore
|
||||||
|
if !customerInfo.entitlements.active.isEmpty {
|
||||||
|
onPurchaseSuccess?()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onRestoreFailure { error in
|
||||||
|
#if DEBUG
|
||||||
|
print("❌ [PaywallPresenter] Restore failed: \(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Load Offering
|
// MARK: - Load Offering
|
||||||
|
|
||||||
|
|||||||
@ -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 ""
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user