From 89ee36c1a459bffcf0df6592ec1c798b32226712 Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Thu, 4 Jun 2026 01:05:24 +0200 Subject: [PATCH] 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) --- Cable/AppDelegate.swift | 1 + Cable/Loads/LoadsView.swift | 2 + Cable/ReviewPrompt.swift | 110 +++++++++++++++ Cable/Systems/SystemBillOfMaterialsView.swift | 1 + android/app/build.gradle.kts | 2 + .../app/voltplan/cable/CableApplication.kt | 5 +- .../app/voltplan/cable/data/ReviewPrompt.kt | 128 ++++++++++++++++++ .../voltplan/cable/data/UnitSystemSettings.kt | 2 +- .../cable/ui/bom/BillOfMaterialsScreen.kt | 6 +- .../cable/ui/system/SystemDetailScreen.kt | 5 + android/gradle/libs.versions.toml | 2 + 11 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 Cable/ReviewPrompt.swift create mode 100644 android/app/src/main/java/app/voltplan/cable/data/ReviewPrompt.kt diff --git a/Cable/AppDelegate.swift b/Cable/AppDelegate.swift index 4bcb246..8fa19ab 100644 --- a/Cable/AppDelegate.swift +++ b/Cable/AppDelegate.swift @@ -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 } diff --git a/Cable/Loads/LoadsView.swift b/Cable/Loads/LoadsView.swift index 354cf26..d396b16 100644 --- a/Cable/Loads/LoadsView.swift +++ b/Cable/Loads/LoadsView.swift @@ -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.") diff --git a/Cable/ReviewPrompt.swift b/Cable/ReviewPrompt.swift new file mode 100644 index 0000000..88474ea --- /dev/null +++ b/Cable/ReviewPrompt.swift @@ -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" + } +} diff --git a/Cable/Systems/SystemBillOfMaterialsView.swift b/Cable/Systems/SystemBillOfMaterialsView.swift index c7ae9f1..e7bf9ee 100644 --- a/Cable/Systems/SystemBillOfMaterialsView.swift +++ b/Cable/Systems/SystemBillOfMaterialsView.swift @@ -346,6 +346,7 @@ struct SystemBillOfMaterialsView: View { ) await MainActor.run { activeShareItem = ExportedPDFShareItem(url: url) + ReviewPrompt.registerSuccessfulExport() } } catch { await MainActor.run { diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index bdd9d8f..d0d7fbe 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -115,4 +115,6 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.coil.compose) + + implementation(libs.play.review.ktx) } diff --git a/android/app/src/main/java/app/voltplan/cable/CableApplication.kt b/android/app/src/main/java/app/voltplan/cable/CableApplication.kt index 90426e2..5a20e89 100644 --- a/android/app/src/main/java/app/voltplan/cable/CableApplication.kt +++ b/android/app/src/main/java/app/voltplan/cable/CableApplication.kt @@ -3,6 +3,7 @@ package app.voltplan.cable import android.app.Application import app.voltplan.cable.analytics.Analytics import app.voltplan.cable.data.CableRepository +import app.voltplan.cable.data.ReviewPrompt import app.voltplan.cable.data.UnitSystemSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -24,9 +25,11 @@ class CableApplication : Application() { // Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:). CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { - if (settings.consumeFirstLaunch()) { + val isFirstLaunch = settings.consumeFirstLaunch() + if (isFirstLaunch) { Analytics.log("First Launch") } + ReviewPrompt.migrateIfNeeded(this@CableApplication, isFirstLaunch) Analytics.log("App Launched") } } diff --git a/android/app/src/main/java/app/voltplan/cable/data/ReviewPrompt.kt b/android/app/src/main/java/app/voltplan/cable/data/ReviewPrompt.kt new file mode 100644 index 0000000..8e186e8 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/data/ReviewPrompt.kt @@ -0,0 +1,128 @@ +package app.voltplan.cable.data + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import app.voltplan.cable.BuildConfig +import app.voltplan.cable.analytics.Analytics +import com.google.android.play.core.ktx.launchReview +import com.google.android.play.core.ktx.requestReview +import com.google.android.play.core.review.ReviewManagerFactory +import kotlinx.coroutines.flow.first + +/** + * Decides when to ask the user for a Play Store rating via the Play In-App Review API. + * Google throttles the actual dialog (and shows nothing in debug/sideload builds), so this gate + * keeps requests rare and tied to genuine success moments — a completed export/share. + * Mirrors the iOS `ReviewPrompt` enum, sharing the same gate thresholds and the `cable_settings` + * DataStore so both platforms behave identically. + */ +object ReviewPrompt { + private val MIGRATION_DONE = stringPreferencesKey("review.migrationDone") + private val FIRST_LAUNCH_DATE = longPreferencesKey("review.firstLaunchDate") + private val EXPORT_COUNT = intPreferencesKey("review.successfulExportCount") + private val LAST_PROMPT_DATE = longPreferencesKey("review.lastPromptDate") + private val LAST_PROMPTED_VERSION = stringPreferencesKey("review.lastPromptedVersion") + private val USER_TYPE = stringPreferencesKey("review.userType") + + private const val MIN_EXPORTS = 2 + private const val MIN_DAYS_SINCE_INSTALL = 3L + private const val MIN_DAYS_BETWEEN_PROMPTS = 120L + private const val DAY_MS = 24L * 60 * 60 * 1000 + + /** + * 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 value returned by [UnitSystemSettings.consumeFirstLaunch]. + */ + suspend fun migrateIfNeeded(context: Context, isFirstLaunch: Boolean) { + if (context.dataStore.data.first()[MIGRATION_DONE] != null) return + val now = System.currentTimeMillis() + context.dataStore.edit { + if (isFirstLaunch) { + // Genuine new install: normal flow — needs 2 exports and 3 days. + it[FIRST_LAUNCH_DATE] = now + it[EXPORT_COUNT] = 0 + it[USER_TYPE] = "new" + } 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. + it[FIRST_LAUNCH_DATE] = now - MIN_DAYS_SINCE_INSTALL * DAY_MS + it[EXPORT_COUNT] = MIN_EXPORTS - 1 + it[USER_TYPE] = "existing" + } + it[MIGRATION_DONE] = "true" + } + } + + /** + * 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. + */ + suspend fun registerSuccessfulExport(context: Context) { + var count = 0 + var firstLaunch = 0L + context.dataStore.edit { + if (it[FIRST_LAUNCH_DATE] == null) it[FIRST_LAUNCH_DATE] = System.currentTimeMillis() + count = (it[EXPORT_COUNT] ?: 0) + 1 + it[EXPORT_COUNT] = count + firstLaunch = it[FIRST_LAUNCH_DATE] ?: 0L + } + if (shouldRequest(context, count, firstLaunch)) { + requestReview(context) + } + } + + private suspend fun shouldRequest(context: Context, exportCount: Int, firstLaunch: Long): Boolean { + // A: enough successful exports + if (exportCount < MIN_EXPORTS) return false + + val now = System.currentTimeMillis() + // B: installed long enough + if (now - firstLaunch < MIN_DAYS_SINCE_INSTALL * DAY_MS) return false + + val prefs = context.dataStore.data.first() + // C: not prompted too recently + val lastPrompt = prefs[LAST_PROMPT_DATE] ?: 0L + if (lastPrompt > 0 && now - lastPrompt < MIN_DAYS_BETWEEN_PROMPTS * DAY_MS) return false + + // D: at most once per app version + if (prefs[LAST_PROMPTED_VERSION] == BuildConfig.VERSION_NAME) return false + + return true + } + + private suspend fun requestReview(context: Context) { + // Mark as requested up front — Google may suppress the dialog, but we still count it + // against our own throttle so we don't ask again immediately. + context.dataStore.edit { + it[LAST_PROMPT_DATE] = System.currentTimeMillis() + it[LAST_PROMPTED_VERSION] = BuildConfig.VERSION_NAME + } + val userType = context.dataStore.data.first()[USER_TYPE] ?: "unknown" + Analytics.log( + "Review Prompt Requested", + mapOf("version" to BuildConfig.VERSION_NAME, "userType" to userType), + ) + + val activity = context.findActivity() ?: return + runCatching { + val manager = ReviewManagerFactory.create(context) + val reviewInfo = manager.requestReview() + manager.launchReview(activity, reviewInfo) + } + } + + private fun Context.findActivity(): Activity? { + var ctx: Context? = this + while (ctx is ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/data/UnitSystemSettings.kt b/android/app/src/main/java/app/voltplan/cable/data/UnitSystemSettings.kt index 00ff0f5..b636500 100644 --- a/android/app/src/main/java/app/voltplan/cable/data/UnitSystemSettings.kt +++ b/android/app/src/main/java/app/voltplan/cable/data/UnitSystemSettings.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.first -private val Context.dataStore by preferencesDataStore(name = "cable_settings") +internal val Context.dataStore by preferencesDataStore(name = "cable_settings") private val UNIT_SYSTEM_KEY = stringPreferencesKey("unitSystem") private val LAUNCHED_BEFORE_KEY = stringPreferencesKey("hasLaunchedBefore") diff --git a/android/app/src/main/java/app/voltplan/cable/ui/bom/BillOfMaterialsScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/bom/BillOfMaterialsScreen.kt index 6c5ea29..1022b71 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/bom/BillOfMaterialsScreen.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/bom/BillOfMaterialsScreen.kt @@ -46,6 +46,7 @@ import app.voltplan.cable.R import app.voltplan.cable.data.UnitSystem import app.voltplan.cable.ui.LocalUnitSettings import app.voltplan.cable.ui.loads.CalcState +import app.voltplan.cable.data.ReviewPrompt import app.voltplan.cable.pdf.SystemBomPdf import kotlinx.coroutines.launch @@ -74,7 +75,10 @@ fun BillOfMaterialsScreen(systemId: String, onBack: () -> Unit) { enabled = state.sections.isNotEmpty(), onClick = { vm.logPdfExported() - scope.launch { SystemBomPdf.exportAndShare(context, state, unit) } + scope.launch { + SystemBomPdf.exportAndShare(context, state, unit) + ReviewPrompt.registerSuccessfulExport(context) + } }, ) { Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.bom_export_pdf_button)) } }, diff --git a/android/app/src/main/java/app/voltplan/cable/ui/system/SystemDetailScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/system/SystemDetailScreen.kt index c0ea4c0..a7c77ac 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/system/SystemDetailScreen.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/system/SystemDetailScreen.kt @@ -51,6 +51,7 @@ import app.voltplan.cable.ui.overview.OverviewTab import app.voltplan.cable.ui.sfSymbol import app.voltplan.cable.ui.systemIconOptions import app.voltplan.cable.ui.theme.componentColor +import app.voltplan.cable.data.ReviewPrompt import app.voltplan.cable.pdf.SystemDiagram import app.voltplan.cable.pdf.SystemOverviewPdf import android.widget.Toast @@ -163,10 +164,13 @@ fun SystemDetailScreen( showOverviewMenu = false scope.launch { exporting = true + var failed = false SystemDiagram.exportAndShare(context, state, unitSystem) { + failed = true Toast.makeText(context, R.string.overview_share_diagram_error, Toast.LENGTH_LONG).show() } exporting = false + if (!failed) ReviewPrompt.registerSuccessfulExport(context) } }, ) @@ -179,6 +183,7 @@ fun SystemDetailScreen( exporting = true SystemOverviewPdf.exportAndShare(context, state, unitSystem) exporting = false + ReviewPrompt.registerSuccessfulExport(context) } }, ) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 1487410..742dce1 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -14,6 +14,7 @@ okhttp = "4.12.0" serialization = "1.7.3" retrofitSerialization = "1.0.0" coil = "2.7.0" +playReview = "2.0.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -39,6 +40,7 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +play-review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "playReview" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }