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()