Add in-app rating prompt (iOS + Android)

Request an App Store / Play Store review after a successful export
(Overview PDF, BOM PDF, or wiring diagram). A shared gate keeps prompts
rare: >=2 successful exports, >=3 days since install, >=120 days since the
last prompt, and at most once per app version. A one-time migration
backdates existing users so the prompt can fire on their first export
after updating. Logs a "Review Prompt Requested" analytics event.

iOS uses StoreKit's AppStore.requestReview(in:) with UserDefaults state;
Android uses the Play In-App Review API with DataStore state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 01:05:24 +02:00
parent 23b117bfe2
commit 89ee36c1a4
11 changed files with 261 additions and 3 deletions

View File

@@ -21,6 +21,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
AnalyticsTracker.log("First Launch")
}
ReviewPrompt.migrateIfNeeded(isFirstLaunch: isFirstLaunch)
AnalyticsTracker.log("App Launched")
return true
}

View File

@@ -1133,6 +1133,7 @@ struct LoadsView: View {
await MainActor.run {
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
isExportingOverview = false
ReviewPrompt.registerSuccessfulExport()
}
} catch {
await MainActor.run {
@@ -1162,6 +1163,7 @@ struct LoadsView: View {
"system": snapshot.systemName,
])
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
ReviewPrompt.registerSuccessfulExport()
} else {
overviewExportError = OverviewExportError(
message: String(localized: "overview.share.diagram.error", defaultValue: "Could not generate diagram. Check your internet connection.")

110
Cable/ReviewPrompt.swift Normal file
View File

@@ -0,0 +1,110 @@
//
// 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 — a completed export/share. Mirrors the Android `ReviewPrompt` object.
//
import Foundation
import StoreKit
import UIKit
enum ReviewPrompt {
private enum Key {
static let migrationDone = "review.migrationDone"
static let firstLaunchDate = "review.firstLaunchDate"
static let exportCount = "review.successfulExportCount"
static let lastPromptDate = "review.lastPromptDate"
static let lastPromptedVersion = "review.lastPromptedVersion"
static let userType = "review.userType"
}
/// Gate thresholds — see CLAUDE-discussed spec.
private static let minExports = 2
private static let minDaysSinceInstall: TimeInterval = 3
private static let minDaysBetweenPrompts: TimeInterval = 120
private static let day: TimeInterval = 86_400
private static var defaults: UserDefaults { .standard }
/// One-time setup distinguishing fresh installs from users updating into this feature.
/// Existing users are backdated and pre-seeded so the prompt can fire on their *first*
/// successful export after updating. Pass the `isFirstLaunch` value already computed in
/// `AppDelegate` (the existing `hasLaunchedBefore` flag).
static func migrateIfNeeded(isFirstLaunch: Bool) {
guard !defaults.bool(forKey: Key.migrationDone) else { return }
let now = Date().timeIntervalSince1970
if isFirstLaunch {
// Genuine new install: normal flow — needs 2 exports and 3 days.
defaults.set(now, forKey: Key.firstLaunchDate)
defaults.set(0, forKey: Key.exportCount)
defaults.set("new", forKey: Key.userType)
} else {
// Existing user updating in: backdate install past the age gate and pre-seed the
// counter so the very next successful export satisfies the gate.
defaults.set(now - minDaysSinceInstall * day, forKey: Key.firstLaunchDate)
defaults.set(minExports - 1, forKey: Key.exportCount)
defaults.set("existing", forKey: Key.userType)
}
defaults.set(true, forKey: Key.migrationDone)
}
/// Call after any successful export/share (Overview PDF, BOM PDF, Diagram image).
/// Increments the shared counter, then requests a review if every gate condition holds.
@MainActor
static func registerSuccessfulExport() {
// Guard against an export that races ahead of migration.
if defaults.object(forKey: Key.firstLaunchDate) == nil {
defaults.set(Date().timeIntervalSince1970, forKey: Key.firstLaunchDate)
}
let count = defaults.integer(forKey: Key.exportCount) + 1
defaults.set(count, forKey: Key.exportCount)
guard shouldRequest(exportCount: count) else { return }
request()
}
private static func shouldRequest(exportCount: Int) -> Bool {
// A: enough successful exports
guard exportCount >= minExports else { return false }
let now = Date().timeIntervalSince1970
// B: installed long enough
let firstLaunch = defaults.double(forKey: Key.firstLaunchDate)
guard now - firstLaunch >= minDaysSinceInstall * day else { return false }
// C: not prompted too recently
let lastPrompt = defaults.double(forKey: Key.lastPromptDate)
if lastPrompt > 0, now - lastPrompt < minDaysBetweenPrompts * day { return false }
// D: at most once per app version
if defaults.string(forKey: Key.lastPromptedVersion) == currentVersion { return false }
return true
}
@MainActor
private static func request() {
// Mark as requested up front — the OS may suppress the dialog, but we still
// count it against our own throttle so we don't ask again immediately.
defaults.set(Date().timeIntervalSince1970, forKey: Key.lastPromptDate)
defaults.set(currentVersion, forKey: Key.lastPromptedVersion)
AnalyticsTracker.log("Review Prompt Requested", properties: [
"version": currentVersion,
"userType": defaults.string(forKey: Key.userType) ?? "unknown",
])
guard let scene = UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene else { return }
AppStore.requestReview(in: scene)
}
private static var currentVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
}
}

View File

@@ -346,6 +346,7 @@ struct SystemBillOfMaterialsView: View {
)
await MainActor.run {
activeShareItem = ExportedPDFShareItem(url: url)
ReviewPrompt.registerSuccessfulExport()
}
} catch {
await MainActor.run {