The old gate needed two exports, three days of install age, one prompt per version and 120 days between prompts. Aptabase shows only 9% of users ever export and 5% export twice, so `Review Prompt Requested` never fired once in 19 days and the store has a single rating. Replace the export counter with three milestones -- a fully configured system, an opened bill of materials, a completed export -- each counted at most once per install, and ask once two different ones are reached. Recording and asking are now separate: milestones are booked mid-task, where StoreKit and Play drop the request, so the ask happens from calm screens only (system overview, export preview dismissal, share sheet dismissal). Two fixes that made the old prompt lose its slot for good: the throttle keys were written before checking for a foreground scene, and the export trigger fired while the Quick Look sheet was still animating away. Both platforms now mark the throttle only once the store API really has a review flow to show. Add a Settings entry that links straight to the store review page. It is never throttled, and 45% of users open Settings versus 9% who export. New analytics event `Review Milestone Reached` makes the funnel measurable. Existing installs keep their legacy export credit and still need a second milestone before being asked.
142 lines
6.3 KiB
Swift
142 lines
6.3 KiB
Swift
//
|
||
// ReviewPrompt.swift
|
||
// Cable
|
||
//
|
||
// Decides when to ask the user for an App Store rating via StoreKit's
|
||
// `AppStore.requestReview(in:)`. The OS throttles the actual dialog (max ~3×/year and
|
||
// may show nothing at all), so this gate keeps requests rare and tied to genuine success
|
||
// moments. Recording a milestone and asking for the rating are deliberately separate:
|
||
// milestones are reached in the middle of a task (a sheet opens, a share sheet closes),
|
||
// which is exactly when StoreKit drops the request. `promptIfEligible()` is therefore only
|
||
// called from calm screens. Mirrors the Android `ReviewPrompt` object.
|
||
//
|
||
|
||
import Foundation
|
||
import StoreKit
|
||
import UIKit
|
||
|
||
enum ReviewPrompt {
|
||
/// Distinct proofs that the user got real value out of the app. Each one counts at most
|
||
/// once per install, so navigating in circles cannot inflate the gate.
|
||
enum Milestone: String {
|
||
/// A system whose loads are all fully configured — the app's own definition of a finished plan.
|
||
case systemPlanned
|
||
/// Opened a system's bill of materials.
|
||
case billOfMaterials
|
||
/// Completed an export/share (Overview PDF, BOM PDF, diagram image).
|
||
case exported
|
||
}
|
||
|
||
private enum Key {
|
||
static let gateVersion = "review.gateVersion"
|
||
static let milestones = "review.milestones"
|
||
static let lastPromptDate = "review.lastPromptDate"
|
||
static let lastPromptedVersion = "review.lastPromptedVersion"
|
||
static let userType = "review.userType"
|
||
static let legacyExportCount = "review.successfulExportCount"
|
||
static let legacyFirstLaunchDate = "review.firstLaunchDate"
|
||
static let legacyMigrationDone = "review.migrationDone"
|
||
}
|
||
|
||
/// Two *different* milestones — a single one (only opening the parts list, say) is not enough.
|
||
private static let minMilestones = 2
|
||
private static let minDaysBetweenPrompts: TimeInterval = 90
|
||
private static let day: TimeInterval = 86_400
|
||
/// Bump when the milestone semantics change so `migrateIfNeeded` runs again.
|
||
private static let gateVersion = 2
|
||
/// Lets a sheet dismissal finish before StoreKit tries to present on the same scene.
|
||
private static let presentationDelay: TimeInterval = 0.7
|
||
|
||
/// Deep link for the manual entry point in Settings. Unlike `AppStore.requestReview`, this
|
||
/// is never throttled or suppressed, so it is the only path a willing user can always take.
|
||
static let writeReviewURL = URL(string: "https://apps.apple.com/app/id6752443870?action=write-review")!
|
||
|
||
/// Injection seam for tests; production always uses `.standard`.
|
||
static var store: UserDefaults = .standard
|
||
|
||
/// One-time setup, called on every launch. Existing installs keep the credit they earned
|
||
/// under the previous export-only gate: the legacy counter was pre-seeded on update, so any
|
||
/// non-zero value means "knows the app already" and counts as the export milestone. They
|
||
/// still need a second, real milestone before we ask.
|
||
static func migrateIfNeeded(isFirstLaunch: Bool) {
|
||
guard store.integer(forKey: Key.gateVersion) < gateVersion else { return }
|
||
|
||
if store.object(forKey: Key.userType) == nil {
|
||
store.set(isFirstLaunch ? "new" : "existing", forKey: Key.userType)
|
||
}
|
||
if store.integer(forKey: Key.legacyExportCount) > 0 {
|
||
record(.exported)
|
||
}
|
||
for key in [Key.legacyExportCount, Key.legacyFirstLaunchDate, Key.legacyMigrationDone] {
|
||
store.removeObject(forKey: key)
|
||
}
|
||
store.set(gateVersion, forKey: Key.gateVersion)
|
||
}
|
||
|
||
/// Books a success moment. Never presents anything — safe to call from anywhere.
|
||
static func record(_ milestone: Milestone) {
|
||
var reached = Set(store.stringArray(forKey: Key.milestones) ?? [])
|
||
guard reached.insert(milestone.rawValue).inserted else { return }
|
||
store.set(reached.sorted(), forKey: Key.milestones)
|
||
|
||
AnalyticsTracker.log("Review Milestone Reached", properties: [
|
||
"milestone": milestone.rawValue,
|
||
"reached": reached.count,
|
||
])
|
||
}
|
||
|
||
/// Asks for a rating if the gate allows it. Call only from a screen at rest — never while a
|
||
/// sheet is being presented or dismissed.
|
||
@MainActor
|
||
static func promptIfEligible() {
|
||
guard isEligible else { return }
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + presentationDelay) { present() }
|
||
}
|
||
|
||
/// Internal rather than private so the gate can be tested without a foreground scene.
|
||
static var isEligible: Bool {
|
||
// A: enough distinct milestones
|
||
let reached = store.stringArray(forKey: Key.milestones) ?? []
|
||
guard reached.count >= minMilestones else { return false }
|
||
|
||
// B: not prompted too recently
|
||
let now = Date().timeIntervalSince1970
|
||
let lastPrompt = store.double(forKey: Key.lastPromptDate)
|
||
if lastPrompt > 0, now - lastPrompt < minDaysBetweenPrompts * day { return false }
|
||
|
||
// C: at most once per app version
|
||
if store.string(forKey: Key.lastPromptedVersion) == currentVersion { return false }
|
||
|
||
return true
|
||
}
|
||
|
||
@MainActor
|
||
private static func present() {
|
||
// Re-check after the delay: the app may have been backgrounded, or another trigger
|
||
// may have gotten there first. Only mark the throttle once we really can present —
|
||
// otherwise a suppressed request would burn this version's single slot.
|
||
guard isEligible, let scene = activeScene else { return }
|
||
|
||
store.set(Date().timeIntervalSince1970, forKey: Key.lastPromptDate)
|
||
store.set(currentVersion, forKey: Key.lastPromptedVersion)
|
||
|
||
AnalyticsTracker.log("Review Prompt Requested", properties: [
|
||
"version": currentVersion,
|
||
"userType": store.string(forKey: Key.userType) ?? "unknown",
|
||
"milestones": (store.stringArray(forKey: Key.milestones) ?? []).joined(separator: ","),
|
||
])
|
||
|
||
AppStore.requestReview(in: scene)
|
||
}
|
||
|
||
@MainActor
|
||
private static var activeScene: UIWindowScene? {
|
||
UIApplication.shared.connectedScenes
|
||
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
|
||
}
|
||
|
||
private static var currentVersion: String {
|
||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||
}
|
||
}
|