Advertisement

← poker-tutor

Sources/PokerUI/PaywallView.swift

6.6 KB Download file
import SwiftUI
import PokerSession
import PokerStore

/// The screen that asks somebody to pay.
///
/// Apple rejects a subscription paywall that does not carry, on the screen where the buyer taps
/// buy: what it costs, how long a period is, that it renews until cancelled, a way to restore a
/// purchase, and links to terms and to a privacy policy. All six are here on purpose. If one of
/// them goes missing in a tidy-up, the app comes back from review.
///
/// The tone is the same as the rest of the app. This is the one screen with an obvious incentive to
/// overstate things, so it says plainly what somebody keeps if they do not pay — which is all of
/// their own hands — and it does not pretend the free hands were a mistake on their part.
struct PaywallView: View {
    @Environment(AppState.self) var app
    @Environment(\.metrics) var metrics

    /// Whether somebody can walk away from this. False when the hands are gone: there is nothing
    /// behind it to go back to. True when they opened it themselves out of Settings.
    var dismissable: Bool

    var onClose: () -> Void

    private var store: SubscriptionStore? { app.subscription }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 18) {
                header

                Panel(title: Copy.paywallTitle, subtitle: nil) {
                    VStack(alignment: .leading, spacing: 14) {
                        Text(Copy.paywallBlurb(app.handsPlayed))
                            .body(Theme.coachBase(TextRole.body.base)).foregroundStyle(Theme.text)
                            .fixedSize(horizontal: false, vertical: true)

                        // The three facts Apple requires, kept immediately above the buy button so
                        // nobody has to scroll to find what they are agreeing to.
                        Text(priceLine)
                            .body().foregroundStyle(Theme.dim)
                            .fixedSize(horizontal: false, vertical: true)

                        PillButton(title: buyTitle, wide: metrics.compact,
                                   enabled: canBuy) {
                            Task { await store?.purchase() }
                        }

                        if let message = statusMessage {
                            Text(message)
                                .body().foregroundStyle(Theme.warn)
                                .fixedSize(horizontal: false, vertical: true)
                        }
                    }
                }

                Panel(title: "Your hands stay yours", subtitle: nil) {
                    Text(Copy.paywallKeepsYourHands)
                        .body(Theme.coachBase(TextRole.body.base)).foregroundStyle(Theme.dim)
                        .fixedSize(horizontal: false, vertical: true)
                }

                VStack(alignment: .leading, spacing: 10) {
                    Text(Copy.paywallRestoreNote)
                        .body().foregroundStyle(Theme.dim)
                        .fixedSize(horizontal: false, vertical: true)
                    GhostButton(title: "Restore", wide: metrics.compact) {
                        Task { await store?.restore() }
                    }
                }

                legal

                if dismissable {
                    GhostButton(title: "Not now", wide: metrics.compact) { onClose() }
                }
            }
            .padding(metrics.compact ? 16 : 24)
            .frame(maxWidth: 620, alignment: .leading)
        }
        .background(Theme.background)
    }

    private var header: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text(app.variant.displayName)
                .font(.system(size: metrics.titleSize, weight: .bold, design: .rounded))
                .foregroundStyle(Theme.text)
            AdLabMark()
        }
    }

    /// Apple's standard terms and our privacy policy. Both links are required.
    private var legal: some View {
        HStack(spacing: 16) {
            Link(Copy.termsLink, destination: URL(string: Copy.termsURL)!)
            Link(Copy.privacyLink, destination: URL(string: Copy.privacyURL)!)
        }
        .font(.system(size: Theme.size(11)))
        .foregroundStyle(Theme.dim)
    }

    // MARK: - What the store is telling us

    /// The price, or an honest sentence about why there is not one.
    private var priceLine: String {
        guard let price = store?.displayPrice else { return Copy.paywallNoPrice }
        return Copy.paywallPrice(price)
    }

    /// No price means nothing to agree to, so there is nothing to tap. A purchase in flight means
    /// tapping again would be a second purchase.
    private var canBuy: Bool {
        guard let store else { return false }
        return store.displayPrice != nil && store.status != .working
    }

    private var buyTitle: String {
        store?.status == .working ? "Working…" : "Subscribe"
    }

    private var statusMessage: String? {
        switch store?.status {
        case .failed(let message): return message
        case .waitingForApproval: return Copy.paywallPendingNote
        default: return nil
        }
    }
}

/// The line that tells somebody the free hands are running out, before they run out.
///
/// Shown on Home rather than over the table: interrupting a hand to mention a price is the thing
/// this app spent five phases learning not to do.
struct AllowanceNotice: View {
    let handsLeft: Int
    var onOpen: () -> Void

    @Environment(\.metrics) var metrics

    var body: some View {
        Button(action: onOpen) {
            HStack(spacing: 10) {
                Symbol("hourglass", size: 13)
                    .foregroundStyle(Theme.gold)
                Text(Copy.handsLeftWarning(handsLeft))
                    .body().foregroundStyle(Theme.text)
                    .fixedSize(horizontal: false, vertical: true)
                Spacer(minLength: 8)
                Symbol("chevron.right", size: 11)
                    .foregroundStyle(Theme.dim)
            }
            .padding(.horizontal, 14)
            .padding(.vertical, 11)
            .background(RoundedRectangle(cornerRadius: 11, style: .continuous)
                .fill(Theme.gold.opacity(0.10)))
            .overlay(RoundedRectangle(cornerRadius: 11, style: .continuous)
                .stroke(Theme.gold.opacity(0.30), lineWidth: 1))
            .contentShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
        }
        .buttonStyle(.plain)
    }
}