Compare commits

...

7 Commits

23 changed files with 2381 additions and 183 deletions

37
.gitignore vendored Normal file
View 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

View File

@ -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.

58
Info.plist Normal file
View 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>

View File

@ -435,20 +435,8 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_KEY_AppClipDomain = "$(APPCLIP_DOMAIN)"; INFOPLIST_FILE = Info.plist;
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";
IPHONEOS_DEPLOYMENT_TARGET = 18.6; IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -477,20 +465,8 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_KEY_AppClipDomain = "$(APPCLIP_DOMAIN)"; INFOPLIST_FILE = Info.plist;
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";
IPHONEOS_DEPLOYMENT_TARGET = 18.6; IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",

View File

@ -78,7 +78,7 @@
<EnvironmentVariable <EnvironmentVariable
key = "ENABLE_DEBUG_PREMIUM" key = "ENABLE_DEBUG_PREMIUM"
value = "1" value = "1"
isEnabled = "YES"> isEnabled = "NO">
</EnvironmentVariable> </EnvironmentVariable>
</EnvironmentVariables> </EnvironmentVariables>
</LaunchAction> </LaunchAction>

View File

@ -44,7 +44,15 @@ struct RootView: View {
} }
} }
.sheet(isPresented: $showPaywall) { .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
}
}
}
} }
} }
} }

View File

@ -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()
}
} }
} }
} }

View File

@ -2,11 +2,4 @@
// 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
// 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)

View File

@ -2,11 +2,4 @@
// 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
// 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)

View File

@ -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

View File

@ -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

View File

@ -11,7 +11,6 @@ import Bedrock
struct ContentView: View { struct ContentView: View {
@State private var settings = SettingsViewModel() @State private var settings = SettingsViewModel()
@State private var premiumManager = PremiumManager()
@State private var showSettings = false @State private var showSettings = false
@State private var showPaywall = false @State private var showPaywall = false
@ -112,8 +111,12 @@ struct ContentView: View {
}) { }) {
SettingsView(viewModel: settings, showPaywall: $showPaywall) SettingsView(viewModel: settings, showPaywall: $showPaywall)
} }
.sheet(isPresented: $showPaywall) { .sheet(isPresented: $showPaywall, onDismiss: {
ProPaywallView() // Force refresh of premium status after paywall closes
// This ensures SettingsViewModel and UI see the updated premium status
settings.refreshPremiumStatus()
}) {
PaywallPresenter()
} }
} }

View File

@ -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)

View File

