Features

Review Requests

App Store ratings move downloads, but a badly-timed review prompt does the opposite: interrupt someone mid-task and you earn a one-star tap. ShipThatApp ships a review flow that asks at the right moment — only after a user has actually engaged, only once per app version, and always through Apple's native requestReview API.

You don't have to build any of the counting, gating, or timing logic. It's already wired into the app's landing flow; you mostly just decide what counts as "engagement" and how many times it should happen first.

The strategy in one breath

A prompt only appears when both are true:

  1. The user has completed a key process at least Config.Purchases.minAppRunsBeforeReviewReq times (default 4).
  2. The current app version hasn't already prompted (so updates don't re-nag).

When both hold, the app waits 2 seconds (so the prompt lands after the satisfying moment, not on top of it), then calls the system review sheet and records the version. This respects Apple's own guidance — and Apple still caps the actual sheet to three appearances per 365 days regardless, so over-asking is impossible.

Tunable in one place

The engagement threshold lives in Config.Purchases.minAppRunsBeforeReviewReq. There is no MIN_APP_RUNS_BEFORE_REVIEW_REQ global constant — that's a stale reference from older docs. See Configuration for the full Config reference.

How it works

Two pieces collaborate:

  • SettingsManager (Services/Settings/) persists the two counters in UserDefaults: how many times the process completed, and the last version that prompted.
  • LandingView (Views/) reads the SwiftUI requestReview environment value and runs the gating logic when the authenticated app appears.

Persistence: SettingsManager

SettingsManager is the app's central @Observable @MainActor settings store. Two of its stored properties back the review flow, both persisted through UserDefaults via didSet:

/// Number of times a key process has been completed (for review prompts).
var processCompletedCount: Int {
    didSet { UserDefaults.standard.set(processCompletedCount, forKey: Keys.processCompletedCount) }
}

/// The last app version that prompted for a review.
var lastVersionPromptedForReview: String? {
    didSet { UserDefaults.standard.set(lastVersionPromptedForReview, forKey: Keys.lastVersionPromptedForReview) }
}

Incrementing the engagement counter is a single call:

/// Increments the process completed count for review prompting logic.
func incrementProcessCount() {
    processCompletedCount += 1
}

The gate: LandingView

When the authenticated, tab-based app appears, LandingView increments the count and checks both conditions. The version comes straight from Info.plist:

/// Increments the process completion count and requests a review if criteria are met.
private func incrementProcessCount() {
    settingsManager.incrementProcessCount()
    let count = settingsManager.processCompletedCount
    Logger.viewCycle.info("Process before review completed \(count) time(s).")

    let currentVersion = Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as? String ?? ""

    if count >= Config.Purchases.minAppRunsBeforeReviewReq && currentVersion != settingsManager.lastVersionPromptedForReview {
        requestReviewAfterDelay(for: currentVersion)
    }
}

The ask: requestReviewAfterDelay

If the gate passes, the prompt is deferred two seconds, presented via the native API, and the version is recorded so it can't fire again on this build. An analytics event is logged for visibility:

/// Requests a review after a two-second delay.
private func requestReviewAfterDelay(for version: String) {
    Task {
        try await Task.sleep(for: .seconds(2))
        requestReview()
        settingsManager.lastVersionPromptedForReview = version
        TelemetryManager.send("reviewRequested", with: ["version": version])
    }
}

requestReview is the SwiftUI environment value, declared at the top of LandingView:

@Environment(\.requestReview) private var requestReview

Why the environment value, not StoreKit directly

@Environment(\.requestReview) is the modern, SwiftUI-native way to invoke SKStoreReviewController / AppStore.requestReview. It's testable, it respects the system's own frequency caps, and it requires no UIKit window plumbing.

Where the count is incremented today

Out of the box, LandingView.incrementProcessCount() is called in .onAppear of the authenticated tab view — so "engagement" currently means opening the app while signed in. With the default threshold of 4, a user sees the prompt on roughly their fourth qualifying launch.

That's a sensible default, but the real win is moving the increment to a genuine success moment.

Customize & extend

Change how often it asks

Edit one number in Config.swift:

enum Purchases {
    static let minAppRunsBeforeReviewReq = 4  // raise for fewer prompts
}

Tie it to a real "win" instead of a launch

The highest-converting reviews come right after a delightful moment. Call incrementProcessCount() from there instead of (or in addition to) app appear — for example, after a user saves their first Dex scan, finishes an AI chat, or generates an image. Inject SettingsManager and increment when the good thing happens:

@Environment(SettingsManager.self) private var settingsManager

// ...after a successful, satisfying action:
settingsManager.incrementProcessCount()

Then let the existing gate decide whether to prompt. Counting only meaningful completions makes the eventual prompt land on a happy user.

Reset for testing

SettingsManager.resetAllSettings() zeroes processCompletedCount and clears lastVersionPromptedForReview, so you can re-test the flow from scratch in the simulator without reinstalling.

The sheet won't always appear

requestReview() is a request, not a command. The system may show nothing — most commonly because Apple's three-prompts-per-365-days cap is hit, or because you're in a debug/TestFlight context. Don't treat the call as a guarantee, and never block UI waiting on it. Your code has done its job the moment it calls requestReview().

  • Configuration — where minAppRunsBeforeReviewReq and the product IDs live.
  • AuthenticationLandingView is also the auth router that hosts this logic.
  • Analytics — the reviewRequested TelemetryDeck event lets you measure prompt frequency.
Previous
Analytics