Onboarding

DeliciousKit's onboarding is built around what actually moves install-to-paid conversion: value first, then ask. You describe the flow as data; DeliciousKit renders it, captures the answers, personalizes the paywall, and fires analytics on every step.

Describe the flow

extension OnboardingConfig {
  static var myApp: OnboardingConfig {
    OnboardingConfig(
      theme: DeliciousKitTheme(accent: .blue),
      steps: [
        .feature(title: "Welcome", subtitle: "What this app does.", systemImage: "sparkles"),
        .singleSelect(key: "goal", question: "What's your goal?", options: [
          OnboardingOption(id: "a", label: "Get fit", emoji: "💪"),
          OnboardingOption(id: "b", label: "Lose weight", emoji: "🥗")
        ]),
        .commitment(key: "mins", question: "Minutes a day?", range: 5...60,
                    step: 5, unit: " min", defaultValue: 15),
        .building(title: "Building your plan…", subtitle: "One moment.",
                  messages: ["Analyzing", "Personalizing", "Done!"]),
        .paywall
      ],
      paywall: PaywallConfig(/* see Paywall & purchases */)   // omit entirely if you don't use `.paywall` in steps
    )
  }
}

The step types

  • .feature — a value screen (title, subtitle, SF Symbol). Show what the app does, not a feature list.
  • .singleSelect — a one-tap question that personalizes the experience and/or qualifies the pitch.
  • .commitment — a slider ("minutes a day?"). A small investment that lifts ownership.
  • .building — a short "building your plan…" beat right before the ask.
  • .permission — a system-permission ask, with App-Review-compliant three-state handling built in (see below).
  • .paywall — the upsell, with its headline personalized from the user's answers. Optional — omit it from steps for a usage-gated paywall shown later instead (see Paywall & purchases).

Permission steps

.permission generalizes the three-state pattern every permission ask needs — not-yet-asked, authorized, denied — so you don't hand-roll it per permission, per app:

.permission(
  key: "push",
  title: "Stay in the loop",
  subtitle: "Get notified when it matters.",
  details: "We'll only send what's genuinely useful.",
  deniedDetails: "Notifications are off. Enable them in Settings anytime.",
  systemImage: "bell.badge.fill",
  checkStatus: {
    let settings = await UNUserNotificationCenter.current().notificationSettings()
    switch settings.authorizationStatus {
    case .authorized, .provisional: return .authorized
    case .denied: return .denied
    default: return .notRequested
    }
  },
  request: {
    let granted = (try? await UNUserNotificationCenter.current()
      .requestAuthorization(options: [.alert, .sound, .badge])) ?? false
    return granted ? .authorized : .denied
  }
)

DeliciousKit doesn't hardcode which permission this is — checkStatus/request are your own closures, so the same step works for push, location, camera, contacts, anything with an OS authorization API. The rendered step handles the rest:

  • Not yet asked — the button triggers your request() closure (which shows the system dialog).
  • Authorized — a green checkmark; the button advances.
  • Denied — the button becomes "Open Settings" (deep-links out), with a small "Continue" link below it. There's deliberately no button that skips before the system dialog — Apple's Guideline 5.1.1(iv) only permits a way forward after a denial, never a bypass of the dialog itself.

Fires .permissionRequested / .permissionGranted / .permissionDenied analytics events automatically, keyed by the key you pass in.

Why this order works

Value before commitment; small commitments before the ask; a "building" moment to deepen investment; then a paywall framed around what the user just told you. It defaults to a soft (skippable) paywall — flip allowSkip: false for a hard one. The deeper craft (when to use a questionnaire vs. value-paging, permission priming) lives in the Delicious Onboarding skill.

Next: Paywall & purchases.