swiftship/internal/skills/data/features/paywall/SKILL.md
Custom paywall UI patterns for subscriptions and credit packs with Apple compliance. Use when building paywalls or purchase screens.
npx skillsauth add abdullah4ai/apple-developer-toolkit paywallInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Build custom paywalls matching the app's design system. DO NOT use RevenueCat's built-in PaywallView — always build custom UI.
Do NOT create:
SubscriptionPlan / SubscriptionTier / Plan enum or struct with hardcoded pricesvar price: String returning "$X.XX"[SubscriptionPlan] — hold [Package] from RevenueCat insteadPaywallViewModel or any ViewModel that calls Purchases.shared — PaywallView uses SubscriptionManager.shared directly.sheet for paywall presentation — MUST use .fullScreenCoverThe PaywallView gets its data from SubscriptionManager.packages which holds RevenueCat Package objects. ALL pricing comes from package.storeProduct.localizedPriceString. ALL plan names come from package.storeProduct.localizedTitle.
struct PaywallView: View {
@Environment(\.dismiss) private var dismiss
@State private var manager = SubscriptionManager.shared
var body: some View {
ZStack {
ScrollView {
VStack(spacing: AppTheme.Spacing.lg) {
closeButton
heroSection
planCards
ctaButton
footer
}
.padding(AppTheme.Spacing.md)
}
if manager.purchaseSuccess {
purchaseSuccessOverlay
}
}
.task { await manager.loadOfferings() }
.onChange(of: manager.purchaseSuccess) { _, success in
if success {
Task {
try? await Task.sleep(for: .seconds(1.5))
manager.resetPurchaseSuccess()
dismiss()
}
}
}
}
private var planCards: some View {
VStack(spacing: AppTheme.Spacing.sm) {
ForEach(manager.packages, id: \.identifier) { package in
PaywallPlanCard(
package: package,
isSelected: manager.selectedPackage?.identifier == package.identifier,
onTap: { manager.selectedPackage = package }
)
}
}
}
private var ctaButton: some View {
Button {
guard let pkg = manager.selectedPackage else { return }
Task { await manager.purchase(pkg) }
} label: {
Text("Subscribe")
.font(AppTheme.Fonts.headline)
}
.buttonStyle(.borderedProminent)
.disabled(manager.selectedPackage == nil || manager.isLoading)
}
}
After a successful purchase, the paywall MUST show a success overlay before auto-dismissing. This gives the user clear confirmation that their purchase went through.
private var purchaseSuccessOverlay: some View {
ZStack {
Color.black.opacity(0.6)
.ignoresSafeArea()
VStack(spacing: AppTheme.Spacing.md) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(AppTheme.Colors.success)
.symbolEffect(.bounce, value: manager.purchaseSuccess)
Text("You're all set!")
.font(AppTheme.Fonts.title2)
.foregroundStyle(.white)
Text("Your premium access is now active")
.font(AppTheme.Fonts.body)
.foregroundStyle(.white.opacity(0.8))
}
}
.transition(.opacity)
.animation(.easeInOut(duration: 0.3), value: manager.purchaseSuccess)
}
Key points:
purchaseSuccess becomes trueisPremium is already updated, so feature gates unlock automaticallyAppTheme.Colors.success if defined, otherwise use .greenstruct PaywallPlanCard: View {
let package: Package // RevenueCat Package — NOT a custom model
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: AppTheme.Spacing.sm) {
Text(package.storeProduct.localizedTitle) // from store
.font(AppTheme.Fonts.headline)
Text(package.storeProduct.localizedPriceString) // from store
.font(AppTheme.Fonts.title2)
Text(package.storeProduct.localizedDescription) // from store
.font(AppTheme.Fonts.subheadline)
}
.padding(AppTheme.Spacing.md)
.background(isSelected ? AppTheme.Colors.primary.opacity(0.1) : AppTheme.Colors.surface)
.cornerRadius(AppTheme.Style.cornerRadius)
.overlay(
RoundedRectangle(cornerRadius: AppTheme.Style.cornerRadius)
.stroke(isSelected ? AppTheme.Colors.primary : .clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
}
}
When showing annual alongside monthly plans, calculate savings dynamically from StoreKit prices. NEVER hardcode savings percentages.
// REQUIRED calculation — in the PlanCard or a helper
private var savingsText: String? {
guard let monthlyPackage = manager.packages.first(where: { $0.packageType == .monthly }),
let annualPackage = manager.packages.first(where: { $0.packageType == .annual }) else {
return nil
}
let monthlyAnnualized = monthlyPackage.storeProduct.price * 12
let annualPrice = annualPackage.storeProduct.price
guard monthlyAnnualized > annualPrice else { return nil }
let savings = ((monthlyAnnualized - annualPrice) / monthlyAnnualized * 100)
.formatted(.number.precision(.fractionLength(0)))
return "Save \(savings)%"
}
BANNED:
Every paywall MUST follow these rules:
package.storeProduct.localizedPriceString, never hardcodedPresent paywalls as .fullScreenCover, NEVER as .sheet:
// REQUIRED
.fullScreenCover(isPresented: $showPaywall) {
PaywallView()
}
// BANNED — never use .sheet for paywalls
.sheet(isPresented: $showPaywall) { // WRONG
PaywallView()
}
Why: .sheet allows swipe-to-dismiss which bypasses mandatory disclosures. Apple requires the close button to be the only dismissal mechanism so users see compliance text.
See Compliance Checklist, Subscription Paywall, Credit Paywall, and Disclosure Text for templates.
testing
Use for 3D games: racing, 3D sports, board games, marble maze, tower defense, bowling. SceneKit + SceneView architecture, 3D scene hierarchy, physics, game loop, primitives, materials, cameras, particles, audio.
documentation
Game UI patterns: SwiftUI HUD overlays on SpriteKit, menus (main/pause/game-over), virtual joystick/d-pad, score displays, health bars, tutorial onboarding.
tools
Download free game sprites/textures/3D models and generate procedural assets. Covers nw_download_asset tool, texture factories, sprite atlas organization, 3D model loading, and programmatic asset creation.
testing
Use for 2D games: arcade, puzzle, sports, ping pong, platformer, shooter, 2D racing. SpriteKit + SpriteView architecture, scene hierarchy, physics, game loop, audio, particles, game feel.