← poker-tutor
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)
}
}