421 lines
15 KiB
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)
|
|
}
|