From e04df9104d5cfb8278b543d2b3123290b9917c33 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 11:56:49 -0400 Subject: [PATCH] feat(myaccount): show Change Username only once a handle is claimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row was unconditional, so an account with no handle got "Change Username" — a change of nothing. Claiming a first handle already has its own entrance in the You tab's progress card, which carries the minimum-balance gate and disappears once the handle exists; the My Account row now appears at exactly that point. MyAccountScreenViewModel reads userProfile.username off UserManager and folds it into the same buildItemList filter that already hides the biometrics and staff rows. It starts hidden, so the row doesn't flash in before the profile loads. Matches iOS SettingsMyAccountScreen, which wraps its row in `if let username = session.profile?.username`. --- .../internal/myaccount/MyAccountMenuItems.kt | 6 +- .../myaccount/MyAccountScreenViewModel.kt | 42 ++++++++++- .../MyAccountScreenViewModelStateTest.kt | 72 ++++++++++++++++++- 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt index 1142cbf06..e92da8175 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountMenuItems.kt @@ -38,8 +38,10 @@ internal data object ChangeDisplayName : FullMenuItem() { override val icon: Painter diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt index b96bb4411..96e0761cb 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/myaccount/MyAccountScreenViewModel.kt @@ -9,9 +9,11 @@ import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.StaffMenuItem import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.user.UserManager import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -31,6 +33,7 @@ internal class MyAccountScreenViewModel @Inject constructor( private val appSettings: AppSettingsCoordinator, featureFlagController: FeatureFlagController, userFlags: UserFlagsCoordinator, + userManager: UserManager, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -46,8 +49,15 @@ internal class MyAccountScreenViewModel @Inject constructor( // Why the row can't act, when it can't — e.g. the hardware is there with nothing enrolled. @StringRes val biometricsDescription: Int? = null, val betaUnlocked: Boolean = false, - // Staff-only rows stay out until the real flag state loads, so they never flash in. - val items: List> = FullMenuList.filterNot { it is StaffMenuItem }, + // Whether the account holds a handle. Changing one presupposes having one. + val usernameClaimed: Boolean = false, + // Conditional rows — staff, and the handle — stay out until their real state loads, so they + // never flash in for an account that shouldn't see them. + val items: List> = buildItemList( + biometricsSupported = true, + betaUnlocked = false, + usernameClaimed = false, + ), ) internal sealed interface Event { @@ -62,6 +72,7 @@ internal class MyAccountScreenViewModel @Inject constructor( data object OnBiometricsToggled : Event data object OnChangeDisplayNameClicked : Event data object OnEditDisplayName : Event + data class OnUsernameClaimChanged(val claimed: Boolean) : Event data object OnChangeUsernameClicked : Event data object OnEditUsername : Event data object OnBlocklistClicked: Event @@ -78,6 +89,13 @@ internal class MyAccountScreenViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnBetaFeaturesUnlocked(it)) } .launchIn(viewModelScope) + userManager.state + .map { it.userProfile?.username } + .map { username -> !username.isNullOrBlank() } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnUsernameClaimChanged(it)) } + .launchIn(viewModelScope) + appSettings.settings() .map { items -> items.find { it.setting.type == AppSettingValue.BiometricsRequired } } .onEach { item -> @@ -127,12 +145,17 @@ internal class MyAccountScreenViewModel @Inject constructor( } internal companion object { - /** Biometrics drops out on hardware that can't offer it; staff rows need the beta unlock. */ + /** + * Biometrics drops out on hardware that can't offer it; staff rows need the beta unlock; + * changing a handle needs one to already be claimed. + */ private fun buildItemList( biometricsSupported: Boolean, betaUnlocked: Boolean, + usernameClaimed: Boolean, ): List> = FullMenuList .filterNot { it == RequireBiometrics && !biometricsSupported } + .filterNot { it == ChangeUsername && !usernameClaimed } .filter { it !is StaffMenuItem || betaUnlocked } val updateStateForEvent: (Event) -> ((State) -> State) = { event -> @@ -153,6 +176,18 @@ internal class MyAccountScreenViewModel @Inject constructor( items = buildItemList( biometricsSupported = state.biometricsSupported, betaUnlocked = event.unlocked, + usernameClaimed = state.usernameClaimed, + ), + ) + } + + is Event.OnUsernameClaimChanged -> { state -> + state.copy( + usernameClaimed = event.claimed, + items = buildItemList( + biometricsSupported = state.biometricsSupported, + betaUnlocked = state.betaUnlocked, + usernameClaimed = event.claimed, ), ) } @@ -166,6 +201,7 @@ internal class MyAccountScreenViewModel @Inject constructor( items = buildItemList( biometricsSupported = event.supported, betaUnlocked = state.betaUnlocked, + usernameClaimed = state.usernameClaimed, ), ) } diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt index 6ac73f458..e95f15cbc 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt @@ -15,13 +15,81 @@ class MyAccountScreenViewModelStateTest { private val reduce = MyAccountScreenViewModel.Companion.updateStateForEvent + private fun claimed(state: MyAccountScreenViewModel.State) = + reduce(MyAccountScreenViewModel.Event.OnUsernameClaimChanged(claimed = true))(state) + @Test - fun `default state lists the display name, username, biometrics and blocklist`() { + fun `default state lists the display name, biometrics and blocklist`() { val state = MyAccountScreenViewModel.State() - assertEquals(listOf(ChangeDisplayName, ChangeUsername, RequireBiometrics, Blocklist), state.items) + assertEquals(listOf(ChangeDisplayName, RequireBiometrics, Blocklist), state.items) assertFalse(state.biometricsRequired) } + @Test + fun `changing the username is offered only once a handle is claimed`() { + val unclaimed = MyAccountScreenViewModel.State() + assertFalse(unclaimed.usernameClaimed) + assertFalse(unclaimed.items.any { it is ChangeUsername }) + + val withHandle = claimed(unclaimed) + + assertTrue(withHandle.usernameClaimed) + assertEquals( + listOf(ChangeDisplayName, ChangeUsername, RequireBiometrics, Blocklist), + withHandle.items, + ) + } + + @Test + fun `losing the handle takes the username row back out`() { + val dropped = reduce( + MyAccountScreenViewModel.Event.OnUsernameClaimChanged(claimed = false) + )(claimed(MyAccountScreenViewModel.State())) + + assertFalse(dropped.usernameClaimed) + assertFalse(dropped.items.any { it is ChangeUsername }) + } + + @Test + fun `biometrics changes keep a claimed username row visible`() { + val updated = reduce( + MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( + required = true, + supported = true, + available = true, + ) + )(claimed(MyAccountScreenViewModel.State())) + + assertTrue(updated.items.any { it is ChangeUsername }) + } + + @Test + fun `unlocking beta keeps a claimed username row visible`() { + val updated = reduce( + MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(unlocked = true) + )(claimed(MyAccountScreenViewModel.State())) + + assertTrue(updated.items.any { it is ChangeUsername }) + assertTrue(updated.items.any { it is UserProfile }) + } + + @Test + fun `claiming a handle leaves the other rows' conditions alone`() { + val noBiometrics = reduce( + MyAccountScreenViewModel.Event.OnBiometricsSettingChanged( + required = false, + supported = false, + available = false, + ) + )(MyAccountScreenViewModel.State()) + + val withHandle = claimed(noBiometrics) + + assertTrue(withHandle.items.any { it is ChangeUsername }) + assertFalse(withHandle.items.any { it is RequireBiometrics }) + assertFalse(withHandle.items.any { it is UserProfile }) + } + @Test fun `changing the display name is offered without the beta unlock`() { val locked = MyAccountScreenViewModel.State()