|
|
@@ -0,0 +1,313 @@
|
|
|
+import SwiftUI
|
|
|
+
|
|
|
+struct PaywallView: View {
|
|
|
+ @Bindable var viewModel: PaywallViewModel
|
|
|
+ var onDismiss: () -> Void
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ GeometryReader { geometry in
|
|
|
+ let metrics = PaywallLayoutMetrics(size: geometry.size)
|
|
|
+
|
|
|
+ ZStack(alignment: .topTrailing) {
|
|
|
+ AppTheme.background
|
|
|
+ .ignoresSafeArea()
|
|
|
+
|
|
|
+ VStack(spacing: metrics.sectionSpacing) {
|
|
|
+ header(metrics: metrics)
|
|
|
+ featuresSection(metrics: metrics)
|
|
|
+ pricingCards(metrics: metrics)
|
|
|
+ ctaSection(metrics: metrics)
|
|
|
+ footerLinks(metrics: metrics)
|
|
|
+ }
|
|
|
+ .padding(.horizontal, metrics.horizontalInset)
|
|
|
+ .padding(.top, metrics.topInset)
|
|
|
+ .padding(.bottom, metrics.bottomInset)
|
|
|
+ .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
|
|
|
+
|
|
|
+ closeButton
|
|
|
+ .padding(metrics.closeButtonPadding)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
+ .ignoresSafeArea()
|
|
|
+ }
|
|
|
+
|
|
|
+ private var closeButton: some View {
|
|
|
+ Button(action: onDismiss) {
|
|
|
+ Image(systemName: "xmark")
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .frame(width: 32, height: 32)
|
|
|
+ .background(AppTheme.cardBackground)
|
|
|
+ .clipShape(Circle())
|
|
|
+ .overlay(
|
|
|
+ Circle()
|
|
|
+ .stroke(AppTheme.cardBorder, lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func header(metrics: PaywallLayoutMetrics) -> some View {
|
|
|
+ VStack(spacing: metrics.headerSpacing) {
|
|
|
+ Text(PaywallContent.title)
|
|
|
+ .font(.system(size: metrics.titleFontSize, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+
|
|
|
+ Text(PaywallContent.subtitle)
|
|
|
+ .font(.system(size: metrics.subtitleFontSize))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
+ .frame(maxWidth: metrics.subtitleMaxWidth)
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func featuresSection(metrics: PaywallLayoutMetrics) -> some View {
|
|
|
+ VStack(alignment: .leading, spacing: metrics.featureSectionSpacing) {
|
|
|
+ Text(PaywallContent.includedSectionTitle)
|
|
|
+ .font(.system(size: metrics.sectionTitleFontSize, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+
|
|
|
+ LazyVGrid(columns: metrics.featureColumns, alignment: .leading, spacing: metrics.featureRowSpacing) {
|
|
|
+ ForEach(PaywallContent.features) { feature in
|
|
|
+ PaywallFeatureRow(feature: feature, metrics: metrics)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func pricingCards(metrics: PaywallLayoutMetrics) -> some View {
|
|
|
+ HStack(alignment: .top, spacing: metrics.cardSpacing) {
|
|
|
+ ForEach(PaywallPlan.allCases) { plan in
|
|
|
+ PaywallPricingCard(
|
|
|
+ plan: plan,
|
|
|
+ isSelected: viewModel.selectedPlan == plan,
|
|
|
+ metrics: metrics
|
|
|
+ ) {
|
|
|
+ viewModel.selectPlan(plan)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func ctaSection(metrics: PaywallLayoutMetrics) -> some View {
|
|
|
+ VStack(spacing: metrics.ctaSpacing) {
|
|
|
+ Button {
|
|
|
+ viewModel.purchase()
|
|
|
+ } label: {
|
|
|
+ Text("Get Premium")
|
|
|
+ .font(.system(size: metrics.ctaFontSize, weight: .bold))
|
|
|
+ .foregroundStyle(Color(hex: 0x0B0E14))
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ .padding(.vertical, metrics.ctaVerticalPadding)
|
|
|
+ .background(
|
|
|
+ LinearGradient(
|
|
|
+ colors: [AppTheme.accentPurpleLight, AppTheme.accentTeal],
|
|
|
+ startPoint: .leading,
|
|
|
+ endPoint: .trailing
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: metrics.cornerRadius))
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+
|
|
|
+ HStack(spacing: 6) {
|
|
|
+ Image(systemName: "lock.fill")
|
|
|
+ .font(.system(size: metrics.footerNoteFontSize))
|
|
|
+ Text("Secure payment. Cancel anytime.")
|
|
|
+ .font(.system(size: metrics.footerNoteFontSize))
|
|
|
+ }
|
|
|
+ .foregroundStyle(AppTheme.textTertiary)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func footerLinks(metrics: PaywallLayoutMetrics) -> some View {
|
|
|
+ HStack {
|
|
|
+ Button("Continue with free plan") {
|
|
|
+ onDismiss()
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .font(.system(size: metrics.footerLinkFontSize))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ HStack(spacing: metrics.footerLinkSpacing) {
|
|
|
+ footerLink("Restore Purchases", metrics: metrics) {
|
|
|
+ viewModel.restorePurchases()
|
|
|
+ }
|
|
|
+ footerLink("Privacy Policy", metrics: metrics) {}
|
|
|
+ footerLink("Support", metrics: metrics) {}
|
|
|
+ footerLink("Terms of Services", metrics: metrics) {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func footerLink(_ title: String, metrics: PaywallLayoutMetrics, action: @escaping () -> Void) -> some View {
|
|
|
+ Button(title, action: action)
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .font(.system(size: metrics.footerLinkFontSize))
|
|
|
+ .foregroundStyle(AppTheme.textTertiary)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Layout Metrics
|
|
|
+
|
|
|
+private struct PaywallLayoutMetrics {
|
|
|
+ let size: CGSize
|
|
|
+
|
|
|
+ var horizontalInset: CGFloat { size.width * 0.06 }
|
|
|
+ var topInset: CGFloat { size.height * 0.07 }
|
|
|
+ var bottomInset: CGFloat { size.height * 0.05 }
|
|
|
+ var closeButtonPadding: CGFloat { max(16, size.width * 0.02) }
|
|
|
+
|
|
|
+ var sectionSpacing: CGFloat { size.height * 0.04 }
|
|
|
+ var headerSpacing: CGFloat { size.height * 0.012 }
|
|
|
+ var featureSectionSpacing: CGFloat { size.height * 0.018 }
|
|
|
+ var featureRowSpacing: CGFloat { size.height * 0.016 }
|
|
|
+ var cardSpacing: CGFloat { size.width * 0.018 }
|
|
|
+ var ctaSpacing: CGFloat { size.height * 0.014 }
|
|
|
+ var footerLinkSpacing: CGFloat { size.width * 0.014 }
|
|
|
+
|
|
|
+ var titleFontSize: CGFloat { clamp(size.height * 0.048, min: 24, max: 40) }
|
|
|
+ var subtitleFontSize: CGFloat { clamp(size.height * 0.022, min: 13, max: 18) }
|
|
|
+ var sectionTitleFontSize: CGFloat { clamp(size.height * 0.02, min: 12, max: 16) }
|
|
|
+ var featureFontSize: CGFloat { clamp(size.height * 0.019, min: 11, max: 15) }
|
|
|
+ var ctaFontSize: CGFloat { clamp(size.height * 0.024, min: 14, max: 18) }
|
|
|
+ var footerNoteFontSize: CGFloat { clamp(size.height * 0.017, min: 10, max: 13) }
|
|
|
+ var footerLinkFontSize: CGFloat { clamp(size.height * 0.017, min: 10, max: 13) }
|
|
|
+
|
|
|
+ var subtitleMaxWidth: CGFloat { size.width * 0.55 }
|
|
|
+ var cardMinHeight: CGFloat { size.height * 0.32 }
|
|
|
+ var cardPadding: CGFloat { size.width * 0.014 }
|
|
|
+ var cornerRadius: CGFloat { clamp(size.height * 0.014, min: 8, max: 12) }
|
|
|
+ var ctaVerticalPadding: CGFloat { size.height * 0.022 }
|
|
|
+
|
|
|
+ var featureColumns: [GridItem] {
|
|
|
+ [
|
|
|
+ GridItem(.flexible(), spacing: featureRowSpacing),
|
|
|
+ GridItem(.flexible(), spacing: featureRowSpacing),
|
|
|
+ GridItem(.flexible(), spacing: featureRowSpacing),
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ private func clamp(_ value: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat {
|
|
|
+ Swift.min(Swift.max(value, min), max)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Components
|
|
|
+
|
|
|
+private struct PaywallFeatureRow: View {
|
|
|
+ let feature: PaywallFeature
|
|
|
+ let metrics: PaywallLayoutMetrics
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ Image(systemName: feature.systemImage)
|
|
|
+ .font(.system(size: metrics.featureFontSize * 0.9, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.accentTeal)
|
|
|
+ .frame(width: metrics.featureFontSize + 4)
|
|
|
+
|
|
|
+ Text(feature.title)
|
|
|
+ .font(.system(size: metrics.featureFontSize))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .lineLimit(1)
|
|
|
+ .minimumScaleFactor(0.85)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private struct PaywallPricingCard: View {
|
|
|
+ let plan: PaywallPlan
|
|
|
+ let isSelected: Bool
|
|
|
+ let metrics: PaywallLayoutMetrics
|
|
|
+ let onSelect: () -> Void
|
|
|
+
|
|
|
+ var body: some View {
|
|
|
+ Button(action: onSelect) {
|
|
|
+ VStack(alignment: .leading, spacing: 0) {
|
|
|
+ if plan.isBestValue {
|
|
|
+ Text("BEST VALUE")
|
|
|
+ .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
|
|
|
+ .foregroundStyle(Color(hex: 0x0B0E14))
|
|
|
+ .padding(.horizontal, 10)
|
|
|
+ .padding(.vertical, 4)
|
|
|
+ .background(AppTheme.accentTeal)
|
|
|
+ .clipShape(Capsule())
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ .padding(.bottom, metrics.cardPadding)
|
|
|
+ } else {
|
|
|
+ Color.clear
|
|
|
+ .frame(height: metrics.cardPadding * 1.6)
|
|
|
+ .padding(.bottom, metrics.cardPadding * 0.4)
|
|
|
+ }
|
|
|
+
|
|
|
+ Text(plan.title)
|
|
|
+ .font(.system(size: metrics.ctaFontSize, weight: .semibold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+
|
|
|
+ Text(plan.subtitle)
|
|
|
+ .font(.system(size: metrics.featureFontSize * 0.85))
|
|
|
+ .foregroundStyle(AppTheme.textTertiary)
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
+ .padding(.top, 2)
|
|
|
+
|
|
|
+ HStack(alignment: .firstTextBaseline, spacing: 2) {
|
|
|
+ Text(plan.price)
|
|
|
+ .font(.system(size: metrics.titleFontSize * 0.85, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.textPrimary)
|
|
|
+
|
|
|
+ if let suffix = plan.priceSuffix {
|
|
|
+ Text(suffix)
|
|
|
+ .font(.system(size: metrics.featureFontSize))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding(.top, metrics.cardPadding)
|
|
|
+ .padding(.bottom, metrics.cardPadding * 0.9)
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: metrics.featureRowSpacing * 0.7) {
|
|
|
+ ForEach(plan.features, id: \.self) { feature in
|
|
|
+ HStack(alignment: .top, spacing: 8) {
|
|
|
+ Image(systemName: "checkmark")
|
|
|
+ .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
|
|
|
+ .foregroundStyle(AppTheme.accentGreen)
|
|
|
+ .padding(.top, 2)
|
|
|
+
|
|
|
+ Text(feature)
|
|
|
+ .font(.system(size: metrics.featureFontSize * 0.92))
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
+ .multilineTextAlignment(.leading)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Spacer(minLength: 0)
|
|
|
+ }
|
|
|
+ .padding(metrics.cardPadding)
|
|
|
+ .frame(maxWidth: .infinity, minHeight: metrics.cardMinHeight, alignment: .topLeading)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: metrics.cornerRadius)
|
|
|
+ .fill(AppTheme.cardBackground)
|
|
|
+ )
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: metrics.cornerRadius)
|
|
|
+ .stroke(
|
|
|
+ isSelected ? AppTheme.accentTeal : AppTheme.cardBorder,
|
|
|
+ lineWidth: isSelected ? 2 : 1
|
|
|
+ )
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+#Preview {
|
|
|
+ PaywallView(viewModel: PaywallViewModel(), onDismiss: {})
|
|
|
+ .frame(width: AppWindow.width, height: AppWindow.height)
|
|
|
+}
|