|
@@ -1,10 +1,19 @@
|
|
|
import SwiftUI
|
|
import SwiftUI
|
|
|
|
|
|
|
|
struct PaywallView: View {
|
|
struct PaywallView: View {
|
|
|
- let onClose: () -> Void
|
|
|
|
|
|
|
+ @EnvironmentObject private var subscriptions: SubscriptionManager
|
|
|
@StateObject private var viewModel = PaywallViewModel()
|
|
@StateObject private var viewModel = PaywallViewModel()
|
|
|
|
|
+ let onClose: () -> Void
|
|
|
@State private var isCTAHovered = false
|
|
@State private var isCTAHovered = false
|
|
|
|
|
|
|
|
|
|
+ private var isPurchasingSelectedPlan: Bool {
|
|
|
|
|
+ subscriptions.purchasingPlan == viewModel.selectedPlan
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var buttonsBusy: Bool {
|
|
|
|
|
+ subscriptions.purchasingPlan != nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
var body: some View {
|
|
var body: some View {
|
|
|
GeometryReader { proxy in
|
|
GeometryReader { proxy in
|
|
|
let horizontalInset = min(max(proxy.size.width * 0.03, 12), 34)
|
|
let horizontalInset = min(max(proxy.size.width * 0.03, 12), 34)
|
|
@@ -22,20 +31,46 @@ struct PaywallView: View {
|
|
|
let featureHeight = max(74, min(92, 84 * layoutScale))
|
|
let featureHeight = max(74, min(92, 84 * layoutScale))
|
|
|
let planHeight = max(160, min(174, 166 * layoutScale))
|
|
let planHeight = max(160, min(174, 166 * layoutScale))
|
|
|
|
|
|
|
|
- VStack(spacing: 0) {
|
|
|
|
|
- VStack(spacing: verticalSpacing) {
|
|
|
|
|
- titleSection
|
|
|
|
|
- featuresSection(featureHeight: featureHeight)
|
|
|
|
|
- plansSection(planHeight: planHeight)
|
|
|
|
|
- ctaSection
|
|
|
|
|
- legalSection
|
|
|
|
|
|
|
+ ZStack {
|
|
|
|
|
+ VStack(spacing: 0) {
|
|
|
|
|
+ VStack(spacing: verticalSpacing) {
|
|
|
|
|
+ titleSection
|
|
|
|
|
+ featuresSection(featureHeight: featureHeight)
|
|
|
|
|
+ plansSection(planHeight: planHeight)
|
|
|
|
|
+ ctaSection
|
|
|
|
|
+ legalSection
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(width: contentWidth, alignment: .top)
|
|
|
|
|
+ .padding(.top, topPadding)
|
|
|
|
|
+ .padding(.bottom, topPadding)
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
|
|
|
+ .background(Color.white.ignoresSafeArea())
|
|
|
|
|
+
|
|
|
|
|
+ if subscriptions.isLoadingProducts && !subscriptions.hasAllProductsLoaded {
|
|
|
|
|
+ Color.white.opacity(0.65)
|
|
|
|
|
+ .ignoresSafeArea()
|
|
|
|
|
+ .allowsHitTesting(false)
|
|
|
|
|
+ ProgressView()
|
|
|
|
|
+ .controlSize(.large)
|
|
|
}
|
|
}
|
|
|
- .frame(width: contentWidth, alignment: .top)
|
|
|
|
|
- .padding(.top, topPadding)
|
|
|
|
|
- .padding(.bottom, topPadding)
|
|
|
|
|
}
|
|
}
|
|
|
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
|
|
|
- .background(Color.white.ignoresSafeArea())
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ .task {
|
|
|
|
|
+ await subscriptions.loadProducts()
|
|
|
|
|
+ }
|
|
|
|
|
+ .alert(
|
|
|
|
|
+ "Subscriptions",
|
|
|
|
|
+ isPresented: Binding(
|
|
|
|
|
+ get: { subscriptions.purchaseError != nil },
|
|
|
|
|
+ set: { presented in
|
|
|
|
|
+ if !presented { subscriptions.purchaseError = nil }
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ ) {
|
|
|
|
|
+ Button("OK", role: .cancel) {}
|
|
|
|
|
+ } message: {
|
|
|
|
|
+ Text(subscriptions.purchaseError?.message ?? "")
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -75,8 +110,13 @@ struct PaywallView: View {
|
|
|
PaywallPlanCardView(
|
|
PaywallPlanCardView(
|
|
|
plan: plan,
|
|
plan: plan,
|
|
|
isSelected: viewModel.selectedPlan == plan,
|
|
isSelected: viewModel.selectedPlan == plan,
|
|
|
|
|
+ mainPrice: subscriptions.mainPrice(for: plan),
|
|
|
|
|
+ secondaryPrice: subscriptions.secondaryPrice(for: plan),
|
|
|
minHeight: planHeight,
|
|
minHeight: planHeight,
|
|
|
- onSelect: { viewModel.selectPlan(plan) }
|
|
|
|
|
|
|
+ onSelect: {
|
|
|
|
|
+ viewModel.selectPlan(plan)
|
|
|
|
|
+ subscriptions.purchaseError = nil
|
|
|
|
|
+ }
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -85,30 +125,47 @@ struct PaywallView: View {
|
|
|
|
|
|
|
|
private var ctaSection: some View {
|
|
private var ctaSection: some View {
|
|
|
VStack(spacing: 14) {
|
|
VStack(spacing: 14) {
|
|
|
- Text(viewModel.selectedPlan.billingDescription)
|
|
|
|
|
|
|
+ Text(subscriptions.billingDescription(for: viewModel.selectedPlan))
|
|
|
.font(.system(size: 13, weight: .medium))
|
|
.font(.system(size: 13, weight: .medium))
|
|
|
.foregroundStyle(Color(red: 0.47, green: 0.51, blue: 0.57))
|
|
.foregroundStyle(Color(red: 0.47, green: 0.51, blue: 0.57))
|
|
|
|
|
|
|
|
- Button(action: {}) {
|
|
|
|
|
|
|
+ Button {
|
|
|
|
|
+ Task {
|
|
|
|
|
+ let purchased = await subscriptions.purchase(viewModel.selectedPlan)
|
|
|
|
|
+ if purchased {
|
|
|
|
|
+ onClose()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } label: {
|
|
|
HStack {
|
|
HStack {
|
|
|
Spacer()
|
|
Spacer()
|
|
|
- Text(viewModel.selectedPlan.ctaTitle)
|
|
|
|
|
- .font(.system(size: 20, weight: .heavy, design: .rounded))
|
|
|
|
|
- .foregroundStyle(.white)
|
|
|
|
|
- .lineLimit(1)
|
|
|
|
|
- .minimumScaleFactor(0.85)
|
|
|
|
|
|
|
+ if isPurchasingSelectedPlan {
|
|
|
|
|
+ ProgressView()
|
|
|
|
|
+ .controlSize(.small)
|
|
|
|
|
+ .tint(.white)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Text(viewModel.selectedPlan.ctaTitle)
|
|
|
|
|
+ .font(.system(size: 20, weight: .heavy, design: .rounded))
|
|
|
|
|
+ .foregroundStyle(.white)
|
|
|
|
|
+ .lineLimit(1)
|
|
|
|
|
+ .minimumScaleFactor(0.85)
|
|
|
|
|
+ }
|
|
|
Spacer()
|
|
Spacer()
|
|
|
}
|
|
}
|
|
|
.padding(.vertical, 14)
|
|
.padding(.vertical, 14)
|
|
|
.background(isCTAHovered ? Color(red: 0.02, green: 0.56, blue: 0.43) : Color(red: 0.03, green: 0.62, blue: 0.48))
|
|
.background(isCTAHovered ? Color(red: 0.02, green: 0.56, blue: 0.43) : Color(red: 0.03, green: 0.62, blue: 0.48))
|
|
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
|
.overlay(alignment: .trailing) {
|
|
.overlay(alignment: .trailing) {
|
|
|
- Image(systemName: "chevron.right.2")
|
|
|
|
|
- .font(.system(size: 18, weight: .heavy))
|
|
|
|
|
- .foregroundStyle(Color.white.opacity(0.75))
|
|
|
|
|
- .padding(.trailing, 24)
|
|
|
|
|
|
|
+ if !isPurchasingSelectedPlan {
|
|
|
|
|
+ Image(systemName: "chevron.right.2")
|
|
|
|
|
+ .font(.system(size: 18, weight: .heavy))
|
|
|
|
|
+ .foregroundStyle(Color.white.opacity(0.75))
|
|
|
|
|
+ .padding(.trailing, 24)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ .disabled(buttonsBusy || subscriptions.isLoadingProducts)
|
|
|
|
|
+ .opacity((buttonsBusy || subscriptions.isLoadingProducts) ? 0.7 : 1)
|
|
|
.scaleEffect(isCTAHovered ? 1.01 : 1.0)
|
|
.scaleEffect(isCTAHovered ? 1.01 : 1.0)
|
|
|
.shadow(
|
|
.shadow(
|
|
|
color: isCTAHovered ? AppTheme.primaryShadow.opacity(0.32) : .clear,
|
|
color: isCTAHovered ? AppTheme.primaryShadow.opacity(0.32) : .clear,
|
|
@@ -137,8 +194,16 @@ struct PaywallView: View {
|
|
|
|
|
|
|
|
HStack(spacing: 0) {
|
|
HStack(spacing: 0) {
|
|
|
PaywallFooterLinkView(title: "Continue with free plan", action: onClose)
|
|
PaywallFooterLinkView(title: "Continue with free plan", action: onClose)
|
|
|
|
|
+ PaywallFooterLinkView(title: "Restore Purchases") {
|
|
|
|
|
+ Task {
|
|
|
|
|
+ await subscriptions.restorePurchases()
|
|
|
|
|
+ if subscriptions.hasPremiumAccess {
|
|
|
|
|
+ onClose()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .disabled(buttonsBusy)
|
|
|
PaywallFooterLinkView(title: "Privacy Policy")
|
|
PaywallFooterLinkView(title: "Privacy Policy")
|
|
|
- PaywallFooterLinkView(title: "Support")
|
|
|
|
|
PaywallFooterLinkView(title: "Terms of Services")
|
|
PaywallFooterLinkView(title: "Terms of Services")
|
|
|
}
|
|
}
|
|
|
.frame(height: 44)
|
|
.frame(height: 44)
|
|
@@ -199,6 +264,8 @@ private struct PaywallFeatureBox: View {
|
|
|
private struct PaywallPlanCardView: View {
|
|
private struct PaywallPlanCardView: View {
|
|
|
let plan: PaywallPlan
|
|
let plan: PaywallPlan
|
|
|
let isSelected: Bool
|
|
let isSelected: Bool
|
|
|
|
|
+ let mainPrice: String
|
|
|
|
|
+ let secondaryPrice: String
|
|
|
let minHeight: CGFloat
|
|
let minHeight: CGFloat
|
|
|
let onSelect: () -> Void
|
|
let onSelect: () -> Void
|
|
|
@State private var isHovered = false
|
|
@State private var isHovered = false
|
|
@@ -239,7 +306,7 @@ private struct PaywallPlanCardView: View {
|
|
|
.padding(.horizontal, 14)
|
|
.padding(.horizontal, 14)
|
|
|
.padding(.top, 14)
|
|
.padding(.top, 14)
|
|
|
|
|
|
|
|
- Text(plan.mainPrice)
|
|
|
|
|
|
|
+ Text(mainPrice)
|
|
|
.font(.system(size: 21, weight: .heavy, design: .rounded))
|
|
.font(.system(size: 21, weight: .heavy, design: .rounded))
|
|
|
.foregroundStyle(Color(red: 0.03, green: 0.62, blue: 0.48))
|
|
.foregroundStyle(Color(red: 0.03, green: 0.62, blue: 0.48))
|
|
|
.lineLimit(1)
|
|
.lineLimit(1)
|
|
@@ -250,7 +317,7 @@ private struct PaywallPlanCardView: View {
|
|
|
|
|
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
|
HStack {
|
|
HStack {
|
|
|
- Text(plan.secondaryPrice)
|
|
|
|
|
|
|
+ Text(secondaryPrice)
|
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.font(.system(size: 14, weight: .semibold))
|
|
|
.foregroundStyle(Color(red: 0.45, green: 0.48, blue: 0.54))
|
|
.foregroundStyle(Color(red: 0.45, green: 0.48, blue: 0.54))
|
|
|
.overlay(alignment: .center) {
|
|
.overlay(alignment: .center) {
|