From 8f01d78146d9bfc07865448729edff4890c5b2bb Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 9 Feb 2026 17:03:33 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- PRD.md | 1 + README.md | 1 + SelfieCam.xcodeproj/project.pbxproj | 4 +- .../UserInterfaceState.xcuserstate | Bin 39365 -> 32966 bytes .../Views/OnboardingSettingsView.swift | 1 + .../Settings/Views/SettingsView.swift | 92 +- docs/REVENUECAT_INTEGRATION_GUIDE.md | 1306 ++--------------- 7 files changed, 159 insertions(+), 1246 deletions(-) diff --git a/PRD.md b/PRD.md index 37cc424..e301d23 100644 --- a/PRD.md +++ b/PRD.md @@ -44,6 +44,7 @@ SelfieCam is a professional-grade selfie camera app featuring a customizable scr 1. **Protocol-Oriented Programming (POP)** - All shared capabilities defined via protocols before concrete types 2. **MVVM-lite** - Views are "dumb" renderers; all logic lives in `@Observable` view models 3. **Bedrock Design System** - Centralized design tokens, no magic numbers + - Settings layout contract: `SettingsCard` owns horizontal insets, custom rows use `SettingsCardRow`, and in-card separators use `SettingsDivider` 4. **Full Accessibility** - Dynamic Type, VoiceOver labels/hints/traits/announcements 5. **Modern Swift & SwiftUI** - Swift 6 concurrency, `@MainActor`, modern APIs 6. **Testable & Reusable Design** - Protocols enable mocking and future package extraction diff --git a/README.md b/README.md index 98d1fb2..b9213d8 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Perfect for low-light selfies, content creation, video calls, makeup application - Dynamic Type and ScaledMetric for readable text at all sizes - String Catalog localization ready (`.xcstrings`) - Consistent design system using Bedrock framework +- Settings cards follow Bedrock’s row contract (`SettingsCard` + `SettingsCardRow` + `SettingsDivider`) for consistent insets and row rhythm - Prevents screen dimming during camera use ## Screenshots diff --git a/SelfieCam.xcodeproj/project.pbxproj b/SelfieCam.xcodeproj/project.pbxproj index 10fdf24..fac7f2d 100644 --- a/SelfieCam.xcodeproj/project.pbxproj +++ b/SelfieCam.xcodeproj/project.pbxproj @@ -442,7 +442,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(PRODUCT_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -472,7 +472,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(PRODUCT_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate b/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate index 6721b1fac027c5ecc9ab3f9c5bf1e5161f7da3b1..be1f7f73e3f4b65b4b39930c5fd9fb8b149a01c3 100644 GIT binary patch literal 32966 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 - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - CFBundleDisplayName - $(PRODUCT_NAME) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - - UIApplicationSupportsIndirectInputEvents - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - - NSCameraUsageDescription - Your camera usage description - - - RevenueCatAPIKey - $(REVENUECAT_API_KEY) - - -``` - -##### Step 3: Configure Xcode Project - -1. **Set Build Configurations to use xcconfig files:** - - Project → Info → Configurations - - Set Debug to use `Debug.xcconfig` - - Set Release to use `Release.xcconfig` - -2. **Update Build Settings for the app target:** - - Set `GENERATE_INFOPLIST_FILE = NO` - - Set `INFOPLIST_FILE = Info.plist` (path relative to project root) - -##### Step 4: Update PremiumManager to Read from Info.plist - -```swift -private static var apiKey: String { - let key = (Bundle.main.object(forInfoDictionaryKey: "RevenueCatAPIKey") as? String) ?? "" - let placeholders = [ - "", - "YOUR_REVENUECAT_API_KEY_HERE", - "test_YOUR_TEST_KEY_HERE", - "appl_YOUR_PRODUCTION_KEY_HERE" - ] - - #if DEBUG - let prefix = key.split(separator: "_").first.map(String.init) ?? "missing" - let keyStatus = key.isEmpty ? "empty" : "present" - print("ℹ️ [PremiumManager] RevenueCatAPIKey \(keyStatus), prefix=\(prefix)") - #endif - - guard !placeholders.contains(key) else { - #if DEBUG - print("⚠️ [PremiumManager] RevenueCat API key not configured. Check Secrets.debug.xcconfig / Secrets.release.xcconfig") - #endif - return "" - } - return key -} -``` - -##### Common Pitfalls - -**1. INFOPLIST_KEY_ prefix doesn't work for custom keys** - -The `INFOPLIST_KEY_` build setting prefix (e.g., `INFOPLIST_KEY_RevenueCatAPIKey`) only works for Apple's predefined Info.plist keys. Custom keys like `RevenueCatAPIKey` will NOT be added to the generated Info.plist. - -❌ **Does NOT work:** -``` -// In xcconfig - custom keys are ignored -INFOPLIST_KEY_RevenueCatAPIKey = $(REVENUECAT_API_KEY) -``` - -✅ **Works:** Use a custom Info.plist with build setting substitution: -```xml -RevenueCatAPIKey -$(REVENUECAT_API_KEY) -``` - -**2. Info.plist in synced folder causes "Multiple commands produce Info.plist"** - -If your project uses `fileSystemSynchronizedGroups` (Xcode 15+ default for new projects), placing Info.plist inside the app folder causes Xcode to automatically add it to Copy Bundle Resources, resulting in a build error. - -❌ **Causes error:** `YourApp/YourApp/Info.plist` - -✅ **Correct location:** `YourApp/Info.plist` (at project root, outside the synced folder) - -**3. Wrong xcconfig include path** - -The `#include` path in xcconfig files is relative to the xcconfig file itself. - -❌ **Wrong (if files are in same directory):** -``` -#include "YourApp/Configuration/Secrets.debug.xcconfig" -``` - -✅ **Correct:** -``` -#include "Secrets.debug.xcconfig" -``` - -**4. Verify the key is in the built app** - -After building, verify the key was properly substituted: -```bash -plutil -p ~/Library/Developer/Xcode/DerivedData/YourApp-*/Build/Products/Debug-iphonesimulator/Your\ App.app/Info.plist | grep RevenueCat -``` - -Expected output: -``` -"RevenueCatAPIKey" => "test_your_actual_key" -``` - ---- - -## PremiumManager Implementation - -Create a centralized manager for all RevenueCat interactions. - -### Complete Implementation - -```swift -import RevenueCat -import SwiftUI - -@MainActor -@Observable -final class PremiumManager { - - // MARK: - Published State - - /// Available packages for purchase - var availablePackages: [Package] = [] - - // MARK: - Configuration - - /// Entitlement identifier - must match RevenueCat dashboard - private let entitlementIdentifier = "pro" - - /// Task for listening to customer info updates - @ObservationIgnored private var customerInfoTask: Task? - - /// API key from secrets - private static var apiKey: String { - let key = Secrets.revenueCatAPIKey - guard !key.isEmpty, key != "YOUR_REVENUECAT_API_KEY_HERE" else { - #if DEBUG - print("⚠️ [PremiumManager] RevenueCat API key not configured") - #endif - return "" - } - return key - } - - // MARK: - Debug Override (Optional) - - #if DEBUG - @AppStorage("debugPremiumEnabled") - @ObservationIgnored private var debugPremiumEnabled = false - - private var isDebugPremiumEnabled: Bool { - debugPremiumEnabled || ProcessInfo.processInfo.environment["ENABLE_DEBUG_PREMIUM"] == "1" - } - - var isDebugPremiumToggleEnabled: Bool { - get { debugPremiumEnabled } - set { debugPremiumEnabled = newValue } - } - #endif - - // MARK: - Premium Status - - var isPremium: Bool { - #if DEBUG - if isDebugPremiumEnabled { return true } - #endif - - guard !Self.apiKey.isEmpty else { return false } - - return Purchases.shared.cachedCustomerInfo? - .entitlements[entitlementIdentifier]?.isActive == true - } - - /// Alias for compatibility - var isPremiumUnlocked: Bool { isPremium } - - // MARK: - Initialization - - init() { - guard !Self.apiKey.isEmpty else { - #if DEBUG - print("⚠️ [PremiumManager] Skipping RevenueCat configuration - no API key") - #endif - return - } - - #if DEBUG - Purchases.logLevel = .debug - #endif - - Purchases.configure(withAPIKey: Self.apiKey) - - Task { - try? await loadProducts() - } - } - - // MARK: - Products - - func loadProducts() async throws { - guard !Self.apiKey.isEmpty else { return } - - let offerings = try await Purchases.shared.offerings() - if let current = offerings.current { - availablePackages = current.availablePackages - } - } - - // MARK: - Purchase - - func purchase(_ package: Package) async throws -> Bool { - #if DEBUG - if isDebugPremiumEnabled { - return true // Simulate success - } - #endif - - let result = try await Purchases.shared.purchase(package: package) - return result.customerInfo.entitlements[entitlementIdentifier]?.isActive == true - } - - func purchase(productId: String) async throws { - guard let package = availablePackages.first(where: { - $0.storeProduct.productIdentifier == productId - }) else { - throw PurchaseError.productNotFound - } - _ = try await purchase(package) - } - - // MARK: - Restore - - func restorePurchases() async throws { - #if DEBUG - if isDebugPremiumEnabled { return } - #endif - - _ = try await Purchases.shared.restorePurchases() - } - - // MARK: - Subscription Status - - /// Fetch fresh customer info (call on app launch) - func checkSubscriptionStatus() async { - guard !Self.apiKey.isEmpty else { return } - - do { - _ = try await Purchases.shared.customerInfo() - } catch { - #if DEBUG - print("⚠️ [PremiumManager] Failed to fetch customer info: \(error)") - #endif - } - } - - // MARK: - Real-time Updates - - /// Start listening for subscription changes - func startListeningForCustomerInfoUpdates() { - guard !Self.apiKey.isEmpty else { return } - - customerInfoTask?.cancel() - - customerInfoTask = Task { - for await customerInfo in Purchases.shared.customerInfoStream { - let isActive = customerInfo.entitlements[entitlementIdentifier]?.isActive == true - #if DEBUG - print("📱 [PremiumManager] Customer info updated. Premium: \(isActive)") - #endif - } - } - } - - func stopListeningForCustomerInfoUpdates() { - customerInfoTask?.cancel() - customerInfoTask = nil - } -} - -// MARK: - Errors - -enum PurchaseError: LocalizedError { - case productNotFound - - var errorDescription: String? { - switch self { - case .productNotFound: - return "Product not found" - } - } -} -``` - -### App Entry Point Integration - -```swift -@main -struct YourApp: App { - @State private var premiumManager = PremiumManager() - - var body: some Scene { - WindowGroup { - ContentView() - .environment(premiumManager) - .task { - await premiumManager.checkSubscriptionStatus() - premiumManager.startListeningForCustomerInfoUpdates() - } - } - } -} -``` - ---- - -## Paywall Implementation - -### PaywallPresenter with Fallback - -Use RevenueCat's native PaywallView with a custom fallback for offline scenarios. - -```swift -import SwiftUI -import RevenueCat -import RevenueCatUI - -struct PaywallPresenter: View { - /// Called on successful purchase or restore - 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 - - init(onPurchaseSuccess: (() -> Void)? = nil) { - self.onPurchaseSuccess = onPurchaseSuccess - } - - var body: some View { - Group { - if isLoading { - loadingView - } else if useFallback { - CustomPaywallView(onPurchaseSuccess: onPurchaseSuccess) - } else if let offering { - paywallView(for: offering) - } else { - CustomPaywallView(onPurchaseSuccess: onPurchaseSuccess) - } - } - .task { - await loadOffering() - } - .alert("Purchase Error", isPresented: $showError, presenting: errorMessage) { _ in - Button("OK", role: .cancel) { errorMessage = nil } - } message: { message in - Text(message) - } - } - - private var loadingView: some View { - VStack { - ProgressView() - .scaleEffect(1.5) - Text("Loading...") - .padding(.top, 16) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private func paywallView(for offering: Offering) -> some View { - PaywallView(offering: offering) - .onPurchaseCompleted { _ in - onPurchaseSuccess?() - dismiss() - } - .onPurchaseCancelled { - // User cancelled - keep paywall open - } - .onPurchaseFailure { error in - errorMessage = error.localizedDescription - showError = true - } - .onRestoreCompleted { customerInfo in - if !customerInfo.entitlements.active.isEmpty { - onPurchaseSuccess?() - dismiss() - } - } - .onRestoreFailure { error in - errorMessage = error.localizedDescription - showError = true - } - } - - private func loadOffering() async { - do { - let offerings = try await Purchases.shared.offerings() - if let current = offerings.current { - offering = current - } else { - useFallback = true - } - } catch { - useFallback = true - } - isLoading = false - } -} -``` - -### Paywall Integration Patterns - -There are two primary contexts for presenting paywalls, each requiring different handling. - -#### Use Case 1: Onboarding Flow - -During onboarding, a successful purchase should complete the onboarding and transition to the main app. - -```swift -struct RootView: View { - @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false - @State private var onboardingViewModel = OnboardingViewModel() - @State private var settingsViewModel = SettingsViewModel() - @State private var showPaywall = false - - var body: some View { - ZStack { - if hasCompletedOnboarding { - MainAppView() - } else { - OnboardingView( - viewModel: onboardingViewModel, - showPaywall: $showPaywall, - onComplete: { - withAnimation { - hasCompletedOnboarding = true - } - } - ) - } - } - .sheet(isPresented: $showPaywall) { - PaywallPresenter { - // Purchase successful during onboarding - complete it - if !hasCompletedOnboarding { - onboardingViewModel.completeOnboarding(settings: settingsViewModel) - withAnimation { - hasCompletedOnboarding = true - } - } - } - } - } -} -``` - -**Key points for onboarding:** -- Pass a success callback that completes onboarding -- Transition directly to main app after successful purchase -- User skipping the paywall continues onboarding normally - -#### Use Case 2: In-App Purchase (Settings, Feature Gates) - -When users trigger the paywall from within the app (e.g., tapping a premium feature in Settings), the UI must refresh to reflect the new premium status after purchase. - -```swift -struct MainContentView: View { - @State private var settings = SettingsViewModel() - @State private var showSettings = false - @State private var showPaywall = false - - var body: some View { - // Main app content - YourMainView() - .sheet(isPresented: $showSettings) { - SettingsView(viewModel: settings, showPaywall: $showPaywall) - } - .sheet(isPresented: $showPaywall, onDismiss: { - // CRITICAL: Force UI to refresh premium status after paywall closes - settings.refreshPremiumStatus() - }) { - PaywallPresenter() - } - } -} -``` - -**Key points for in-app purchases:** -- Use `onDismiss` to trigger a premium status refresh -- No success callback needed (UI refreshes via observation) -- Settings dismisses before showing paywall, then user returns to main view - -#### Combined Pattern - -For apps with both onboarding and in-app paywalls, handle both cases: - -```swift -.sheet(isPresented: $showPaywall, onDismiss: { - // Always refresh premium status when paywall closes - settings.refreshPremiumStatus() -}) { - PaywallPresenter { - // Only used during onboarding - if !hasCompletedOnboarding { - completeOnboarding() - } - // For in-app purchases, the onDismiss handles the refresh - } -} -``` - ---- - -## Event Handling - -### Available RevenueCat PaywallView Callbacks - -| Modifier | When Called | -|----------|------------| -| `.onPurchaseStarted { package in }` | Purchase initiated for a package | -| `.onPurchaseCompleted { customerInfo in }` | Purchase successful | -| `.onPurchaseCancelled { }` | User cancelled purchase | -| `.onPurchaseFailure { error in }` | Purchase failed | -| `.onRestoreStarted { }` | Restore initiated | -| `.onRestoreCompleted { customerInfo in }` | Restore successful | -| `.onRestoreFailure { error in }` | Restore failed | -| `.onRequestedDismissal { }` | Paywall requests to close | - -### Error Handling Best Practices - -```swift -.onPurchaseFailure { error in - let nsError = error as NSError - - // Don't show error for user cancellation - if nsError.code == 2 { // SKError.paymentCancelled - return - } - - // Handle specific error types - switch nsError.code { - case 0: // SKError.unknown - errorMessage = "An unknown error occurred. Please try again." - case 1: // SKError.clientInvalid - errorMessage = "You are not authorized to make purchases." - case 3: // SKError.paymentNotAllowed - errorMessage = "Payments are not allowed on this device." - default: - errorMessage = error.localizedDescription - } - showError = true -} -``` - ---- - -## SwiftUI Observation and Premium Status Updates - -### The Problem - -When a user completes a purchase, RevenueCat updates `Purchases.shared.cachedCustomerInfo` with the new subscription status. However, SwiftUI's `@Observable` macro doesn't automatically detect changes to this shared singleton. - -**This affects the in-app purchase flow (Use Case 2), not onboarding (Use Case 1):** - -| Use Case | Affected? | Why | -|----------|-----------|-----| -| Onboarding | No | Success callback explicitly completes onboarding and transitions to main app | -| In-App (Settings) | **Yes** | UI must detect the change to show unlocked features | - -### Why In-App Purchases Don't Update the UI - -1. User opens Settings (which has a `SettingsViewModel` with its own `PremiumManager`) -2. User taps a premium feature, triggering the paywall -3. User completes purchase successfully -4. Paywall dismisses, returning to Settings -5. `isPremiumUnlocked` still returns `false` in the UI even though purchase succeeded - -**Technical reason:** -- `isPremiumUnlocked` reads from `Purchases.shared.cachedCustomerInfo` -- The cached customer info IS updated after purchase -- But SwiftUI doesn't re-render because no `@Observable` property changed - -### Solution: Refresh Token Pattern - -Add a refresh mechanism that forces SwiftUI to re-evaluate the premium status: - -```swift -@MainActor -@Observable -final class SettingsViewModel { - @ObservationIgnored let premiumManager = PremiumManager() - - // Refresh token that forces re-evaluation when incremented - private var premiumRefreshToken: Int = 0 - - /// Premium status - always reads current value from RevenueCat - var isPremiumUnlocked: Bool { - // Access refresh token to create observation dependency - _ = premiumRefreshToken - return premiumManager.isPremiumUnlocked - } - - /// Force SwiftUI to re-evaluate premium status - /// Call this after paywall dismisses - func refreshPremiumStatus() { - premiumRefreshToken += 1 - } -} -``` - -### Triggering the Refresh - -Always call `refreshPremiumStatus()` when the paywall sheet dismisses: - -```swift -.sheet(isPresented: $showPaywall, onDismiss: { - // Force UI to re-check premium status after paywall closes - // This handles both successful purchases and user cancellations - settings.refreshPremiumStatus() -}) { - PaywallPresenter() -} -``` - -**Why use `onDismiss` instead of the success callback?** -- `onDismiss` is called for all dismissal reasons (purchase, cancel, swipe down) -- It ensures the UI always reflects the current state -- Works for both onboarding and in-app contexts - -### Complete Integration Example - -Handling both onboarding and in-app purchase flows: - -```swift -struct RootView: View { - @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false - @State private var settings = SettingsViewModel() - @State private var showPaywall = false - - var body: some View { - Group { - if hasCompletedOnboarding { - MainAppView(settings: settings, showPaywall: $showPaywall) - } else { - OnboardingView(showPaywall: $showPaywall, onComplete: completeOnboarding) - } - } - .sheet(isPresented: $showPaywall, onDismiss: { - // ALWAYS refresh after paywall closes (for in-app purchases) - settings.refreshPremiumStatus() - }) { - PaywallPresenter { - // Only for onboarding: complete it on successful purchase - if !hasCompletedOnboarding { - completeOnboarding() - } - } - } - } - - private func completeOnboarding() { - withAnimation { - hasCompletedOnboarding = true - } - } -} -``` - -### Alternative: Shared PremiumManager via Environment - -Instead of each view model having its own `PremiumManager`, use a single shared instance: - -```swift -@main -struct YourApp: App { - @State private var premiumManager = PremiumManager() - - var body: some Scene { - WindowGroup { - RootView() - .environment(premiumManager) - .task { - await premiumManager.checkSubscriptionStatus() - premiumManager.startListeningForCustomerInfoUpdates() - } - } - } -} - -// In any view: -struct SettingsView: View { - @Environment(PremiumManager.self) private var premiumManager - - var body: some View { - if premiumManager.isPremiumUnlocked { - // Premium content - } - } -} -``` - -This approach ensures all views observe the same instance. - -### Testing Checklist - -Test both purchase flows to ensure they work correctly: - -**Onboarding Flow:** -- [ ] Skip to paywall step in onboarding -- [ ] Complete a purchase -- [ ] Verify: App transitions to main content immediately -- [ ] Verify: Premium features are unlocked - -**In-App Purchase Flow:** -- [ ] Complete onboarding WITHOUT purchasing (tap "Maybe Later") -- [ ] Open Settings -- [ ] Tap a premium feature to trigger paywall -- [ ] Complete a purchase -- [ ] Verify: Paywall dismisses -- [ ] Verify: Settings shows premium features as unlocked -- [ ] Verify: No need to restart app or reopen Settings - -**Restore Purchases:** -- [ ] Test restore from onboarding soft paywall -- [ ] Test restore from in-app paywall -- [ ] Verify: UI updates correctly after restore - ---- - -## App Store Connect Setup - -### 1. Create Your App - -1. Go to [App Store Connect](https://appstoreconnect.apple.com) -2. **My Apps** → **+** → **New App** -3. Fill in bundle ID (must match Xcode exactly) - -### 2. Create Subscription Group - -1. In your app → **Subscriptions** -2. Click **+** next to "Subscription Groups" -3. Name it (e.g., "Pro Access") - -### 3. Create Products - -**Auto-Renewable Subscriptions** (monthly, yearly): - -| Field | Example Value | -|-------|--------------| -| Reference Name | Pro Monthly | -| Product ID | `com.company.app.pro.monthly` | -| Duration | 1 Month | -| Price | $2.99 | - -**Non-Consumable** (lifetime): - -| Field | Example Value | -|-------|--------------| -| Reference Name | Pro Lifetime | -| Product ID | `com.company.app.pro.lifetime` | -| Price | $39.99 | - -### 4. Agreements - -1. **Agreements, Tax, and Banking** → Accept **Paid Applications** -2. Add bank account and tax information - ---- - -## RevenueCat Dashboard Setup - -### 1. Create Project and App - -1. Create project at [RevenueCat](https://app.revenuecat.com) -2. Add iOS app with your bundle ID - -### 2. Connect to App Store - -1. Get App-Specific Shared Secret from App Store Connect -2. Add to RevenueCat: **Project Settings** → **Apps** → your app - -### 3. Create Products - -Add each product with IDs matching App Store Connect exactly. - -### 4. Create Entitlement - -1. **Entitlements** → **+ New** -2. Name: `pro` (or your identifier) -3. Attach all products that grant this entitlement - -### 5. Create Offering - -1. **Offerings** → Edit **default** +1. Go to **Offerings** → Edit **default** offering 2. Add packages: - - `$rc_monthly` → monthly product - - `$rc_annual` → yearly product - - `$rc_lifetime` → lifetime product + - `$rc_monthly` → your monthly product + - `$rc_annual` → your yearly product + - `$rc_lifetime` → your lifetime product -### 6. Design Paywall (Optional) +#### Step 6: Design Paywall (Optional) 1. In your offering, click **Add Paywall** 2. Use the visual editor to design -3. Configure colors, text, layout +3. Configure colors, text, layout to match SelfieCam branding -### 7. Get API Keys +#### Step 7: Get API Keys -- **Test Key** (starts with `test_`): For development, free purchases -- **Production Key** (starts with `appl_`): For App Store builds +Go to **Project Settings** → **API Keys**: +- **Test Key** (starts with `test_`): Already configured in `Secrets.debug.xcconfig` +- **Production Key** (starts with `appl_`): Copy this to `Secrets.release.xcconfig` --- -## Testing Strategies +## Testing -### 1. Debug Premium Toggle (Fastest) +### Debug Premium Toggle -For UI testing without purchase flow: +In debug builds, you can enable premium without purchasing: +1. Go to Settings in the app +2. Toggle "Debug Premium" (only visible in DEBUG builds) -```swift -#if DEBUG -Toggle("Debug Premium", isOn: $premiumManager.isDebugPremiumToggleEnabled) -#endif -``` - -### 2. StoreKit Configuration File (Local) - -1. **File** → **New** → **StoreKit Configuration File** -2. Add products matching your IDs -3. **Edit Scheme** → **Run** → **Options** → Set StoreKit Configuration -4. Purchases are instant and free - -### 3. RevenueCat Test Mode (Integration) - -Use the test API key (`test_`): -- Purchases are free -- Transactions appear in RevenueCat dashboard -- Restore not available (returns cached info) - -### 4. Sandbox Testing (Full) +### Sandbox Testing 1. Create Sandbox Tester in App Store Connect 2. Sign out of App Store on device 3. Make purchase, sign in with sandbox credentials 4. Verify in RevenueCat dashboard -**Sandbox Renewal Times:** - -| Real | Sandbox | -|------|---------| -| 1 week | 3 min | -| 1 month | 5 min | -| 1 year | 1 hour | - ---- - -## Production Checklist - -### Code - -- [ ] PremiumManager configured with production API key -- [ ] Secrets file is gitignored -- [ ] Error handling for all purchase scenarios -- [ ] Loading states during purchases -- [ ] Restore purchases functionality -- [ ] Real-time subscription status updates - -### App Store Connect - -- [ ] App created with correct bundle ID -- [ ] All products created with localizations -- [ ] Subscription group configured -- [ ] Paid Apps agreement accepted -- [ ] Bank and tax info submitted - -### RevenueCat Dashboard - -- [ ] Production app created with bundle ID -- [ ] Shared secret configured -- [ ] All products added (IDs match exactly) -- [ ] Entitlement created with products attached -- [ ] Offering configured with packages -- [ ] (Optional) Paywall designed - -### Testing - -- [ ] Tested with StoreKit Configuration -- [ ] Tested with sandbox account -- [ ] Verified transactions in RevenueCat dashboard -- [ ] Tested restore purchases -- [ ] Tested subscription expiration/renewal - --- ## Troubleshooting +### "RevenueCat API key not configured" + +1. Clean build: `rm -rf ~/Library/Developer/Xcode/DerivedData/SelfieCam-*` +2. Verify xcconfig includes are correct (relative paths) +3. Check `Info.plist` contains `RevenueCatAPIKey$(REVENUECAT_API_KEY)` +4. Verify with: `plutil -p ~/Library/Developer/Xcode/DerivedData/SelfieCam-*/Build/Products/Debug-*/Selfie\ Cam.app/Info.plist | grep Revenue` + ### Products Not Loading 1. Verify product IDs match exactly (case-sensitive) -2. Check API key is correct -3. Ensure shared secret is configured -4. Wait a few minutes after creating products - -### "Purchases has not been configured" - -- `Purchases.configure(withAPIKey:)` must be called before any other SDK methods -- Ensure it's called early in app lifecycle (App init or `@main`) - -### Sandbox Purchase Fails - -1. Sign out of regular App Store -2. Use sandbox credentials (not real Apple ID) -3. Check Paid Apps agreement is accepted -4. Ensure products have prices set - -### Entitlements Not Granting Access - -1. Verify entitlement identifier matches code exactly -2. Check products are attached to entitlement -3. Confirm packages are in the offering - -### RevenueCat Dashboard Empty - -1. Using test key shows in sandbox environment -2. Check bundle ID matches exactly -3. Wait 1-2 minutes for transactions to appear - -### API Key Shows in Build Settings But Not in App (xcconfig) - -**Symptom:** `xcodebuild -showBuildSettings` shows `REVENUECAT_API_KEY = test_...` but the app logs show "RevenueCatAPIKey empty". - -**Cause:** The `INFOPLIST_KEY_` prefix only works for Apple's predefined keys, not custom keys. - -**Solution:** -1. Create a custom `Info.plist` file at the project root (NOT inside the synced app folder) -2. Add the key with build setting substitution: `$(REVENUECAT_API_KEY)` -3. Set `GENERATE_INFOPLIST_FILE = NO` in build settings -4. Set `INFOPLIST_FILE = Info.plist` - -See [Option C: xcconfig + Info.plist](#option-c-xcconfig--infoplist-recommended-for-xcode-15-projects) for complete instructions. - -### "Multiple commands produce Info.plist" Build Error - -**Cause:** The Info.plist file is inside a `fileSystemSynchronizedGroups` folder (Xcode 15+ default), causing Xcode to automatically add it to Copy Bundle Resources. - -**Solution:** Move Info.plist to the project root directory (same level as `.xcodeproj`), NOT inside the app source folder. - -``` -YourProject/ -├── YourProject.xcodeproj -├── Info.plist ← Correct location -└── YourProject/ - └── (source files) ← NOT here -``` - -### xcconfig Include Path Errors - -**Symptom:** Build settings show empty or placeholder values even though the Secrets xcconfig files exist. - -**Cause:** The `#include` path is relative to the xcconfig file itself, not the project root. - -**Fix:** If `Debug.xcconfig` and `Secrets.debug.xcconfig` are in the same directory: - -``` -// Wrong -#include "YourApp/Configuration/Secrets.debug.xcconfig" - -// Correct -#include "Secrets.debug.xcconfig" -``` - -### Verifying xcconfig Setup - -Run these commands to debug your xcconfig setup: - -```bash -# Check if REVENUECAT_API_KEY is in build settings -xcodebuild -showBuildSettings -scheme "YourScheme" -configuration Debug | grep REVENUECAT - -# Check if key is in the built Info.plist -plutil -p ~/Library/Developer/Xcode/DerivedData/YourApp-*/Build/Products/Debug-iphonesimulator/Your\ App.app/Info.plist | grep -i revenue - -# Clean and rebuild after xcconfig changes -rm -rf ~/Library/Developer/Xcode/DerivedData/YourApp-* -xcodebuild clean build -scheme "YourScheme" -configuration Debug -destination "platform=iOS Simulator,name=iPhone 16 Pro" -``` +2. Check API key is correct for environment +3. Ensure shared secret is configured in RevenueCat +4. Wait a few minutes after creating new products --- -## Resources +## Related Documentation -- [RevenueCat Documentation](https://docs.revenuecat.com) -- [RevenueCat iOS SDK Reference](https://sdk.revenuecat.com/ios/index.html) -- [App Store Connect Help](https://help.apple.com/app-store-connect/) -- [StoreKit Documentation](https://developer.apple.com/documentation/storekit) +- [IN_APP_PURCHASE_SETUP.md](IN_APP_PURCHASE_SETUP.md) - Detailed App Store Connect setup +- [RevenueCat Docs](https://docs.revenuecat.com) - Official documentation