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:
- The user has completed a key process at least
Config.Purchases.minAppRunsBeforeReviewReqtimes (default 4). - 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 inUserDefaults: how many times the process completed, and the last version that prompted.LandingView(Views/) reads the SwiftUIrequestReviewenvironment 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().
Related
- Configuration — where
minAppRunsBeforeReviewReqand the product IDs live. - Authentication —
LandingViewis also the auth router that hosts this logic. - Analytics — the
reviewRequestedTelemetryDeck event lets you measure prompt frequency.