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

@@ -115,4 +115,6 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.coil.compose)
implementation(libs.play.review.ktx)
}

View File

@@ -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")
}
}

View File

@@ -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
}
}

View File

@@ -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")

View File

@@ -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)) }
},

View File

@@ -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)
}
},
)