Files
Cable/Cable/ReviewPrompt.swift
Stefan Lange-Hegermann 9673bde107 Review: ask on value milestones, not exports
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.
2026-08-19 15:07:46 +02:00

142 lines
6.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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"
}
}