Skip to content

Commit fddc087

Browse files
committed
feat(RevenueCatUI): evaluate state conditions in the override resolver
1 parent dee70ca commit fddc087

2 files changed

Lines changed: 144 additions & 2 deletions

File tree

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/PresentedPartial.kt

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptyList
1111
import com.revenuecat.purchases.ui.revenuecatui.helpers.Result
1212
import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrElse
1313
import dev.drewhamilton.poko.Poko
14+
import kotlinx.serialization.json.JsonPrimitive
1415
import kotlinx.serialization.json.booleanOrNull
1516
import kotlinx.serialization.json.doubleOrNull
17+
import kotlin.math.abs
1618

1719
/**
1820
* Partial components transformed and ready for presentation.
@@ -91,6 +93,8 @@ internal fun <T : PartialComponent, P : PresentedPartial<P>> List<ComponentOverr
9193
internal class ConditionContext(
9294
val selectedPackageId: String?,
9395
val customVariables: Map<String, CustomVariableValue>,
96+
val stateValues: Map<String, JsonPrimitive> = emptyMap(),
97+
val stateDefaults: Map<String, JsonPrimitive> = emptyMap(),
9498
)
9599

96100
/**
@@ -147,8 +151,7 @@ private fun ComponentOverride.Condition.evaluate(
147151
is ComponentOverride.Condition.PromoOfferRule -> evaluate(offerEligibility)
148152
is ComponentOverride.Condition.SelectedPackage -> evaluate(conditionContext.selectedPackageId)
149153
is ComponentOverride.Condition.Variable -> evaluate(conditionContext.customVariables)
150-
// State condition evaluation lands in a later phase; until then it never applies its override.
151-
is ComponentOverride.Condition.State -> false
154+
is ComponentOverride.Condition.State -> evaluate(conditionContext.stateValues, conditionContext.stateDefaults)
152155
ComponentOverride.Condition.Unsupported -> false
153156
}
154157

@@ -199,6 +202,35 @@ private fun ComponentOverride.Condition.Variable.matchesValue(
199202
else -> false
200203
}
201204

205+
private const val STATE_NUMBER_COMPARISON_EPSILON = 1e-10
206+
207+
private fun ComponentOverride.Condition.State.evaluate(
208+
stateValues: Map<String, JsonPrimitive>,
209+
stateDefaults: Map<String, JsonPrimitive>,
210+
): Boolean {
211+
// An undeclared key (absent from both the store and the declared defaults) never applies its override,
212+
// regardless of operator.
213+
val current = stateValues[name] ?: stateDefaults[name] ?: return false
214+
val matches = matchesValue(current)
215+
return when (operator) {
216+
ComponentOverride.EqualityOperator.EQUALS -> matches
217+
ComponentOverride.EqualityOperator.NOT_EQUALS -> !matches
218+
}
219+
}
220+
221+
private fun ComponentOverride.Condition.State.matchesValue(current: JsonPrimitive): Boolean = when {
222+
value.isString || current.isString ->
223+
value.isString && current.isString && value.content == current.content
224+
value.booleanOrNull != null || current.booleanOrNull != null ->
225+
value.booleanOrNull == current.booleanOrNull
226+
else -> {
227+
val expectedNumber = value.doubleOrNull
228+
val currentNumber = current.doubleOrNull
229+
expectedNumber != null && currentNumber != null &&
230+
abs(expectedNumber - currentNumber) < STATE_NUMBER_COMPARISON_EPSILON
231+
}
232+
}
233+
202234
private val ScreenCondition.applicableConditions: Set<ComponentOverride.Condition>
203235
get() = when (this) {
204236
ScreenCondition.COMPACT -> setOf(ComponentOverride.Condition.Compact)

ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/BuildPresentedPartialTests.kt

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ internal class BuildPresentedPartialTests(@Suppress("UNUSED_PARAMETER") name: St
4949
val expected: LocalizedTextPartial?,
5050
val selectedPackageId: String? = null,
5151
val customVariables: Map<String, CustomVariableValue> = emptyMap(),
52+
val stateValues: Map<String, JsonPrimitive> = emptyMap(),
53+
val stateDefaults: Map<String, JsonPrimitive> = emptyMap(),
5254
)
5355

5456
@Suppress("LargeClass")
@@ -194,6 +196,27 @@ internal class BuildPresentedPartialTests(@Suppress("UNUSED_PARAMETER") name: St
194196
return overrides
195197
}
196198

199+
private fun stateCondition(
200+
operator: ComponentOverride.EqualityOperator,
201+
name: String,
202+
value: JsonPrimitive,
203+
) = ComponentOverride.Condition.State(operator = operator, name = name, value = value)
204+
205+
private fun stateArgs(
206+
condition: ComponentOverride.Condition.State,
207+
stateValues: Map<String, JsonPrimitive> = emptyMap(),
208+
stateDefaults: Map<String, JsonPrimitive> = emptyMap(),
209+
expected: LocalizedTextPartial?,
210+
) = Args(
211+
availableOverrides = listOf(PresentedOverride(listOf(condition), selectedPartial)),
212+
windowSize = COMPACT,
213+
offerEligibility = Ineligible,
214+
state = DEFAULT,
215+
stateValues = stateValues,
216+
stateDefaults = stateDefaults,
217+
expected = expected,
218+
)
219+
197220
@Suppress("LongMethod")
198221
@JvmStatic
199222
@Parameterized.Parameters(name = "{0}")
@@ -1169,6 +1192,91 @@ internal class BuildPresentedPartialTests(@Suppress("UNUSED_PARAMETER") name: St
11691192
),
11701193
),
11711194

1195+
// State condition tests
1196+
arrayOf(
1197+
"state boolean equals: applies when the stored value matches",
1198+
stateArgs(
1199+
condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "open", JsonPrimitive(true)),
1200+
stateValues = mapOf("open" to JsonPrimitive(true)),
1201+
expected = selectedPartial,
1202+
),
1203+
),
1204+
arrayOf(
1205+
"state boolean equals: does not apply when the stored value differs",
1206+
stateArgs(
1207+
condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "open", JsonPrimitive(true)),
1208+
stateValues = mapOf("open" to JsonPrimitive(false)),
1209+
expected = null,
1210+
),
1211+
),
1212+
arrayOf(
1213+
"state string not-equals: applies when the stored value differs",
1214+
stateArgs(
1215+
condition = stateCondition(
1216+
ComponentOverride.EqualityOperator.NOT_EQUALS, "tab", JsonPrimitive("billing"),
1217+
),
1218+
stateValues = mapOf("tab" to JsonPrimitive("usage")),
1219+
expected = selectedPartial,
1220+
),
1221+
),
1222+
arrayOf(
1223+
"state integer equals: applies when the stored value matches",
1224+
stateArgs(
1225+
condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "slide", JsonPrimitive(2)),
1226+
stateValues = mapOf("slide" to JsonPrimitive(2)),
1227+
expected = selectedPartial,
1228+
),
1229+
),
1230+
arrayOf(
1231+
"state number: integer condition matches a double-typed store value",
1232+
stateArgs(
1233+
condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "slide", JsonPrimitive(2)),
1234+
stateValues = mapOf("slide" to JsonPrimitive(2.0)),
1235+
expected = selectedPartial,
1236+
),
1237+
),
1238+
arrayOf(
1239+
"state: type mismatch evaluates as not equal",
1240+
stateArgs(
1241+
condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "slide", JsonPrimitive(2)),
1242+
stateValues = mapOf("slide" to JsonPrimitive("2")),
1243+
expected = null,
1244+
),
1245+
),
1246+
arrayOf(
1247+
"state: missing value falls back to the declared default",
1248+
stateArgs(
1249+
condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "open", JsonPrimitive(true)),
1250+
stateDefaults = mapOf("open" to JsonPrimitive(true)),
1251+
expected = selectedPartial,
1252+
),
1253+
),
1254+
arrayOf(
1255+
"state: stored value wins over the declared default",
1256+
stateArgs(
1257+
condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "open", JsonPrimitive(true)),
1258+
stateValues = mapOf("open" to JsonPrimitive(true)),
1259+
stateDefaults = mapOf("open" to JsonPrimitive(false)),
1260+
expected = selectedPartial,
1261+
),
1262+
),
1263+
arrayOf(
1264+
"state: undeclared key with equals never applies",
1265+
stateArgs(
1266+
condition = stateCondition(ComponentOverride.EqualityOperator.EQUALS, "ghost", JsonPrimitive(true)),
1267+
expected = null,
1268+
),
1269+
),
1270+
arrayOf(
1271+
"state: undeclared key with not-equals never applies",
1272+
stateArgs(
1273+
condition = stateCondition(
1274+
ComponentOverride.EqualityOperator.NOT_EQUALS, "ghost", JsonPrimitive(true),
1275+
),
1276+
expected = null,
1277+
),
1278+
),
1279+
11721280
// IntroOffer (legacy object) tests
11731281
arrayOf(
11741282
"intro_offer: should apply when eligible",
@@ -2057,6 +2165,8 @@ internal class BuildPresentedPartialTests(@Suppress("UNUSED_PARAMETER") name: St
20572165
conditionContext = ConditionContext(
20582166
selectedPackageId = args.selectedPackageId,
20592167
customVariables = args.customVariables,
2168+
stateValues = args.stateValues,
2169+
stateDefaults = args.stateDefaults,
20602170
),
20612171
)
20622172

0 commit comments

Comments
 (0)