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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
110
Cable/ReviewPrompt.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -346,6 +346,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
)
|
||||
await MainActor.run {
|
||||
activeShareItem = ExportedPDFShareItem(url: url)
|
||||
ReviewPrompt.registerSuccessfulExport()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
|
||||
Reference in New Issue
Block a user