Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
658423ee16
commit
3c7f8a39db
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# SelfieCam .gitignore
|
||||||
|
|
||||||
|
# Xcode
|
||||||
|
*.xcuserstate
|
||||||
|
*.xccheckout
|
||||||
|
*.xcscmblueprint
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
|
||||||
|
# Build
|
||||||
|
build/
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
Pods/
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
|
||||||
|
# Secrets - API keys and sensitive configuration
|
||||||
|
# ⚠️ NEVER commit these files
|
||||||
|
Secrets.xcconfig
|
||||||
|
Secrets.debug.xcconfig
|
||||||
|
Secrets.release.xcconfig
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
@ -9,4 +9,4 @@ Always try to build after coding to ensure no build errors exist and use the iPh
|
|||||||
|
|
||||||
Try and use xcode build mcp if it is working and test using screenshots when asked.
|
Try and use xcode build mcp if it is working and test using screenshots when asked.
|
||||||
|
|
||||||
Make sure for UI you are using the Bedrock framework reusable components and Typography where it is needed. Read the REA
|
Make sure for UI you are using the Bedrock framework reusable components and Typography where it is needed for Views to stay consistent.
|
||||||
Binary file not shown.
@ -44,7 +44,7 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPaywall) {
|
.sheet(isPresented: $showPaywall) {
|
||||||
ProPaywallView()
|
PaywallPresenter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,9 @@ import Bedrock
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct SelfieCamApp: App {
|
struct SelfieCamApp: App {
|
||||||
|
/// Premium manager for checking subscription status on launch
|
||||||
|
@State private var premiumManager = PremiumManager()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
Design.showDebugLogs = true
|
Design.showDebugLogs = true
|
||||||
|
|
||||||
@ -37,6 +40,13 @@ struct SelfieCamApp: App {
|
|||||||
// Set screen brightness to 100% on app launch
|
// Set screen brightness to 100% on app launch
|
||||||
UIScreen.main.brightness = 1.0
|
UIScreen.main.brightness = 1.0
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
// Refresh subscription status on launch (handles fresh installs)
|
||||||
|
await premiumManager.checkSubscriptionStatus()
|
||||||
|
|
||||||
|
// Start listening for real-time customer info updates
|
||||||
|
premiumManager.startListeningForCustomerInfoUpdates()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
// Configuration for Debug builds
|
// Configuration for Debug builds
|
||||||
|
|
||||||
#include "Base.xcconfig"
|
#include "Base.xcconfig"
|
||||||
#include? "Secrets.xcconfig"
|
#include? "Secrets.debug.xcconfig"
|
||||||
|
|
||||||
// If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values
|
// If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values
|
||||||
// CI/CD should set these via environment variables
|
// CI/CD should set these via environment variables
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
// Configuration for Release builds
|
// Configuration for Release builds
|
||||||
|
|
||||||
#include "Base.xcconfig"
|
#include "Base.xcconfig"
|
||||||
#include? "Secrets.xcconfig"
|
#include? "Secrets.release.xcconfig"
|
||||||
|
|
||||||
// If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values
|
// If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values
|
||||||
// CI/CD should set these via environment variables
|
// CI/CD should set these via environment variables
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
// Secrets.xcconfig
|
|
||||||
//
|
|
||||||
// ⚠️ DO NOT COMMIT THIS FILE TO VERSION CONTROL
|
|
||||||
// This file contains sensitive API keys and secrets.
|
|
||||||
//
|
|
||||||
// For CI/CD: Set these values via environment variables in your build system.
|
|
||||||
|
|
||||||
// RevenueCat API Key
|
|
||||||
// Get this from: RevenueCat Dashboard > Project Settings > API Keys > Public App-Specific API Key
|
|
||||||
REVENUECAT_API_KEY = your_revenuecat_public_api_key_here
|
|
||||||
@ -1,12 +1,22 @@
|
|||||||
// Secrets.xcconfig.template
|
// Secrets.xcconfig.template
|
||||||
//
|
//
|
||||||
// INSTRUCTIONS:
|
// INSTRUCTIONS:
|
||||||
// 1. Copy this file to "Secrets.xcconfig" in the same directory
|
// This project uses separate secrets files for Debug and Release builds:
|
||||||
// 2. Replace the placeholder values with your actual API keys
|
|
||||||
// 3. NEVER commit Secrets.xcconfig to version control
|
|
||||||
//
|
//
|
||||||
// The actual Secrets.xcconfig file is gitignored for security.
|
// 1. Copy this file to "Secrets.debug.xcconfig" for development/testing
|
||||||
|
// - Use your RevenueCat TEST API key (starts with test_)
|
||||||
|
// - Sandbox purchases, no real money charged
|
||||||
|
//
|
||||||
|
// 2. Copy this file to "Secrets.release.xcconfig" for App Store builds
|
||||||
|
// - Use your RevenueCat PRODUCTION API key (starts with appl_)
|
||||||
|
// - Real purchases, charges real money
|
||||||
|
//
|
||||||
|
// 3. NEVER commit the actual secrets files to version control
|
||||||
|
//
|
||||||
|
// The actual Secrets.*.xcconfig files are gitignored for security.
|
||||||
|
|
||||||
// RevenueCat API Key
|
// RevenueCat API Key
|
||||||
// Get this from: RevenueCat Dashboard > Project Settings > API Keys > Public App-Specific API Key
|
// Get this from: RevenueCat Dashboard > Project Settings > API Keys
|
||||||
REVENUECAT_API_KEY = your_revenuecat_public_api_key_here
|
// - Test key: Use "Public App-Specific API Key" from test environment
|
||||||
|
// - Production key: Use "Public App-Specific API Key" from production environment
|
||||||
|
REVENUECAT_API_KEY = your_revenuecat_api_key_here
|
||||||
|
|||||||
@ -113,7 +113,7 @@ struct ContentView: View {
|
|||||||
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPaywall) {
|
.sheet(isPresented: $showPaywall) {
|
||||||
ProPaywallView()
|
PaywallPresenter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,12 @@ struct OnboardingSoftPaywallView: View {
|
|||||||
@Binding var showPaywall: Bool
|
@Binding var showPaywall: Bool
|
||||||
let onComplete: () -> Void
|
let onComplete: () -> Void
|
||||||
|
|
||||||
|
/// Premium manager for restore purchases
|
||||||
|
@State private var premiumManager = PremiumManager()
|
||||||
|
|
||||||
|
/// Whether a restore is in progress
|
||||||
|
@State private var isRestoring = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
OnboardingContentContainer {
|
OnboardingContentContainer {
|
||||||
VStack(spacing: Design.Spacing.large) {
|
VStack(spacing: Design.Spacing.large) {
|
||||||
@ -87,6 +93,31 @@ struct OnboardingSoftPaywallView: View {
|
|||||||
onComplete()
|
onComplete()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Restore Purchases button
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
isRestoring = true
|
||||||
|
try? await premiumManager.restorePurchases()
|
||||||
|
isRestoring = false
|
||||||
|
|
||||||
|
// Check if premium was restored
|
||||||
|
if premiumManager.isPremiumUnlocked {
|
||||||
|
viewModel.completeOnboarding(settingsViewModel: settingsViewModel)
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if isRestoring {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.secondary)
|
||||||
|
} else {
|
||||||
|
Text(String(localized: "Restore Purchases"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.disabled(isRestoring)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Design.Spacing.xLarge)
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
.padding(.bottom, Design.Spacing.xLarge)
|
.padding(.bottom, Design.Spacing.xLarge)
|
||||||
|
|||||||
@ -83,9 +83,38 @@ private struct ProductPackageButton: View {
|
|||||||
let isPremiumUnlocked: Bool
|
let isPremiumUnlocked: Bool
|
||||||
let onPurchase: () -> Void
|
let onPurchase: () -> Void
|
||||||
|
|
||||||
|
private var isLifetime: Bool {
|
||||||
|
package.packageType == .lifetime
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isAnnual: Bool {
|
||||||
|
package.packageType == .annual
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background color based on package type
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
isLifetime ? AppStatus.warning.opacity(Design.Opacity.medium) : AppAccent.primary.opacity(Design.Opacity.medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Border color based on package type
|
||||||
|
private var borderColor: Color {
|
||||||
|
isLifetime ? AppStatus.warning : AppAccent.primary
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onPurchase) {
|
Button(action: onPurchase) {
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
// Badge for lifetime
|
||||||
|
if isLifetime {
|
||||||
|
Text(String(localized: "ONE TIME"))
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xxSmall)
|
||||||
|
.background(AppStatus.warning)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
Text(package.storeProduct.localizedTitle)
|
Text(package.storeProduct.localizedTitle)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
@ -94,7 +123,12 @@ private struct ProductPackageButton: View {
|
|||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
if package.packageType == .annual {
|
// Subtitle based on type
|
||||||
|
if isLifetime {
|
||||||
|
Text(String(localized: "Pay once, own forever"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.accent))
|
||||||
|
} else if isAnnual {
|
||||||
Text(String(localized: "Best Value • Save 33%"))
|
Text(String(localized: "Best Value • Save 33%"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.accent))
|
.foregroundStyle(.white.opacity(Design.Opacity.accent))
|
||||||
@ -102,14 +136,16 @@ private struct ProductPackageButton: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
.background(AppAccent.primary.opacity(Design.Opacity.medium))
|
.background(backgroundColor)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
.strokeBorder(AppAccent.primary, lineWidth: Design.LineWidth.thin)
|
.strokeBorder(borderColor, lineWidth: Design.LineWidth.thin)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)"))
|
.accessibilityLabel(isLifetime
|
||||||
|
? String(localized: "Purchase \(package.storeProduct.localizedTitle) for \(package.localizedPriceString), one-time payment")
|
||||||
|
: String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
import MijickCamera
|
import MijickCamera
|
||||||
|
import RevenueCatUI
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Bindable var viewModel: SettingsViewModel
|
@Bindable var viewModel: SettingsViewModel
|
||||||
@Binding var showPaywall: Bool
|
@Binding var showPaywall: Bool
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
/// Whether to show RevenueCat Customer Center
|
||||||
|
@State private var showCustomerCenter = false
|
||||||
|
|
||||||
/// Whether premium features are unlocked (for UI gating)
|
/// Whether premium features are unlocked (for UI gating)
|
||||||
private var isPremiumUnlocked: Bool {
|
private var isPremiumUnlocked: Bool {
|
||||||
viewModel.isPremiumUnlocked
|
viewModel.isPremiumUnlocked
|
||||||
@ -443,45 +447,81 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// MARK: - Pro Section
|
// MARK: - Pro Section
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private var proSection: some View {
|
private var proSection: some View {
|
||||||
Button {
|
if isPremiumUnlocked {
|
||||||
dismiss()
|
// User has Pro - show status and manage link via Customer Center
|
||||||
// Small delay to allow sheet to dismiss before showing paywall
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
||||||
showPaywall = true
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
Image(systemName: "crown.fill")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(AppStatus.warning)
|
.foregroundStyle(AppStatus.success)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
Text(String(localized: "Upgrade to Pro"))
|
Text(String(localized: "Pro Active"))
|
||||||
.font(.system(size: Design.FontSize.medium, weight: .semibold))
|
.font(.system(size: Design.FontSize.medium, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
Text(String(localized: "Premium colors, HDR, timers & more"))
|
Button {
|
||||||
.font(.system(size: Design.FontSize.caption))
|
showCustomerCenter = true
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
} label: {
|
||||||
|
Text(String(localized: "Manage Subscription"))
|
||||||
|
.font(.system(size: Design.FontSize.caption))
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.body)
|
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.medium)
|
.padding(Design.Spacing.medium)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
.fill(AppAccent.primary.opacity(Design.Opacity.subtle))
|
.fill(AppStatus.success.opacity(Design.Opacity.subtle))
|
||||||
.strokeBorder(AppAccent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
.strokeBorder(AppStatus.success.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
||||||
)
|
)
|
||||||
|
.accessibilityLabel(String(localized: "Pro subscription active"))
|
||||||
|
.accessibilityHint(String(localized: "Tap Manage Subscription to view or cancel"))
|
||||||
|
.presentCustomerCenter(isPresented: $showCustomerCenter)
|
||||||
|
} else {
|
||||||
|
// User doesn't have Pro - show upgrade button
|
||||||
|
Button {
|
||||||
|
dismiss()
|
||||||
|
// Small delay to allow sheet to dismiss before showing paywall
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
|
showPaywall = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(AppStatus.warning)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(String(localized: "Upgrade to Pro"))
|
||||||
|
.font(.system(size: Design.FontSize.medium, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(String(localized: "Premium colors, HDR, timers & more"))
|
||||||
|
.font(.system(size: Design.FontSize.caption))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.fill(AppAccent.primary.opacity(Design.Opacity.subtle))
|
||||||
|
.strokeBorder(AppAccent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(String(localized: "Upgrade to Pro"))
|
||||||
|
.accessibilityHint(String(localized: "Opens upgrade options"))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(String(localized: "Upgrade to Pro"))
|
|
||||||
.accessibilityHint(String(localized: "Opens upgrade options"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iCloud Sync Section
|
// MARK: - iCloud Sync Section
|
||||||
|
|||||||
126
SelfieCam/Shared/Premium/PaywallPresenter.swift
Normal file
126
SelfieCam/Shared/Premium/PaywallPresenter.swift
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
//
|
||||||
|
// PaywallPresenter.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Presents RevenueCat native Paywall with automatic fallback to custom PaywallView
|
||||||
|
// if the RevenueCat paywall fails to load or is not configured.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import RevenueCat
|
||||||
|
import RevenueCatUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
/// Presents RevenueCat Paywall with fallback to custom paywall.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```swift
|
||||||
|
/// .sheet(isPresented: $showPaywall) {
|
||||||
|
/// PaywallPresenter()
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The presenter will:
|
||||||
|
/// 1. Attempt to load the RevenueCat paywall from your configured offering
|
||||||
|
/// 2. If successful, display the native RevenueCat PaywallView
|
||||||
|
/// 3. If it fails (network error, no paywall configured), fall back to ProPaywallView
|
||||||
|
struct PaywallPresenter: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var offering: Offering?
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var useFallback = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isLoading {
|
||||||
|
// Loading state while fetching offerings
|
||||||
|
loadingView
|
||||||
|
} else if useFallback {
|
||||||
|
// Fallback to custom paywall on error
|
||||||
|
ProPaywallView()
|
||||||
|
} else if let offering {
|
||||||
|
// RevenueCat native paywall
|
||||||
|
paywallView(for: offering)
|
||||||
|
} else {
|
||||||
|
// No offering available, use fallback
|
||||||
|
ProPaywallView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadOffering()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading View
|
||||||
|
|
||||||
|
private var loadingView: some View {
|
||||||
|
VStack {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.5)
|
||||||
|
.tint(.white)
|
||||||
|
|
||||||
|
Text(String(localized: "Loading..."))
|
||||||
|
.font(.system(size: Design.FontSize.body))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
.padding(.top, Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Paywall View
|
||||||
|
|
||||||
|
private func paywallView(for offering: Offering) -> some View {
|
||||||
|
PaywallView(offering: offering)
|
||||||
|
.onPurchaseCompleted { customerInfo in
|
||||||
|
#if DEBUG
|
||||||
|
print("✅ [PaywallPresenter] Purchase completed")
|
||||||
|
#endif
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.onRestoreCompleted { customerInfo in
|
||||||
|
#if DEBUG
|
||||||
|
print("✅ [PaywallPresenter] Restore completed")
|
||||||
|
#endif
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load Offering
|
||||||
|
|
||||||
|
private func loadOffering() async {
|
||||||
|
do {
|
||||||
|
let offerings = try await Purchases.shared.offerings()
|
||||||
|
|
||||||
|
// Check if current offering has a paywall configured
|
||||||
|
if let current = offerings.current {
|
||||||
|
offering = current
|
||||||
|
isLoading = false
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
print("✅ [PaywallPresenter] Loaded offering: \(current.identifier)")
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
// No current offering, use fallback
|
||||||
|
#if DEBUG
|
||||||
|
print("⚠️ [PaywallPresenter] No current offering available, using fallback")
|
||||||
|
#endif
|
||||||
|
useFallback = true
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("⚠️ [PaywallPresenter] Failed to load offerings: \(error)")
|
||||||
|
#endif
|
||||||
|
useFallback = true
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
PaywallPresenter()
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
@ -9,7 +9,10 @@ final class PremiumManager: PremiumManaging {
|
|||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
/// RevenueCat entitlement identifier - must match your RevenueCat dashboard
|
/// RevenueCat entitlement identifier - must match your RevenueCat dashboard
|
||||||
private let entitlementIdentifier = "pro"
|
private let entitlementIdentifier = "Selfie Cam by TopDog Pro"
|
||||||
|
|
||||||
|
/// 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 Info.plist (injected at build time from Secrets.xcconfig)
|
||||||
private static var apiKey: String {
|
private static var apiKey: String {
|
||||||
@ -153,4 +156,55 @@ final class PremiumManager: PremiumManaging {
|
|||||||
argument: String(localized: "Purchases restored")
|
argument: String(localized: "Purchases restored")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Subscription Status Check
|
||||||
|
|
||||||
|
/// Explicitly fetches fresh customer info from RevenueCat.
|
||||||
|
/// Call this on app launch to handle fresh installs where the user
|
||||||
|
/// may have an active subscription from another device.
|
||||||
|
func checkSubscriptionStatus() async {
|
||||||
|
guard !Self.apiKey.isEmpty else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Fetch fresh customer info - this updates the cached info used by isPremium
|
||||||
|
_ = try await Purchases.shared.customerInfo()
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("⚠️ [PremiumManager] Failed to fetch customer info: \(error)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Customer Info Listener
|
||||||
|
|
||||||
|
/// Starts listening for real-time customer info updates from RevenueCat.
|
||||||
|
/// This enables reactive UI updates when subscription status changes
|
||||||
|
/// (e.g., user subscribes, cancels, or subscription expires).
|
||||||
|
func startListeningForCustomerInfoUpdates() {
|
||||||
|
guard !Self.apiKey.isEmpty else { return }
|
||||||
|
|
||||||
|
// Cancel any existing listener
|
||||||
|
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
|
||||||
|
|
||||||
|
// The @Observable framework will automatically notify observers
|
||||||
|
// when isPremium is accessed after this update since it reads
|
||||||
|
// from Purchases.shared.cachedCustomerInfo which is now updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops listening for customer info updates.
|
||||||
|
/// Call this when the manager is no longer needed.
|
||||||
|
func stopListeningForCustomerInfoUpdates() {
|
||||||
|
customerInfoTask?.cancel()
|
||||||
|
customerInfoTask = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
372
docs/IN_APP_PURCHASE_SETUP.md
Normal file
372
docs/IN_APP_PURCHASE_SETUP.md
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
# In-App Purchase Setup Guide
|
||||||
|
|
||||||
|
This guide walks through setting up Monthly, Yearly, and Lifetime in-app purchases for SelfieCam using RevenueCat.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Apple Developer Program membership ($99/year)
|
||||||
|
- RevenueCat account (free tier available)
|
||||||
|
- Xcode with SelfieCam project
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1: App Store Connect Setup
|
||||||
|
|
||||||
|
### Step 1: Create Your App
|
||||||
|
|
||||||
|
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
|
||||||
|
2. Click **My Apps** → **+** → **New App**
|
||||||
|
3. Fill in:
|
||||||
|
- **Platform**: iOS
|
||||||
|
- **Name**: SelfieCam (or your app name)
|
||||||
|
- **Primary Language**: English (US)
|
||||||
|
- **Bundle ID**: Select your app's bundle ID (must match Xcode)
|
||||||
|
- **SKU**: A unique identifier (e.g., `selfiecam2026`)
|
||||||
|
4. Click **Create**
|
||||||
|
|
||||||
|
### Step 2: Create a Subscription Group
|
||||||
|
|
||||||
|
Subscriptions must belong to a group. Users can only have one active subscription per group.
|
||||||
|
|
||||||
|
1. In your app, go to **Subscriptions** (left sidebar under "In-App Purchases")
|
||||||
|
2. Click **+** next to "Subscription Groups"
|
||||||
|
3. Enter group name: `SelfieCam Pro`
|
||||||
|
4. Click **Create**
|
||||||
|
|
||||||
|
### Step 3: Create Monthly Subscription
|
||||||
|
|
||||||
|
1. In your subscription group, click **+** next to "Subscriptions"
|
||||||
|
2. Fill in:
|
||||||
|
- **Reference Name**: Pro Monthly (internal only)
|
||||||
|
- **Product ID**: `com.mbrucedogs.SelfieCam.pro.monthly`
|
||||||
|
- Replace `yourcompany` with your actual company/developer name
|
||||||
|
- This ID is permanent and cannot be changed
|
||||||
|
3. Click **Create**
|
||||||
|
4. On the subscription page:
|
||||||
|
- **Subscription Duration**: 1 Month
|
||||||
|
- **Subscription Prices**: Click **+**, select your base country, enter price (e.g., $2.99/month)
|
||||||
|
- **App Store Localization**: Click **+**, add English (US):
|
||||||
|
- **Display Name**: Pro Monthly
|
||||||
|
- **Description**: Unlock all premium features with monthly access
|
||||||
|
5. Click **Save**
|
||||||
|
|
||||||
|
### Step 4: Create Yearly Subscription
|
||||||
|
|
||||||
|
1. In your subscription group, click **+** next to "Subscriptions"
|
||||||
|
2. Fill in:
|
||||||
|
- **Reference Name**: Pro Yearly
|
||||||
|
- **Product ID**: `com.mbrucedogs.SelfieCam.pro.yearly`
|
||||||
|
3. Click **Create**
|
||||||
|
4. On the subscription page:
|
||||||
|
- **Subscription Duration**: 1 Year
|
||||||
|
- **Subscription Prices**: Enter price (e.g., $19.99/year - ~33% savings vs monthly)
|
||||||
|
- **App Store Localization**:
|
||||||
|
- **Display Name**: Pro Yearly
|
||||||
|
- **Description**: Best value! Unlock all premium features for a full year
|
||||||
|
5. Click **Save**
|
||||||
|
|
||||||
|
### Step 5: Create Lifetime (Non-Consumable)
|
||||||
|
|
||||||
|
Lifetime purchases are NOT subscriptions - they're non-consumable in-app purchases.
|
||||||
|
|
||||||
|
1. Go to **In-App Purchases** (left sidebar, separate from Subscriptions)
|
||||||
|
2. Click **+** → **Non-Consumable**
|
||||||
|
3. Fill in:
|
||||||
|
- **Reference Name**: Pro Lifetime
|
||||||
|
- **Product ID**: `com.brumcedogs.SelfieCam.pro.lifetime`
|
||||||
|
4. Click **Create**
|
||||||
|
5. On the product page:
|
||||||
|
- **Price Schedule**: Click **+**, select base country, enter price (e.g., $39.99)
|
||||||
|
- **App Store Localization**: Click **+**, add English (US):
|
||||||
|
- **Display Name**: Pro Lifetime
|
||||||
|
- **Description**: Pay once, own forever. All premium features unlocked permanently.
|
||||||
|
6. Click **Save**
|
||||||
|
|
||||||
|
### Step 6: Agreements and Tax Setup
|
||||||
|
|
||||||
|
Before you can sell, you must accept agreements:
|
||||||
|
|
||||||
|
1. Go to [Agreements, Tax, and Banking](https://appstoreconnect.apple.com/agreements)
|
||||||
|
2. Accept the **Paid Applications** agreement
|
||||||
|
3. Fill in your **Bank Account** information
|
||||||
|
4. Fill in your **Tax Forms** (varies by country)
|
||||||
|
|
||||||
|
> **Note**: Products will show "Missing Metadata" until your app is submitted. This is normal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: RevenueCat Setup
|
||||||
|
|
||||||
|
### Step 1: Create RevenueCat Account
|
||||||
|
|
||||||
|
1. Go to [RevenueCat](https://www.revenuecat.com)
|
||||||
|
2. Click **Get Started** → **Sign up**
|
||||||
|
3. Choose the free tier (covers up to $2,500/month in revenue)
|
||||||
|
|
||||||
|
### Step 2: Create a Project
|
||||||
|
|
||||||
|
1. After signup, click **Create New Project**
|
||||||
|
2. Enter project name: `SelfieCam`
|
||||||
|
3. Click **Create Project**
|
||||||
|
|
||||||
|
### Step 3: Add Your iOS App
|
||||||
|
|
||||||
|
1. In your project, go to **Project Settings** (gear icon) → **Apps**
|
||||||
|
2. Click **+ New App**
|
||||||
|
3. Select **App Store** (iOS)
|
||||||
|
4. Fill in:
|
||||||
|
- **App Name**: SelfieCam
|
||||||
|
- **Bundle ID**: Your exact bundle ID from Xcode (e.g., `com.mbrucedogs.SelfieCam`)
|
||||||
|
5. Click **Save Changes**
|
||||||
|
|
||||||
|
### Step 4: Connect to App Store Connect
|
||||||
|
|
||||||
|
RevenueCat needs your App Store Connect shared secret to validate receipts:
|
||||||
|
|
||||||
|
1. In App Store Connect, go to your app → **App Information** (left sidebar)
|
||||||
|
2. Scroll to **App-Specific Shared Secret** → Click **Manage**
|
||||||
|
3. Click **Generate** if you don't have one
|
||||||
|
4. Copy the shared secret
|
||||||
|
5. Back in RevenueCat, go to **Project Settings** → **Apps** → your iOS app
|
||||||
|
6. Paste the shared secret in **App Store Connect App-Specific Shared Secret**
|
||||||
|
7. Click **Save Changes**
|
||||||
|
|
||||||
|
### Step 5: Create Products in RevenueCat
|
||||||
|
|
||||||
|
1. Go to **Products** (left sidebar)
|
||||||
|
2. Click **+ New Product** for each:
|
||||||
|
|
||||||
|
**Product 1 - Monthly:**
|
||||||
|
- **Identifier**: `com.mbrucedogs.SelfieCam.pro.monthly` (must match App Store Connect exactly)
|
||||||
|
- **App**: SelfieCam (iOS)
|
||||||
|
- Click **Add**
|
||||||
|
|
||||||
|
**Product 2 - Yearly:**
|
||||||
|
- **Identifier**: `com.mbrucedogs.SelfieCam.pro.yearly`
|
||||||
|
- **App**: SelfieCam (iOS)
|
||||||
|
- Click **Add**
|
||||||
|
|
||||||
|
**Product 3 - Lifetime:**
|
||||||
|
- **Identifier**: `com.mbrucedogs.SelfieCam.pro.lifetime`
|
||||||
|
- **App**: SelfieCam (iOS)
|
||||||
|
- Click **Add**
|
||||||
|
|
||||||
|
### Step 6: Create an Entitlement
|
||||||
|
|
||||||
|
Entitlements represent what the user "gets" - your code checks for this.
|
||||||
|
|
||||||
|
1. Go to **Entitlements** (left sidebar)
|
||||||
|
2. Click **+ New Entitlement**
|
||||||
|
3. Enter identifier: `Selfie Cam by TopDog Pro` (this matches the code in `PremiumManager.swift`)
|
||||||
|
4. Click **Add**
|
||||||
|
5. Now attach all 3 products:
|
||||||
|
- Click on the `Selfie Cam by TopDog Pro` entitlement
|
||||||
|
- Click **Attach Products**
|
||||||
|
- Select all 3 products (monthly, yearly, lifetime)
|
||||||
|
- Click **Attach**
|
||||||
|
|
||||||
|
### Step 7: Create an Offering
|
||||||
|
|
||||||
|
Offerings group products for display in your paywall.
|
||||||
|
|
||||||
|
1. Go to **Offerings** (left sidebar)
|
||||||
|
2. You'll see a default offering already exists
|
||||||
|
3. Click on **default** offering
|
||||||
|
4. Click **+ New Package** for each:
|
||||||
|
|
||||||
|
**Package 1:**
|
||||||
|
- **Identifier**: Select `$rc_monthly` from dropdown
|
||||||
|
- **Product**: Select your monthly product
|
||||||
|
- Click **Add**
|
||||||
|
|
||||||
|
**Package 2:**
|
||||||
|
- **Identifier**: Select `$rc_annual` from dropdown
|
||||||
|
- **Product**: Select your yearly product
|
||||||
|
- Click **Add**
|
||||||
|
|
||||||
|
**Package 3:**
|
||||||
|
- **Identifier**: Select `$rc_lifetime` from dropdown
|
||||||
|
- **Product**: Select your lifetime product
|
||||||
|
- Click **Add**
|
||||||
|
|
||||||
|
### Step 8: Get Your API Key
|
||||||
|
|
||||||
|
1. Go to **Project Settings** (gear icon) → **API Keys**
|
||||||
|
2. Copy your **Public App-Specific API Key** (starts with `appl_`)
|
||||||
|
3. In your Xcode project, add this to `Configuration/Secrets.xcconfig`:
|
||||||
|
|
||||||
|
```
|
||||||
|
REVENUECAT_API_KEY = appl_your_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Important**: Never commit `Secrets.xcconfig` to git. It should be in your `.gitignore`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3: Testing Purchases
|
||||||
|
|
||||||
|
There are three ways to test purchases without paying real money.
|
||||||
|
|
||||||
|
### Option A: Debug Premium Toggle (Fastest - No Setup Required)
|
||||||
|
|
||||||
|
Use the built-in debug toggle to bypass premium checks entirely:
|
||||||
|
|
||||||
|
1. Run the app in DEBUG mode
|
||||||
|
2. Go to **Settings** → scroll to **Debug** section
|
||||||
|
3. Toggle **Enable Debug Premium** on
|
||||||
|
4. All premium features are now unlocked
|
||||||
|
|
||||||
|
This is useful for testing the UI but doesn't test the actual purchase flow.
|
||||||
|
|
||||||
|
### Option B: StoreKit Configuration File (Local Testing)
|
||||||
|
|
||||||
|
Test purchases locally without App Store Connect:
|
||||||
|
|
||||||
|
1. **Create a StoreKit Configuration File**
|
||||||
|
- In Xcode: File → New → File → **StoreKit Configuration File**
|
||||||
|
- Name it `Products.storekit`
|
||||||
|
- Save it in the SelfieCam project folder
|
||||||
|
|
||||||
|
2. **Add Your Products**
|
||||||
|
Click **+** in the editor and add:
|
||||||
|
|
||||||
|
| Type | Reference Name | Product ID |
|
||||||
|
|------|---------------|------------|
|
||||||
|
| Auto-Renewable Subscription | Pro Monthly | `com.mbrucedogs.SelfieCam.pro.monthly` |
|
||||||
|
| Auto-Renewable Subscription | Pro Yearly | `com.mbrucedogs.SelfieCam.pro.yearly` |
|
||||||
|
| Non-Consumable | Pro Lifetime | `com.mbrucedogs.SelfieCam.pro.lifetime` |
|
||||||
|
|
||||||
|
For subscriptions, create a subscription group called "SelfieCam Pro" first.
|
||||||
|
|
||||||
|
3. **Enable It in Your Scheme**
|
||||||
|
- Product → Scheme → Edit Scheme (or ⌘<)
|
||||||
|
- Select **Run** → **Options** tab
|
||||||
|
- Set **StoreKit Configuration** to your `Products.storekit` file
|
||||||
|
|
||||||
|
4. **Test Purchases**
|
||||||
|
- Run the app in Simulator or on device
|
||||||
|
- Purchases are instant and free
|
||||||
|
- Manage transactions: Debug → StoreKit → Manage Transactions
|
||||||
|
|
||||||
|
> **Note**: StoreKit Configuration testing works with RevenueCat but transactions won't appear in the RevenueCat dashboard.
|
||||||
|
|
||||||
|
### Option C: Sandbox Testing (Full Integration Test)
|
||||||
|
|
||||||
|
Test the complete flow with App Store Connect and RevenueCat:
|
||||||
|
|
||||||
|
1. **Create a Sandbox Tester Account**
|
||||||
|
- Go to [App Store Connect](https://appstoreconnect.apple.com) → **Users and Access** → **Sandbox** → **Testers**
|
||||||
|
- Click **+** to add a new tester
|
||||||
|
- Use a fake email you control (not a real Apple ID)
|
||||||
|
- Set a password you'll remember
|
||||||
|
|
||||||
|
2. **Sign Out of App Store on Your Test Device**
|
||||||
|
- Settings → App Store → Tap your Apple ID → Sign Out
|
||||||
|
- **Don't sign back in yet**
|
||||||
|
|
||||||
|
3. **Run Your App and Make a Purchase**
|
||||||
|
- Run the app on a physical device (recommended) or simulator
|
||||||
|
- Tap a purchase button
|
||||||
|
- When prompted to sign in, use your sandbox tester credentials
|
||||||
|
- Complete the purchase - it's free!
|
||||||
|
|
||||||
|
4. **Verify in RevenueCat**
|
||||||
|
- Go to RevenueCat dashboard → **Customers**
|
||||||
|
- Search for your sandbox user
|
||||||
|
- You should see their entitlement and transaction
|
||||||
|
|
||||||
|
**Sandbox Subscription Renewal Times:**
|
||||||
|
|
||||||
|
| Real Duration | Sandbox Duration |
|
||||||
|
|--------------|------------------|
|
||||||
|
| 1 week | 3 minutes |
|
||||||
|
| 1 month | 5 minutes |
|
||||||
|
| 2 months | 10 minutes |
|
||||||
|
| 3 months | 15 minutes |
|
||||||
|
| 6 months | 30 minutes |
|
||||||
|
| 1 year | 1 hour |
|
||||||
|
|
||||||
|
Subscriptions auto-renew up to 6 times in sandbox, then expire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup Checklist
|
||||||
|
|
||||||
|
### App Store Connect
|
||||||
|
- [ ] App created with correct bundle ID
|
||||||
|
- [ ] Subscription group created (`SelfieCam Pro`)
|
||||||
|
- [ ] Monthly subscription created with price and localization
|
||||||
|
- [ ] Yearly subscription created with price and localization
|
||||||
|
- [ ] Lifetime non-consumable created with price and localization
|
||||||
|
- [ ] Paid Apps agreement accepted
|
||||||
|
- [ ] Bank and tax info submitted
|
||||||
|
- [ ] Sandbox tester account created
|
||||||
|
|
||||||
|
### RevenueCat
|
||||||
|
- [ ] Account created
|
||||||
|
- [ ] Project created
|
||||||
|
- [ ] iOS app added with bundle ID
|
||||||
|
- [ ] Shared secret from App Store Connect added
|
||||||
|
- [ ] 3 products created (matching App Store Connect IDs exactly)
|
||||||
|
- [ ] `pro` entitlement created
|
||||||
|
- [ ] All 3 products attached to `pro` entitlement
|
||||||
|
- [ ] Default offering has 3 packages (monthly, annual, lifetime)
|
||||||
|
- [ ] API key copied to `Secrets.xcconfig`
|
||||||
|
|
||||||
|
### Xcode
|
||||||
|
- [ ] `Secrets.xcconfig` contains `REVENUECAT_API_KEY`
|
||||||
|
- [ ] `Secrets.xcconfig` is in `.gitignore`
|
||||||
|
- [ ] (Optional) StoreKit Configuration file created for local testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Products Not Loading
|
||||||
|
|
||||||
|
- Verify product IDs match exactly between App Store Connect and RevenueCat
|
||||||
|
- Check that the RevenueCat API key is correctly set in `Secrets.xcconfig`
|
||||||
|
- Ensure the shared secret is added in RevenueCat
|
||||||
|
- Products may take a few minutes to propagate after creation
|
||||||
|
|
||||||
|
### Sandbox Purchases Failing
|
||||||
|
|
||||||
|
- Make sure you're signed out of the regular App Store
|
||||||
|
- Use sandbox credentials, not your real Apple ID
|
||||||
|
- Check that agreements are accepted in App Store Connect
|
||||||
|
- Verify the device isn't in a restricted region
|
||||||
|
|
||||||
|
### RevenueCat Not Showing Transactions
|
||||||
|
|
||||||
|
- Sandbox transactions can take a minute to appear
|
||||||
|
- Verify the bundle ID matches exactly
|
||||||
|
- Check that products are attached to the entitlement
|
||||||
|
- Look at RevenueCat's debug logs in Xcode console
|
||||||
|
|
||||||
|
### "Missing Metadata" in App Store Connect
|
||||||
|
|
||||||
|
This is normal until you submit your app for review. Products will work in sandbox despite this warning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Reference
|
||||||
|
|
||||||
|
The following files handle in-app purchases:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `Shared/Premium/PremiumManager.swift` | RevenueCat integration, purchase logic, customer info listener |
|
||||||
|
| `Shared/Premium/PaywallPresenter.swift` | RevenueCat native Paywall with custom fallback |
|
||||||
|
| `Features/Paywall/Views/ProPaywallView.swift` | Custom fallback paywall UI |
|
||||||
|
| `Features/Onboarding/Views/OnboardingSoftPaywallView.swift` | Onboarding soft paywall |
|
||||||
|
| `Features/Settings/Views/SettingsView.swift` | Pro section with Customer Center |
|
||||||
|
| `Configuration/Secrets.xcconfig` | RevenueCat API key (not committed to git) |
|
||||||
|
|
||||||
|
The code checks for a single entitlement called `"Selfie Cam by TopDog Pro"`. All three products (monthly, yearly, lifetime) grant this same entitlement, so the app doesn't need to know which one the user purchased.
|
||||||
|
|
||||||
|
### RevenueCat Features Used
|
||||||
|
|
||||||
|
- **RevenueCat SDK** - Core purchase and subscription management
|
||||||
|
- **RevenueCatUI** - Native paywalls and Customer Center
|
||||||
|
- **PaywallView** - Remote-configurable paywall designed in RevenueCat dashboard
|
||||||
|
- **Customer Center** - Subscription management UI (view plan, cancel, request refund)
|
||||||
Loading…
Reference in New Issue
Block a user