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")
|
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
|
||||||
AnalyticsTracker.log("First Launch")
|
AnalyticsTracker.log("First Launch")
|
||||||
}
|
}
|
||||||
|
ReviewPrompt.migrateIfNeeded(isFirstLaunch: isFirstLaunch)
|
||||||
AnalyticsTracker.log("App Launched")
|
AnalyticsTracker.log("App Launched")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1133,6 +1133,7 @@ struct LoadsView: View {
|
|||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
||||||
isExportingOverview = false
|
isExportingOverview = false
|
||||||
|
ReviewPrompt.registerSuccessfulExport()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -1162,6 +1163,7 @@ struct LoadsView: View {
|
|||||||
"system": snapshot.systemName,
|
"system": snapshot.systemName,
|
||||||
])
|
])
|
||||||
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
||||||
|
ReviewPrompt.registerSuccessfulExport()
|
||||||
} else {
|
} else {
|
||||||
overviewExportError = OverviewExportError(
|
overviewExportError = OverviewExportError(
|
||||||
message: String(localized: "overview.share.diagram.error", defaultValue: "Could not generate diagram. Check your internet connection.")
|
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 {
|
await MainActor.run {
|
||||||
activeShareItem = ExportedPDFShareItem(url: url)
|
activeShareItem = ExportedPDFShareItem(url: url)
|
||||||
|
ReviewPrompt.registerSuccessfulExport()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|||||||
@@ -115,4 +115,6 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
|
implementation(libs.play.review.ktx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app.voltplan.cable
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
import app.voltplan.cable.data.CableRepository
|
import app.voltplan.cable.data.CableRepository
|
||||||
|
import app.voltplan.cable.data.ReviewPrompt
|
||||||
import app.voltplan.cable.data.UnitSystemSettings
|
import app.voltplan.cable.data.UnitSystemSettings
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -24,9 +25,11 @@ class CableApplication : Application() {
|
|||||||
|
|
||||||
// Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:).
|
// Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:).
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
if (settings.consumeFirstLaunch()) {
|
val isFirstLaunch = settings.consumeFirstLaunch()
|
||||||
|
if (isFirstLaunch) {
|
||||||
Analytics.log("First Launch")
|
Analytics.log("First Launch")
|
||||||
}
|
}
|
||||||
|
ReviewPrompt.migrateIfNeeded(this@CableApplication, isFirstLaunch)
|
||||||
Analytics.log("App Launched")
|
Analytics.log("App Launched")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.flow.first
|
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 UNIT_SYSTEM_KEY = stringPreferencesKey("unitSystem")
|
||||||
private val LAUNCHED_BEFORE_KEY = stringPreferencesKey("hasLaunchedBefore")
|
private val LAUNCHED_BEFORE_KEY = stringPreferencesKey("hasLaunchedBefore")
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import app.voltplan.cable.R
|
|||||||
import app.voltplan.cable.data.UnitSystem
|
import app.voltplan.cable.data.UnitSystem
|
||||||
import app.voltplan.cable.ui.LocalUnitSettings
|
import app.voltplan.cable.ui.LocalUnitSettings
|
||||||
import app.voltplan.cable.ui.loads.CalcState
|
import app.voltplan.cable.ui.loads.CalcState
|
||||||
|
import app.voltplan.cable.data.ReviewPrompt
|
||||||
import app.voltplan.cable.pdf.SystemBomPdf
|
import app.voltplan.cable.pdf.SystemBomPdf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -74,7 +75,10 @@ fun BillOfMaterialsScreen(systemId: String, onBack: () -> Unit) {
|
|||||||
enabled = state.sections.isNotEmpty(),
|
enabled = state.sections.isNotEmpty(),
|
||||||
onClick = {
|
onClick = {
|
||||||
vm.logPdfExported()
|
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)) }
|
) { Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.bom_export_pdf_button)) }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import app.voltplan.cable.ui.overview.OverviewTab
|
|||||||
import app.voltplan.cable.ui.sfSymbol
|
import app.voltplan.cable.ui.sfSymbol
|
||||||
import app.voltplan.cable.ui.systemIconOptions
|
import app.voltplan.cable.ui.systemIconOptions
|
||||||
import app.voltplan.cable.ui.theme.componentColor
|
import app.voltplan.cable.ui.theme.componentColor
|
||||||
|
import app.voltplan.cable.data.ReviewPrompt
|
||||||
import app.voltplan.cable.pdf.SystemDiagram
|
import app.voltplan.cable.pdf.SystemDiagram
|
||||||
import app.voltplan.cable.pdf.SystemOverviewPdf
|
import app.voltplan.cable.pdf.SystemOverviewPdf
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -163,10 +164,13 @@ fun SystemDetailScreen(
|
|||||||
showOverviewMenu = false
|
showOverviewMenu = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
exporting = true
|
exporting = true
|
||||||
|
var failed = false
|
||||||
SystemDiagram.exportAndShare(context, state, unitSystem) {
|
SystemDiagram.exportAndShare(context, state, unitSystem) {
|
||||||
|
failed = true
|
||||||
Toast.makeText(context, R.string.overview_share_diagram_error, Toast.LENGTH_LONG).show()
|
Toast.makeText(context, R.string.overview_share_diagram_error, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
exporting = false
|
exporting = false
|
||||||
|
if (!failed) ReviewPrompt.registerSuccessfulExport(context)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -179,6 +183,7 @@ fun SystemDetailScreen(
|
|||||||
exporting = true
|
exporting = true
|
||||||
SystemOverviewPdf.exportAndShare(context, state, unitSystem)
|
SystemOverviewPdf.exportAndShare(context, state, unitSystem)
|
||||||
exporting = false
|
exporting = false
|
||||||
|
ReviewPrompt.registerSuccessfulExport(context)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ okhttp = "4.12.0"
|
|||||||
serialization = "1.7.3"
|
serialization = "1.7.3"
|
||||||
retrofitSerialization = "1.0.0"
|
retrofitSerialization = "1.0.0"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
|
playReview = "2.0.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
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" }
|
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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user