Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -302,11 +302,11 @@ internal class MenuScreenViewModel @Inject constructor(
combine(
userManager.state
.filter { it.authState is AuthState.Ready }
.map { it.userProfile?.username },
.map { it.userProfile?.username to (it.userProfile?.isUsernameAutoAssigned == true) },
userFlags.resolvedFlags.map { it.usernameMinBalance.effectiveValue },
totalBalance.observeTotalBalance(),
) { username, minimum, balance ->
val progress = when (val gate = usernameGate(username, minimum, balance)) {
) { (username, isAutoAssigned), minimum, balance ->
val progress = when (val gate = usernameGate(username, isAutoAssigned, minimum, balance)) {
UsernameGate.Claimed -> null
UsernameGate.Unlocked -> UsernameProgress.Unlocked
is UsernameGate.Locked -> UsernameProgress.Locked(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import com.getcode.opencode.model.financial.minus
* Mirrors iOS `usernameGate(session:minimum:)` in `UsernameGate.swift`.
*/
internal sealed interface UsernameGate {
/** A handle is already claimed — the nudge is spent, and changing it lives in My Account. */
/**
* A handle the user chose is already claimed — the nudge is spent, and changing it lives in
* My Account. A handle the server assigned does not count: the user hasn't picked it.
*/
data object Claimed : UsernameGate

/** Nothing in the way: either the balance clears the minimum, or there is no minimum. */
Expand All @@ -35,16 +38,19 @@ internal sealed interface UsernameGate {
}

/**
* @param username the account's claimed handle, null or blank when it hasn't claimed one.
* @param username the account's handle, null or blank when it has none.
* @param isAutoAssigned whether [username] was assigned by the server rather than chosen by the
* user. An assigned handle still counts as unclaimed, so the nudge keeps showing.
* @param minimum the balance the account must hold to claim, from the `usernameMinBalance` flag.
* @param balance the account's total balance, in the same currency as [minimum].
*/
internal fun usernameGate(
username: String?,
isAutoAssigned: Boolean,
minimum: Fiat,
balance: Fiat,
): UsernameGate = when {
!username.isNullOrBlank() -> UsernameGate.Claimed
!username.isNullOrBlank() && !isAutoAssigned -> UsernameGate.Claimed
// A zero minimum is no gate at all — which is also what an unresolved flag looks like. Either
// reading leaves nothing holding the account back, so both fail open.
!minimum.isPositive -> UsernameGate.Unlocked
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,44 +14,63 @@ class UsernameGateTest {
fun `a claimed handle spends the nudge, whatever the balance`() {
assertEquals(
UsernameGate.Claimed,
usernameGate(username = "mcansh", minimum = usd(25.0), balance = Fiat.Zero),
usernameGate(username = "mcansh", isAutoAssigned = false, minimum = usd(25.0), balance = Fiat.Zero),
)
}

@Test
fun `a blank handle counts as unclaimed`() {
assertIs<UsernameGate.Locked>(
usernameGate(username = " ", minimum = usd(25.0), balance = Fiat.Zero),
usernameGate(username = " ", isAutoAssigned = false, minimum = usd(25.0), balance = Fiat.Zero),
)
}

@Test
fun `an auto-assigned handle still counts as unclaimed`() {
assertIs<UsernameGate.Locked>(
usernameGate(username = "user4821", isAutoAssigned = true, minimum = usd(25.0), balance = Fiat.Zero),
)
}

@Test
fun `an auto-assigned handle follows the balance rules of no handle`() {
assertEquals(
UsernameGate.Unlocked,
usernameGate(username = "user4821", isAutoAssigned = true, minimum = usd(25.0), balance = usd(25.0)),
)
assertEquals(
UsernameGate.Unlocked,
usernameGate(username = "user4821", isAutoAssigned = true, minimum = Fiat.Zero, balance = Fiat.Zero),
)
}

@Test
fun `a zero minimum fails open`() {
assertEquals(
UsernameGate.Unlocked,
usernameGate(username = null, minimum = Fiat.Zero, balance = Fiat.Zero),
usernameGate(username = null, isAutoAssigned = false, minimum = Fiat.Zero, balance = Fiat.Zero),
)
}

@Test
fun `exactly the minimum unlocks`() {
assertEquals(
UsernameGate.Unlocked,
usernameGate(username = null, minimum = usd(25.0), balance = usd(25.0)),
usernameGate(username = null, isAutoAssigned = false, minimum = usd(25.0), balance = usd(25.0)),
)
}

@Test
fun `above the minimum unlocks`() {
assertEquals(
UsernameGate.Unlocked,
usernameGate(username = null, minimum = usd(25.0), balance = usd(25.01)),
usernameGate(username = null, isAutoAssigned = false, minimum = usd(25.0), balance = usd(25.01)),
)
}

@Test
fun `below the minimum reports the shortfall and how far along it is`() {
val gate = usernameGate(username = null, minimum = usd(25.0), balance = usd(20.0))
val gate = usernameGate(username = null, isAutoAssigned = false, minimum = usd(25.0), balance = usd(20.0))

assertIs<UsernameGate.Locked>(gate)
assertEquals(usd(25.0), gate.minimum)
Expand All @@ -61,7 +80,7 @@ class UsernameGateTest {

@Test
fun `an empty balance is zero progress, not a divide by zero`() {
val gate = usernameGate(username = null, minimum = usd(25.0), balance = Fiat.Zero)
val gate = usernameGate(username = null, isAutoAssigned = false, minimum = usd(25.0), balance = Fiat.Zero)

assertIs<UsernameGate.Locked>(gate)
assertEquals(usd(25.0), gate.shortfall)
Expand All @@ -70,7 +89,7 @@ class UsernameGateTest {

@Test
fun `a negative balance clamps to zero progress rather than a backwards bar`() {
val gate = usernameGate(username = null, minimum = usd(25.0), balance = usd(-5.0))
val gate = usernameGate(username = null, isAutoAssigned = false, minimum = usd(25.0), balance = usd(-5.0))

assertIs<UsernameGate.Locked>(gate)
assertEquals(0f, gate.fraction)
Expand Down
Loading