diff --git a/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate b/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate index ef0de9f..6721b1f 100644 Binary files a/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate and b/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/SelfieCam/Configuration/Secrets.swift.template b/SelfieCam/Configuration/Secrets.swift.template index 0bd741e..3bd60b9 100644 --- a/SelfieCam/Configuration/Secrets.swift.template +++ b/SelfieCam/Configuration/Secrets.swift.template @@ -10,5 +10,11 @@ enum Secrets { /// RevenueCat API Key /// - Debug: Use your test/sandbox API key (starts with "test_") /// - Release: Use your production API key (starts with "appl_") - static let revenueCatAPIKey = "YOUR_REVENUECAT_API_KEY_HERE" + static let revenueCatAPIKey: String = { + #if DEBUG + return "test_YOUR_TEST_KEY_HERE" + #else + return "appl_YOUR_PRODUCTION_KEY_HERE" + #endif + }() } diff --git a/SelfieCam/Features/Paywall/Views/ProPaywallView.swift b/SelfieCam/Features/Paywall/Views/ProPaywallView.swift index 55e9e5a..260bad8 100644 --- a/SelfieCam/Features/Paywall/Views/ProPaywallView.swift +++ b/SelfieCam/Features/Paywall/Views/ProPaywallView.swift @@ -21,67 +21,19 @@ struct ProPaywallView: View { /// 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, - isLoading: isPurchasing, - onPurchase: { - Task { - await purchasePackage(package) - } - } - ) - } - } - - // Restore purchases - Button { - Task { - await restorePurchases() - } - } label: { - if isRestoring { - ProgressView() - .tint(.secondary) - } else { - Text(String(localized: "Restore Purchases")) - } - } - .font(.footnote) - .foregroundStyle(.secondary) - .disabled(isRestoring || isPurchasing) + header + benefitsCard + packageSelection + purchaseCTA + restoreButton } .padding(Design.Spacing.large) } @@ -96,7 +48,17 @@ struct ProPaywallView: View { } } .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, @@ -110,6 +72,120 @@ struct ProPaywallView: View { } } + // 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 { @@ -159,15 +235,27 @@ struct ProPaywallView: View { 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 isLoading: Bool - let onPurchase: () -> Void + let isSelected: Bool + let isDisabled: Bool + let onSelect: () -> Void private var isLifetime: Bool { package.packageType == .lifetime @@ -177,63 +265,78 @@ private struct ProductPackageButton: View { package.packageType == .annual } - /// Background color based on package type - private var backgroundColor: Color { - isLifetime ? AppStatus.warning.opacity(Design.Opacity.medium) : AppAccent.primary.opacity(Design.Opacity.medium) - } - - /// Border color based on package type - private var borderColor: Color { - isLifetime ? AppStatus.warning : AppAccent.primary - } - var body: some View { - Button(action: onPurchase) { - VStack(spacing: Design.Spacing.small) { - // Badge for lifetime - if isLifetime { - Text(String(localized: "ONE TIME")) - .font(.caption2.bold()) - .foregroundStyle(.black) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xxSmall) - .background(AppStatus.warning) - .clipShape(Capsule()) + 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)) } - Text(package.storeProduct.localizedTitle) - .font(.headline) - .foregroundStyle(.white) + Spacer() Text(package.localizedPriceString) - .font(.title2.bold()) + .font(.title3.bold()) .foregroundStyle(.white) - - // Subtitle based on type - if isLifetime { - Text(String(localized: "Pay once, own forever")) - .font(.caption) - .foregroundStyle(.white.opacity(Design.Opacity.accent)) - } else if isAnnual { - Text(String(localized: "Best Value • Save 33%")) - .font(.caption) - .foregroundStyle(.white.opacity(Design.Opacity.accent)) - } } - .frame(maxWidth: .infinity) .padding(Design.Spacing.large) - .background(backgroundColor) + .frame(maxWidth: .infinity) + .background(AppSurface.card) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .overlay( RoundedRectangle(cornerRadius: Design.CornerRadius.large) - .strokeBorder(borderColor, lineWidth: Design.LineWidth.thin) + .strokeBorder(isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: Design.LineWidth.thin) ) - .opacity(isLoading ? 0.6 : 1.0) + .opacity(isDisabled ? 0.6 : 1.0) } - .disabled(isLoading) - .accessibilityLabel(isLifetime - ? String(localized: "Purchase \(package.storeProduct.localizedTitle) for \(package.localizedPriceString), one-time payment") - : String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)")) + .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)") } } @@ -245,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) diff --git a/SelfieCam/Resources/Localizable.xcstrings b/SelfieCam/Resources/Localizable.xcstrings index 9d72fcb..f39f79c 100644 --- a/SelfieCam/Resources/Localizable.xcstrings +++ b/SelfieCam/Resources/Localizable.xcstrings @@ -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" : { @@ -1696,10 +1715,6 @@ } } }, - "Loading..." : { - "comment" : "A placeholder text displayed while waiting for content to load.", - "isCommentAutoGenerated" : true - }, "Locked. Tap to unlock with Pro." : { "comment" : "A hint that appears when a user taps on a color preset button.", "isCommentAutoGenerated" : true, @@ -1801,8 +1816,8 @@ "comment" : "A message displayed in an alert when the \"Reset Onboarding\" button is tapped.", "isCommentAutoGenerated" : true }, - "ONE TIME" : { - "comment" : "A label for a badge indicating a one-time purchase.", + "One Time" : { + "comment" : "A description of a one-time purchase option.", "isCommentAutoGenerated" : true }, "Open Settings" : { @@ -1951,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, @@ -2022,6 +2041,9 @@ } } } + }, + "Premium tools for better selfies" : { + }, "Pro Active" : { "comment" : "A label indicating that the user has an active Pro subscription.", @@ -2035,18 +2057,6 @@ "comment" : "An accessibility label for the section of the settings view that displays that their Pro subscription is active.", "isCommentAutoGenerated" : true }, - "Purchase %@ for %@, one-time payment" : { - "comment" : "An accessibility label for a button that purchases a product package. The label includes the product name and price, with a note that it's a one-time payment if the package is a lifetime subscription.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Purchase %1$@ for %2$@, one-time payment" - } - } - } - }, "Purchase Error" : { "comment" : "The title of an alert that appears when there is an error during a purchase process.", "isCommentAutoGenerated" : true @@ -2498,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, @@ -2938,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" : { @@ -3300,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, diff --git a/SelfieCam/Shared/Premium/PaywallPresenter.swift b/SelfieCam/Shared/Premium/PaywallPresenter.swift index 7686be8..54a3e1b 100644 --- a/SelfieCam/Shared/Premium/PaywallPresenter.swift +++ b/SelfieCam/Shared/Premium/PaywallPresenter.swift @@ -7,11 +7,9 @@ // import SwiftUI -import RevenueCat -import RevenueCatUI import Bedrock -/// Presents RevenueCat Paywall with fallback to custom paywall. +/// Presents the custom SelfieCam paywall. /// /// Usage: /// ```swift @@ -23,21 +21,10 @@ import Bedrock /// } /// ``` /// -/// The presenter will: -/// 1. Attempt to load the RevenueCat paywall from your configured offering -/// 2. If successful, display the native RevenueCat PaywallView -/// 3. If it fails (network error, no paywall configured), fall back to ProPaywallView struct PaywallPresenter: View { /// Callback triggered when a purchase or restore is successful var onPurchaseSuccess: (() -> Void)? - @Environment(\.dismiss) private var dismiss - @State private var offering: Offering? - @State private var isLoading = true - @State private var useFallback = false - @State private var errorMessage: String? - @State private var showError = false - /// Creates a PaywallPresenter with an optional success callback /// - Parameter onPurchaseSuccess: Called when purchase or restore completes successfully init(onPurchaseSuccess: (() -> Void)? = nil) { @@ -45,126 +32,7 @@ struct PaywallPresenter: View { } var body: some View { - Group { - if isLoading { - // Loading state while fetching offerings - loadingView - } else if useFallback { - // Fallback to custom paywall on error - ProPaywallView(onPurchaseSuccess: onPurchaseSuccess) - } else if let offering { - // RevenueCat native paywall - paywallView(for: offering) - } else { - // No offering available, use fallback - ProPaywallView(onPurchaseSuccess: onPurchaseSuccess) - } - } - .task { - await loadOffering() - } - .alert( - String(localized: "Purchase Error"), - isPresented: $showError, - presenting: errorMessage - ) { _ in - Button(String(localized: "OK"), role: .cancel) { - errorMessage = nil - } - } message: { message in - Text(message) - } - } - - // MARK: - Loading View - - private var loadingView: some View { - VStack { - ProgressView() - .scaleEffect(1.5) - .tint(.white) - - Text(String(localized: "Loading...")) - .font(.system(size: Design.FontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - .padding(.top, Design.Spacing.medium) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(AppSurface.primary) - } - - // MARK: - Paywall View - - private func paywallView(for offering: Offering) -> some View { - PaywallView(offering: offering) - .onPurchaseCompleted { _ in - #if DEBUG - print("✅ [PaywallPresenter] Purchase completed") - #endif - onPurchaseSuccess?() - dismiss() - } - .onPurchaseCancelled { - #if DEBUG - print("ℹ️ [PaywallPresenter] Purchase cancelled by user") - #endif - // User cancelled - paywall stays open, no action needed - } - .onPurchaseFailure { error in - #if DEBUG - print("❌ [PaywallPresenter] Purchase failed: \(error.localizedDescription)") - #endif - errorMessage = error.localizedDescription - showError = true - } - .onRestoreCompleted { customerInfo in - #if DEBUG - print("✅ [PaywallPresenter] Restore completed") - #endif - // Only call success if user actually has entitlements after restore - if !customerInfo.entitlements.active.isEmpty { - onPurchaseSuccess?() - dismiss() - } - } - .onRestoreFailure { error in - #if DEBUG - print("❌ [PaywallPresenter] Restore failed: \(error.localizedDescription)") - #endif - errorMessage = error.localizedDescription - showError = true - } - } - - // MARK: - Load Offering - - private func loadOffering() async { - do { - let offerings = try await Purchases.shared.offerings() - - // Check if current offering has a paywall configured - if let current = offerings.current { - offering = current - isLoading = false - - #if DEBUG - print("✅ [PaywallPresenter] Loaded offering: \(current.identifier)") - #endif - } else { - // No current offering, use fallback - #if DEBUG - print("⚠️ [PaywallPresenter] No current offering available, using fallback") - #endif - useFallback = true - isLoading = false - } - } catch { - #if DEBUG - print("⚠️ [PaywallPresenter] Failed to load offerings: \(error)") - #endif - useFallback = true - isLoading = false - } + ProPaywallView(onPurchaseSuccess: onPurchaseSuccess) } }