| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323 |
- 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)
- Spacer(minLength: 0)
- 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(spacing: 0) {
- footerLink(PaywallContent.continueWithFreePlan, metrics: metrics) {
- onDismiss()
- }
- Spacer(minLength: metrics.footerLinkSpacing)
- footerLink(PaywallContent.restorePurchases, metrics: metrics) {
- viewModel.restorePurchases()
- }
- Spacer(minLength: metrics.footerLinkSpacing)
- footerLink(PaywallContent.privacyPolicy, metrics: metrics) {}
- Spacer(minLength: metrics.footerLinkSpacing)
- footerLink(PaywallContent.support, metrics: metrics) {}
- Spacer(minLength: metrics.footerLinkSpacing)
- footerLink(PaywallContent.termsOfService, metrics: metrics) {}
- }
- .padding(.horizontal, metrics.footerLinksHorizontalInset)
- .frame(maxWidth: .infinity)
- }
- 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.textSecondary)
- .lineLimit(1)
- .minimumScaleFactor(0.8)
- }
- }
- // MARK: - Layout Metrics
- private struct PaywallLayoutMetrics {
- let size: CGSize
- var horizontalInset: CGFloat { size.width * 0.06 }
- var topInset: CGFloat { size.height * 0.11 }
- 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.018 }
- var footerLinksHorizontalInset: CGFloat { size.width * 0.035 }
- var titleFontSize: CGFloat { clamp(size.height * 0.043, min: 22, max: 36) }
- var subtitleFontSize: CGFloat { clamp(size.height * 0.02, min: 12, max: 16) }
- var sectionTitleFontSize: CGFloat { clamp(size.height * 0.018, min: 11, max: 14) }
- var featureFontSize: CGFloat { clamp(size.height * 0.017, min: 10, max: 13) }
- var ctaFontSize: CGFloat { clamp(size.height * 0.022, min: 13, max: 16) }
- var footerNoteFontSize: CGFloat { clamp(size.height * 0.015, min: 9, max: 12) }
- var footerLinkFontSize: CGFloat { clamp(size.height * 0.015, min: 9, max: 12) }
- 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)
- }
|