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 "" }