SelfieCam/SelfieCam/Features/Paywall/Views/ProPaywallView.swift

421 lines
15 KiB
Swift

import SwiftUI
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?
/// Whether products are loading from RevenueCat
@State private var isLoadingProducts = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Design.Spacing.xLarge) {
header
benefitsCard
packageSelection
purchaseCTA
restoreButton
}
.padding(Design.Spacing.large)
}
.background(AppSurface.overlay)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "Cancel")) { dismiss() }
.foregroundStyle(.white)
.disabled(isPurchasing || isRestoring)
}
}
}
.font(.system(size: bodyFontSize))
.task {
await loadProducts()
}
.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 isLoadingProducts {
ProgressView()
.padding()
} else if manager.availablePackages.isEmpty {
VStack(spacing: Design.Spacing.small) {
Text(String(localized: "Plans are currently unavailable. Please try again."))
.font(.caption)
.foregroundStyle(.white.opacity(Design.Opacity.medium))
.multilineTextAlignment(.center)
Button(String(localized: "Retry")) {
Task {
await loadProducts()
}
}
.font(.footnote.weight(.semibold))
.foregroundStyle(AppAccent.primary)
}
.padding(.vertical, Design.Spacing.medium)
} 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 !manager.availablePackages.isEmpty else {
errorMessage = String(localized: "Plans are unavailable right now. Please try again.")
showError = true
Task {
await loadProducts()
}
return
}
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(
isPurchasing
? String(localized: "Processing...")
: 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 || isLoadingProducts)
.opacity((isPurchasing || isRestoring || isLoadingProducts) ? 0.7 : 1.0)
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 loadProducts() async {
guard !isLoadingProducts else { return }
isLoadingProducts = true
defer { isLoadingProducts = false }
do {
try await manager.loadProducts()
if selectedPackage == nil {
selectedPackage = preferredPackage(from: manager.availablePackages)
}
} catch {
#if DEBUG
print("❌ [ProPaywallView] Failed to load products: \(error.localizedDescription)")
#endif
}
}
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: - Package Option Row
private struct PackageOptionRow: View {
let package: Package
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: 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(.title3.bold())
.foregroundStyle(.white)
}
.padding(Design.Spacing.large)
.frame(maxWidth: .infinity)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(isSelected ? AppAccent.primary : AppBorder.subtle, lineWidth: Design.LineWidth.thin)
)
.opacity(isDisabled ? 0.6 : 1.0)
}
.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)")
}
}
// MARK: - Benefit Row
struct BenefitRow: View {
let image: String
let text: String
var body: some View {
HStack(spacing: Design.Spacing.medium) {
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)
Spacer()
}
}
}
#Preview {
ProPaywallView()
.preferredColorScheme(.dark)
}