Compare commits
7 Commits
658423ee16
...
4537a40cea
| Author | SHA1 | Date | |
|---|---|---|---|
| 4537a40cea | |||
| ac35666208 | |||
| c153b16593 | |||
| d62cc6e88e | |||
| 7fff295913 | |||
| a94404d93d | |||
| 3c7f8a39db |
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
# 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
|
||||
**/Secrets.swift
|
||||
|
||||
# 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.
|
||||
|
||||
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.
|
||||
58
Info.plist
Normal file
58
Info.plist
Normal file
@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>SelfieCam needs microphone access for the camera framework to initialize properly. Audio is not recorded.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>SelfieCam needs photo library access to automatically save your captured photos to your device, making them available in the Photos app and other compatible applications.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>RevenueCatAPIKey</key>
|
||||
<string>$(REVENUECAT_API_KEY)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@ -435,20 +435,8 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_AppClipDomain = "$(APPCLIP_DOMAIN)";
|
||||
INFOPLIST_KEY_AppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_NAME)";
|
||||
INFOPLIST_KEY_CloudKitContainerIdentifier = "$(CLOUDKIT_CONTAINER_IDENTIFIER)";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access for the camera framework to initialize properly. Audio is not recorded.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos to your device, making them available in the Photos app and other compatible applications.";
|
||||
INFOPLIST_KEY_PublicAppName = "$(PRODUCT_NAME)";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@ -477,20 +465,8 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_AppClipDomain = "$(APPCLIP_DOMAIN)";
|
||||
INFOPLIST_KEY_AppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_NAME)";
|
||||
INFOPLIST_KEY_CloudKitContainerIdentifier = "$(CLOUDKIT_CONTAINER_IDENTIFIER)";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access for the camera framework to initialize properly. Audio is not recorded.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos to your device, making them available in the Photos app and other compatible applications.";
|
||||
INFOPLIST_KEY_PublicAppName = "$(PRODUCT_NAME)";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
||||
Binary file not shown.
@ -78,7 +78,7 @@
|
||||
<EnvironmentVariable
|
||||
key = "ENABLE_DEBUG_PREMIUM"
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
|
||||
@ -44,7 +44,15 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPaywall) {
|
||||
ProPaywallView()
|
||||
PaywallPresenter {
|
||||
// Purchase successful during onboarding - complete it
|
||||
if !hasCompletedOnboarding {
|
||||
onboardingViewModel.completeOnboarding(settingsViewModel: settingsViewModel)
|
||||
withAnimation(.easeInOut(duration: Design.Animation.standard)) {
|
||||
hasCompletedOnboarding = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,9 @@ import Bedrock
|
||||
|
||||
@main
|
||||
struct SelfieCamApp: App {
|
||||
/// Premium manager for checking subscription status on launch
|
||||
@State private var premiumManager = PremiumManager()
|
||||
|
||||
init() {
|
||||
Design.showDebugLogs = true
|
||||
|
||||
@ -37,6 +40,13 @@ struct SelfieCamApp: App {
|
||||
// Set screen brightness to 100% on app launch
|
||||
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,11 +2,4 @@
|
||||
// Configuration for Debug builds
|
||||
|
||||
#include "Base.xcconfig"
|
||||
#include? "Secrets.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)
|
||||
#include "Secrets.debug.xcconfig"
|
||||
|
||||
@ -2,11 +2,4 @@
|
||||
// Configuration for Release builds
|
||||
|
||||
#include "Base.xcconfig"
|
||||
#include? "Secrets.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)
|
||||
#include "Secrets.release.xcconfig"
|
||||
|
||||
@ -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 +0,0 @@
|
||||
// Secrets.xcconfig.template
|
||||
//
|
||||
// INSTRUCTIONS:
|
||||
// 1. Copy this file to "Secrets.xcconfig" in the same directory
|
||||
// 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.
|
||||
|
||||
// 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
|
||||
@ -11,7 +11,6 @@ import Bedrock
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var settings = SettingsViewModel()
|
||||
@State private var premiumManager = PremiumManager()
|
||||
@State private var showSettings = false
|
||||
@State private var showPaywall = false
|
||||
|
||||
@ -112,8 +111,12 @@ struct ContentView: View {
|
||||
}) {
|
||||
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
||||
}
|
||||
.sheet(isPresented: $showPaywall) {
|
||||
ProPaywallView()
|
||||
.sheet(isPresented: $showPaywall, onDismiss: {
|
||||
// Force refresh of premium status after paywall closes
|
||||
// This ensures SettingsViewModel and UI see the updated premium status
|
||||
settings.refreshPremiumStatus()
|
||||
}) {
|
||||
PaywallPresenter()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,12 @@ struct OnboardingSoftPaywallView: View {
|
||||
@Binding var showPaywall: Bool
|
||||
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 {
|
||||
OnboardingContentContainer {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
@ -87,6 +93,31 @@ struct OnboardingSoftPaywallView: View {
|
||||
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(.bottom, Design.Spacing.xLarge)
|
||||
|
||||
@ -3,62 +3,37 @@ 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
|
||||
|
||||
/// Currently selected package
|
||||
@State private var selectedPackage: Package?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: Design.Spacing.xLarge) {
|
||||
// Crown icon
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: Design.FontSize.hero))
|
||||
.foregroundStyle(.yellow)
|
||||
|
||||
Text(String(localized: "Go Pro"))
|
||||
.font(.system(size: Design.FontSize.title, weight: .bold))
|
||||
|
||||
// Benefits list
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
BenefitRow(image: "paintpalette.fill", text: String(localized: "Premium Colors + Custom Color Picker"))
|
||||
BenefitRow(image: "sparkles", text: String(localized: "Skin Smoothing Beauty Filter"))
|
||||
BenefitRow(image: "arrow.left.and.right.righttriangle.left.righttriangle.right.fill", text: String(localized: "True Mirror Mode"))
|
||||
BenefitRow(image: "bolt.fill", text: String(localized: "Flash Sync with Ring Light"))
|
||||
BenefitRow(image: "camera.filters", text: String(localized: "HDR Mode for Better Photos"))
|
||||
BenefitRow(image: "person.crop.rectangle.fill", text: String(localized: "Center Stage Auto-Framing"))
|
||||
BenefitRow(image: "timer", text: String(localized: "Extended Self-Timers (5s, 10s)"))
|
||||
BenefitRow(image: "star.fill", text: String(localized: "High Quality Photo Export"))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Product packages
|
||||
if manager.availablePackages.isEmpty {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(manager.availablePackages, id: \.identifier) { package in
|
||||
ProductPackageButton(
|
||||
package: package,
|
||||
isPremiumUnlocked: manager.isPremiumUnlocked,
|
||||
onPurchase: {
|
||||
Task {
|
||||
_ = try? await manager.purchase(package)
|
||||
if manager.isPremiumUnlocked {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore purchases
|
||||
Button(String(localized: "Restore Purchases")) {
|
||||
Task { try? await manager.restorePurchases() }
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
header
|
||||
benefitsCard
|
||||
packageSelection
|
||||
purchaseCTA
|
||||
restoreButton
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
}
|
||||
@ -68,48 +43,300 @@ 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() }
|
||||
.task {
|
||||
try? await manager.loadProducts()
|
||||
if selectedPackage == nil {
|
||||
selectedPackage = preferredPackage(from: manager.availablePackages)
|
||||
}
|
||||
}
|
||||
.onChange(of: manager.availablePackages) { _, newValue in
|
||||
if selectedPackage == nil {
|
||||
selectedPackage = preferredPackage(from: newValue)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
String(localized: "Purchase Error"),
|
||||
isPresented: $showError,
|
||||
presenting: errorMessage
|
||||
) { _ in
|
||||
Button(String(localized: "OK"), role: .cancel) {
|
||||
errorMessage = nil
|
||||
}
|
||||
} message: { message in
|
||||
Text(message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppAccent.primary.opacity(Design.Opacity.subtle))
|
||||
.frame(width: 72, height: 72)
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: Design.FontSize.large, weight: .bold))
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
}
|
||||
|
||||
Text(String(localized: "Unlock SelfieCam Pro"))
|
||||
.font(.system(size: Design.FontSize.title, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(String(localized: "Premium tools for better selfies"))
|
||||
.font(.system(size: Design.FontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var benefitsCard: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
BenefitRow(image: "paintpalette.fill", text: String(localized: "Premium Colors + Custom Color Picker"))
|
||||
BenefitRow(image: "sparkles", text: String(localized: "Skin Smoothing Beauty Filter"))
|
||||
BenefitRow(image: "arrow.left.and.right.righttriangle.left.righttriangle.right.fill", text: String(localized: "True Mirror Mode"))
|
||||
BenefitRow(image: "camera.filters", text: String(localized: "HDR Mode for Better Photos"))
|
||||
BenefitRow(image: "timer", text: String(localized: "Extended Self-Timers (5s, 10s)"))
|
||||
BenefitRow(image: "star.fill", text: String(localized: "High Quality Photo Export"))
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(AppSurface.card)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||
.strokeBorder(AppBorder.subtle, lineWidth: Design.LineWidth.thin)
|
||||
)
|
||||
}
|
||||
|
||||
private var packageSelection: some View {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
if manager.availablePackages.isEmpty {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(manager.availablePackages, id: \.identifier) { package in
|
||||
PackageOptionRow(
|
||||
package: package,
|
||||
isSelected: selectedPackage?.identifier == package.identifier,
|
||||
isDisabled: isPurchasing,
|
||||
onSelect: { selectedPackage = package }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var purchaseCTA: some View {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Button {
|
||||
guard let selectedPackage else {
|
||||
errorMessage = String(localized: "Please select a plan.")
|
||||
showError = true
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await purchasePackage(selectedPackage)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
if isPurchasing {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
Text(String(localized: "Continue"))
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Design.Spacing.large)
|
||||
.background(AppAccent.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
}
|
||||
.disabled(isPurchasing || isRestoring || selectedPackage == nil)
|
||||
|
||||
Text(String(localized: "Cancel anytime. Payment will be charged to your Apple ID."))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
private var restoreButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
await restorePurchases()
|
||||
}
|
||||
} label: {
|
||||
if isRestoring {
|
||||
ProgressView()
|
||||
.tint(.secondary)
|
||||
} else {
|
||||
Text(String(localized: "Restore Purchases"))
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(isRestoring || isPurchasing)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func preferredPackage(from packages: [Package]) -> Package? {
|
||||
if let annual = packages.first(where: { $0.packageType == .annual }) {
|
||||
return annual
|
||||
}
|
||||
if let lifetime = packages.first(where: { $0.packageType == .lifetime }) {
|
||||
return lifetime
|
||||
}
|
||||
return packages.first
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Product Package Button
|
||||
// MARK: - Package Option Row
|
||||
|
||||
private struct ProductPackageButton: View {
|
||||
private struct PackageOptionRow: View {
|
||||
let package: Package
|
||||
let isPremiumUnlocked: Bool
|
||||
let onPurchase: () -> Void
|
||||
let isSelected: Bool
|
||||
let isDisabled: Bool
|
||||
let onSelect: () -> Void
|
||||
|
||||
private var isLifetime: Bool {
|
||||
package.packageType == .lifetime
|
||||
}
|
||||
|
||||
private var isAnnual: Bool {
|
||||
package.packageType == .annual
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onPurchase) {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text(package.storeProduct.localizedTitle)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
Button(action: onSelect) {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(isSelected ? AppAccent.primary : .white.opacity(Design.Opacity.medium))
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Text(package.storeProduct.localizedTitle)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if isAnnual {
|
||||
Text(String(localized: "Best Value"))
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
.padding(.horizontal, Design.Spacing.xSmall)
|
||||
.padding(.vertical, Design.Spacing.xxSmall)
|
||||
.background(AppStatus.warning.opacity(Design.Opacity.subtle))
|
||||
.clipShape(Capsule())
|
||||
} else if isLifetime {
|
||||
Text(String(localized: "One Time"))
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.padding(.horizontal, Design.Spacing.xSmall)
|
||||
.padding(.vertical, Design.Spacing.xxSmall)
|
||||
.background(AppAccent.primary.opacity(Design.Opacity.subtle))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Text(packageSubtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(package.localizedPriceString)
|
||||
.font(.title2.bold())
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if package.packageType == .annual {
|
||||
Text(String(localized: "Best Value • Save 33%"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.accent))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Design.Spacing.large)
|
||||
.background(AppAccent.primary.opacity(Design.Opacity.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(AppSurface.card)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||
.strokeBorder(AppAccent.primary, lineWidth: Design.LineWidth.thin)
|
||||
.strokeBorder(isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: Design.LineWidth.thin)
|
||||
)
|
||||
.opacity(isDisabled ? 0.6 : 1.0)
|
||||
}
|
||||
.accessibilityLabel(String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)"))
|
||||
.disabled(isDisabled)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
}
|
||||
|
||||
private var packageSubtitle: String {
|
||||
if isLifetime {
|
||||
return String(localized: "Pay once, own forever")
|
||||
}
|
||||
if isAnnual {
|
||||
return String(localized: "Best value yearly plan")
|
||||
}
|
||||
return String(localized: "Billed monthly")
|
||||
}
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
if isLifetime {
|
||||
return String(localized: "Select \(package.storeProduct.localizedTitle) for \(package.localizedPriceString), one-time payment")
|
||||
}
|
||||
return String(localized: "Select \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,10 +348,14 @@ struct BenefitRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: image)
|
||||
.font(.title2)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.frame(width: Design.IconSize.xLarge)
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppAccent.primary.opacity(Design.Opacity.subtle))
|
||||
.frame(width: 32, height: 32)
|
||||
Image(systemName: image)
|
||||
.font(.body.bold())
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
|
||||
Text(text)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
@ -21,9 +21,6 @@ extension SettingsViewModel {
|
||||
let wasPremium = premiumManager.isDebugPremiumToggleEnabled
|
||||
premiumManager.isDebugPremiumToggleEnabled = newValue
|
||||
|
||||
// Update premium status for UI refresh
|
||||
isPremiumUnlocked = premiumManager.isPremiumUnlocked
|
||||
|
||||
// Reset premium settings when toggling OFF
|
||||
if wasPremium && !newValue {
|
||||
resetPremiumSettingsToDefaults()
|
||||
|
||||
@ -76,13 +76,22 @@ final class SettingsViewModel: RingLightConfigurable {
|
||||
|
||||
// MARK: - Premium Status
|
||||
|
||||
/// Whether the user has premium access (stored property for proper UI updates)
|
||||
private var _isPremiumUnlocked: Bool = false
|
||||
/// Refresh token that changes when premium status needs to be re-evaluated
|
||||
/// Incrementing this forces SwiftUI to re-compute isPremiumUnlocked
|
||||
private var premiumRefreshToken: Int = 0
|
||||
|
||||
/// Whether the user has premium access (observable for UI updates)
|
||||
/// Whether the user has premium access (computed from PremiumManager)
|
||||
/// This always reads the current status from RevenueCat's cached customer info
|
||||
var isPremiumUnlocked: Bool {
|
||||
get { _isPremiumUnlocked }
|
||||
set { _isPremiumUnlocked = newValue }
|
||||
// Access refresh token to create observation dependency
|
||||
_ = premiumRefreshToken
|
||||
return premiumManager.isPremiumUnlocked
|
||||
}
|
||||
|
||||
/// Force SwiftUI to re-evaluate premium status
|
||||
/// Call this after a purchase completes or when returning from paywall
|
||||
func refreshPremiumStatus() {
|
||||
premiumRefreshToken += 1
|
||||
}
|
||||
|
||||
// MARK: - Display Settings
|
||||
@ -128,9 +137,8 @@ final class SettingsViewModel: RingLightConfigurable {
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
// Initialize premium status
|
||||
_isPremiumUnlocked = premiumManager.isPremiumUnlocked
|
||||
// CloudSyncManager handles syncing automatically
|
||||
// Premium status is always computed from premiumManager.isPremiumUnlocked
|
||||
}
|
||||
|
||||
// MARK: - Internal Methods
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import MijickCamera
|
||||
import RevenueCatUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
@Binding var showPaywall: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Whether to show RevenueCat Customer Center
|
||||
@State private var showCustomerCenter = false
|
||||
|
||||
/// Whether premium features are unlocked (for UI gating)
|
||||
private var isPremiumUnlocked: Bool {
|
||||
viewModel.isPremiumUnlocked
|
||||
@ -443,45 +447,81 @@ struct SettingsView: View {
|
||||
|
||||
// MARK: - Pro Section
|
||||
|
||||
@ViewBuilder
|
||||
private var proSection: some View {
|
||||
Button {
|
||||
dismiss()
|
||||
// Small delay to allow sheet to dismiss before showing paywall
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
showPaywall = true
|
||||
}
|
||||
} label: {
|
||||
if isPremiumUnlocked {
|
||||
// User has Pro - show status and manage link via Customer Center
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: "crown.fill")
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(AppStatus.warning)
|
||||
.foregroundStyle(AppStatus.success)
|
||||
|
||||
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))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(String(localized: "Premium colors, HDR, timers & more"))
|
||||
.font(.system(size: Design.FontSize.caption))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
Button {
|
||||
showCustomerCenter = true
|
||||
} label: {
|
||||
Text(String(localized: "Manage Subscription"))
|
||||
.font(.system(size: Design.FontSize.caption))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.fill(AppStatus.success.opacity(Design.Opacity.subtle))
|
||||
.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
|
||||
|
||||
@ -498,8 +498,13 @@
|
||||
"comment" : "Subtitle of a premium feature card that offers skin smoothing.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Best Value" : {
|
||||
"comment" : "A label indicating that a subscription is the best value for the user.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Best Value • Save 33%" : {
|
||||
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"es-MX" : {
|
||||
@ -522,9 +527,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Best value yearly plan" : {
|
||||
"comment" : "Subtitle for a package option row when the plan is billed annually.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Better lighting" : {
|
||||
"comment" : "Subtitle for a premium feature card that allows users to take photos in better lighting.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Billed monthly" : {
|
||||
|
||||
},
|
||||
"Boomerang" : {
|
||||
"comment" : "Display name for the \"Boomerang\" capture mode.",
|
||||
@ -653,6 +665,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Cancel anytime. Payment will be charged to your Apple ID." : {
|
||||
"comment" : "A footer text displayed below the \"Continue\" button in the Pro paywall.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Captured photo" : {
|
||||
"comment" : "A label describing a captured photo.",
|
||||
"extractionState" : "stale",
|
||||
@ -751,6 +767,7 @@
|
||||
},
|
||||
"Center Stage Auto-Framing" : {
|
||||
"comment" : "Benefit of the \"Go Pro\" premium package: Automatic centering of the subject in the photo.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"es-MX" : {
|
||||
@ -1293,6 +1310,7 @@
|
||||
},
|
||||
"Flash Sync with Ring Light" : {
|
||||
"comment" : "Benefit description for the \"Flash Sync with Ring Light\" feature.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"es-MX" : {
|
||||
@ -1426,6 +1444,7 @@
|
||||
},
|
||||
"Go Pro" : {
|
||||
"comment" : "The title of the \"Go Pro\" button in the Pro paywall.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"es-MX" : {
|
||||
@ -1720,6 +1739,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 +1816,10 @@
|
||||
"comment" : "A message displayed in an alert when the \"Reset Onboarding\" button is tapped.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"One Time" : {
|
||||
"comment" : "A description of a one-time purchase option.",
|
||||
"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 +1872,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
|
||||
@ -1935,6 +1966,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Please select a plan." : {
|
||||
"comment" : "Error message displayed when the user tries to purchase a package but hasn't selected one.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Premium color" : {
|
||||
"comment" : "An accessibility hint for a premium color option in the color preset button.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@ -2007,10 +2042,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Premium tools for better selfies" : {
|
||||
|
||||
},
|
||||
"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 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,
|
||||
@ -2458,6 +2508,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select %@ for %@" : {
|
||||
"comment" : "Accessibility label for a row in the \"Choose a package\" sheet. The first argument is the localized title of the package. The second argument is the localized price string of the package.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Select %1$@ for %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select %@ for %@, one-time payment" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Select %1$@ for %2$@, one-time payment"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select camera position" : {
|
||||
"comment" : "A label describing the action of selecting a camera position.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@ -2898,6 +2970,7 @@
|
||||
},
|
||||
"Subscribe to %@ for %@" : {
|
||||
"comment" : "A button that triggers a purchase of a premium content package. The label text is generated based on the package's title and price.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -3122,6 +3195,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" : {
|
||||
@ -3256,6 +3333,10 @@
|
||||
"comment" : "Title of a section in the onboarding soft paywall that describes the benefits of upgrading to a premium account.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Unlock SelfieCam Pro" : {
|
||||
"comment" : "The title of the paywall view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Upgrade to Pro" : {
|
||||
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
||||
44
SelfieCam/Shared/Premium/PaywallPresenter.swift
Normal file
44
SelfieCam/Shared/Premium/PaywallPresenter.swift
Normal file
@ -0,0 +1,44 @@
|
||||
//
|
||||
// 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 Bedrock
|
||||
|
||||
/// Presents the custom SelfieCam paywall.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// .sheet(isPresented: $showPaywall) {
|
||||
/// PaywallPresenter {
|
||||
/// // Called on successful purchase or restore
|
||||
/// print("Purchase successful!")
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
struct PaywallPresenter: View {
|
||||
/// Callback triggered when a purchase or restore is successful
|
||||
var onPurchaseSuccess: (() -> Void)?
|
||||
|
||||
/// 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 {
|
||||
ProPaywallView(onPurchaseSuccess: onPurchaseSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
PaywallPresenter()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@ -9,15 +9,37 @@ final class PremiumManager: PremiumManaging {
|
||||
// MARK: - Configuration
|
||||
|
||||
/// RevenueCat entitlement identifier - must match your RevenueCat dashboard
|
||||
private let entitlementIdentifier = "pro"
|
||||
private let entitlementIdentifier = "Selfie Cam by TopDog Pro"
|
||||
|
||||
/// Reads the RevenueCat API key from Info.plist (injected at build time from Secrets.xcconfig)
|
||||
/// Task for listening to customer info updates
|
||||
@ObservationIgnored private var customerInfoTask: Task<Void, Never>?
|
||||
|
||||
/// 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 = (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)")
|
||||
if let infoPath = Bundle.main.path(forResource: "Info", ofType: "plist"),
|
||||
let infoDict = NSDictionary(contentsOfFile: infoPath) as? [String: Any] {
|
||||
let rawValue = infoDict["RevenueCatAPIKey"] as? String ?? ""
|
||||
let rawPrefix = rawValue.split(separator: "_").first.map(String.init) ?? "missing"
|
||||
let rawStatus = rawValue.isEmpty ? "empty" : "present"
|
||||
print("ℹ️ [PremiumManager] Info.plist RevenueCatAPIKey \(rawStatus), prefix=\(rawPrefix)")
|
||||
} else {
|
||||
print("ℹ️ [PremiumManager] Info.plist not found in bundle")
|
||||
}
|
||||
#endif
|
||||
guard !placeholders.contains(key) else {
|
||||
#if DEBUG
|
||||
print("⚠️ [PremiumManager] RevenueCat API key not configured. See Configuration/Secrets.xcconfig.template")
|
||||
print("⚠️ [PremiumManager] RevenueCat API key not configured. Check Secrets.debug.xcconfig / Secrets.release.xcconfig")
|
||||
#endif
|
||||
return ""
|
||||
}
|
||||
@ -153,4 +175,55 @@ final class PremiumManager: PremiumManaging {
|
||||
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)
|
||||
1265
docs/REVENUECAT_INTEGRATION_GUIDE.md
Normal file
1265
docs/REVENUECAT_INTEGRATION_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user