@ -3,62 +3,37 @@ import RevenueCat
import Bedrock import Bedrock
struct ProPaywallView: View { struct ProPaywallView: View {
/// Callback triggered when a purchase or restore is successful
var onPurchaseSuccess: (() -> Void)?
@State private var manager = PremiumManager() @State private var manager = PremiumManager()
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = Design.FontSize.body @ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = Design.FontSize.body
/// Error message to display
@State private var errorMessage: String?
/// Whether to show the error alert
@State private var showError = false
/// Whether a purchase is in progress
@State private var isPurchasing = false
/// Whether a restore is in progress
@State private var isRestoring = false
/// Currently selected package
@State private var selectedPackage: Package?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
VStack(spacing: Design.Spacing.xLarge) { VStack(spacing: Design.Spacing.xLarge) {
// Crown icon header
Image(systemName: "crown.fill") benefitsCard
.font(.system(size: Design.FontSize.hero)) packageSelection
.foregroundStyle(.yellow) purchaseCTA
restoreButton
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)
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
} }
@ -68,48 +43,300 @@ struct ProPaywallView: View {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "Cancel")) { dismiss() } Button(String(localized: "Cancel")) { dismiss() }
.foregroundStyle(.white) .foregroundStyle(.white)
.disabled(isPurchasing || isRestoring)
} }
} }
} }
.font(.system(size: bodyFontSize)) .font(.system(size: bodyFontSize))
.task { try? await manager.loadProducts() } .task {
try? await manager.loadProducts()
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 package: Package
let isPremiumUnlocked: Bool let isSelected: Bool
let onPurchase: () -> Void let isDisabled: Bool
let onSelect: () -> Void
private var isLifetime: Bool {
package.packageType == .lifetime
}
private var isAnnual: Bool {
package.packageType == .annual
}
var body: some View { var body: some View {
Button(action: onPurchase) { Button(action: onSelect) {
VStack(spacing: Design.Spacing.small) { HStack(spacing: Design.Spacing.medium) {
Text(package.storeProduct.localizedTitle) Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.headline) .font(.title3)
.foregroundStyle(.white) .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) Text(package.localizedPriceString)
.font(.title2.bold()) .font(.title3.bold())
.foregroundStyle(.white) .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) .padding(Design.Spacing.large)
.background(AppAccent.primary.opacity(Design.Opacity.medium)) .frame(maxWidth: .infinity)
.background(AppSurface.card)
.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(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 { var body: some View {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
Image(systemName: image) ZStack {
.font(.title2) Circle()
.foregroundStyle(AppAccent.primary) .fill(AppAccent.primary.opacity(Design.Opacity.subtle))
.frame(width: Design.IconSize.xLarge) .frame(width: 32, height: 32)
Image(systemName: image)
.font(.body.bold())
.foregroundStyle(AppAccent.primary)
}
Text(text) Text(text)
.foregroundStyle(.white) .foregroundStyle(.white)

View File

@ -21,9 +21,6 @@ extension SettingsViewModel {
let wasPremium = premiumManager.isDebugPremiumToggleEnabled let wasPremium = premiumManager.isDebugPremiumToggleEnabled
premiumManager.isDebugPremiumToggleEnabled = newValue premiumManager.isDebugPremiumToggleEnabled = newValue
// Update premium status for UI refresh
isPremiumUnlocked = premiumManager.isPremiumUnlocked
// Reset premium settings when toggling OFF // Reset premium settings when toggling OFF
if wasPremium && !newValue { if wasPremium && !newValue {
resetPremiumSettingsToDefaults() resetPremiumSettingsToDefaults()

View File

@ -76,13 +76,22 @@ final class SettingsViewModel: RingLightConfigurable {
// MARK: - Premium Status // MARK: - Premium Status
/// Whether the user has premium access (stored property for proper UI updates) /// Refresh token that changes when premium status needs to be re-evaluated
private var _isPremiumUnlocked: Bool = false /// 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 { var isPremiumUnlocked: Bool {
get { _isPremiumUnlocked } // Access refresh token to create observation dependency
set { _isPremiumUnlocked = newValue } _ = 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 // MARK: - Display Settings
@ -128,9 +137,8 @@ final class SettingsViewModel: RingLightConfigurable {
// MARK: - Initialization // MARK: - Initialization
init() { init() {
// Initialize premium status
_isPremiumUnlocked = premiumManager.isPremiumUnlocked
// CloudSyncManager handles syncing automatically // CloudSyncManager handles syncing automatically
// Premium status is always computed from premiumManager.isPremiumUnlocked
} }
// MARK: - Internal Methods // MARK: - Internal Methods

View File

@ -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

View File

@ -498,8 +498,13 @@
"comment" : "Subtitle of a premium feature card that offers skin smoothing.", "comment" : "Subtitle of a premium feature card that offers skin smoothing.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Best Value" : {
"comment" : "A label indicating that a subscription is the best value for the user.",
"isCommentAutoGenerated" : true
},
"Best Value • Save 33%" : { "Best Value • Save 33%" : {
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.", "comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "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" : { "Better lighting" : {
"comment" : "Subtitle for a premium feature card that allows users to take photos in better lighting.", "comment" : "Subtitle for a premium feature card that allows users to take photos in better lighting.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Billed monthly" : {
}, },
"Boomerang" : { "Boomerang" : {
"comment" : "Display name for the \"Boomerang\" capture mode.", "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" : { "Captured photo" : {
"comment" : "A label describing a captured photo.", "comment" : "A label describing a captured photo.",
"extractionState" : "stale", "extractionState" : "stale",
@ -751,6 +767,7 @@
}, },
"Center Stage Auto-Framing" : { "Center Stage Auto-Framing" : {
"comment" : "Benefit of the \"Go Pro\" premium package: Automatic centering of the subject in the photo.", "comment" : "Benefit of the \"Go Pro\" premium package: Automatic centering of the subject in the photo.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
@ -1293,6 +1310,7 @@
}, },
"Flash Sync with Ring Light" : { "Flash Sync with Ring Light" : {
"comment" : "Benefit description for the \"Flash Sync with Ring Light\" feature.", "comment" : "Benefit description for the \"Flash Sync with Ring Light\" feature.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
@ -1426,6 +1444,7 @@
}, },
"Go Pro" : { "Go Pro" : {
"comment" : "The title of the \"Go Pro\" button in the Pro paywall.", "comment" : "The title of the \"Go Pro\" button in the Pro paywall.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "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" : { "Maybe Later" : {
"comment" : "Text for a button that allows a user to dismiss a paywall without purchasing a premium subscription.", "comment" : "Text for a button that allows a user to dismiss a paywall without purchasing a premium subscription.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -1793,6 +1816,10 @@
"comment" : "A message displayed in an alert when the \"Reset Onboarding\" button is tapped.", "comment" : "A message displayed in an alert when the \"Reset Onboarding\" button is tapped.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"One Time" : {
"comment" : "A description of a one-time purchase option.",
"isCommentAutoGenerated" : true
},
"Open Settings" : { "Open Settings" : {
"comment" : "Text for an action button that opens the user's device settings to grant a denied permission.", "comment" : "Text for an action button that opens the user's device settings to grant a denied permission.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -1845,6 +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" : { "Perfect lighting for every shot" : {
"comment" : "Subtitle for a feature highlight that emphasizes the app's ability to provide perfect lighting for every selfie.", "comment" : "Subtitle for a feature highlight that emphasizes the app's ability to provide perfect lighting for every selfie.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -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" : { "Premium color" : {
"comment" : "An accessibility hint for a premium color option in the color preset button.", "comment" : "An accessibility hint for a premium color option in the color preset button.",
"isCommentAutoGenerated" : true, "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" : { "Pro Features" : {
"comment" : "Subtitle for a feature row in the \"Welcome\" view, highlighting the app's advanced features.", "comment" : "Subtitle for a feature row in the \"Welcome\" view, highlighting the app's advanced features.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Pro subscription active" : {
"comment" : "An accessibility label for the section of the settings view that displays that their Pro subscription is active.",
"isCommentAutoGenerated" : true
},
"Purchase Error" : {
"comment" : "The title of an alert that appears when there is an error during a purchase process.",
"isCommentAutoGenerated" : true
},
"Purchase successful! Pro features unlocked." : { "Purchase successful! Pro features unlocked." : {
"comment" : "Announcement read out to the user when a premium purchase is successful.", "comment" : "Announcement read out to the user when a premium purchase is successful.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -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" : { "Select camera position" : {
"comment" : "A label describing the action of selecting a camera position.", "comment" : "A label describing the action of selecting a camera position.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -2898,6 +2970,7 @@
}, },
"Subscribe to %@ for %@" : { "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.", "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, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"en" : { "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" : { "Tap to collapse settings" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "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.", "comment" : "Title of a section in the onboarding soft paywall that describes the benefits of upgrading to a premium account.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Unlock SelfieCam Pro" : {
"comment" : "The title of the paywall view.",
"isCommentAutoGenerated" : true
},
"Upgrade to Pro" : { "Upgrade to Pro" : {
"comment" : "A button label that prompts users to upgrade to the premium version of the app.", "comment" : "A button label that prompts users to upgrade to the premium version of the app.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,

View 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)
}

View File

@ -9,15 +9,37 @@ 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"
/// 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 { private static var apiKey: String {
guard let key = Bundle.main.object(forInfoDictionaryKey: "RevenueCatAPIKey") as? String, let key = (Bundle.main.object(forInfoDictionaryKey: "RevenueCatAPIKey") as? String) ?? ""
!key.isEmpty, let placeholders = [
key != "your_revenuecat_public_api_key_here" else { "",
"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 #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 #endif
return "" return ""
} }
@ -153,4 +175,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
}
} }

View 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)

File diff suppressed because it is too large Load Diff