From 1f065dae3202fa0c594eaa82f09e2577ab99a310 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 17:13:44 -0400 Subject: [PATCH] feat(menu): show username card for auto-assigned handles The custom-username card only appeared for accounts with no handle, so a user whose handle the server assigned never got the nudge to pick their own. usernameGate now takes isAutoAssigned and treats an assigned handle as unclaimed; the balance and minimum rules are unchanged. The view model passes UserProfile.isUsernameAutoAssigned through. --- .../app/menu/internal/MenuScreenViewModel.kt | 6 ++-- .../app/menu/internal/UsernameGate.kt | 12 +++++-- .../app/menu/internal/UsernameGateTest.kt | 35 ++++++++++++++----- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 980b11cf72..f76bf29b7a 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -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( diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/UsernameGate.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/UsernameGate.kt index 48ce924e9e..f4d1e0f65b 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/UsernameGate.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/UsernameGate.kt @@ -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. */ @@ -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 diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/UsernameGateTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/UsernameGateTest.kt index 30628e48ad..bd14478968 100644 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/UsernameGateTest.kt +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/UsernameGateTest.kt @@ -14,14 +14,33 @@ 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(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(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), ) } @@ -29,7 +48,7 @@ class UsernameGateTest { 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), ) } @@ -37,7 +56,7 @@ class UsernameGateTest { 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)), ) } @@ -45,13 +64,13 @@ class UsernameGateTest { 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(gate) assertEquals(usd(25.0), gate.minimum) @@ -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(gate) assertEquals(usd(25.0), gate.shortfall) @@ -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(gate) assertEquals(0f, gate.fraction)