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 @@ -38,8 +38,10 @@ internal data object ChangeDisplayName : FullMenuItem<MyAccountScreenViewModel.E
* the "You" tab's progress card — because claiming a first handle and changing an existing one are
* the same screen, differing only in what the field is prefilled with.
*
* Unconditional: the minimum-balance gate is the server's, and an account that hasn't cleared it is
* told so on submit rather than being shown a row that isn't there.
* Shown only once a handle is claimed, matching iOS `SettingsMyAccountScreen`. Claiming the first
* one belongs to the You tab's card, which carries the minimum-balance gate and disappears the
* moment `usernameGate` reads `Claimed` — exactly where this row appears. No balance gate here: the
* minimum exists to stop squatting at claim time, and an account holding a handle has cleared it.
*/
internal data object ChangeUsername : FullMenuItem<MyAccountScreenViewModel.Event>() {
override val icon: Painter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +33,7 @@ internal class MyAccountScreenViewModel @Inject constructor(
private val appSettings: AppSettingsCoordinator,
featureFlagController: FeatureFlagController,
userFlags: UserFlagsCoordinator,
userManager: UserManager,
dispatchers: DispatcherProvider,
) : BaseViewModel<MyAccountScreenViewModel.State, MyAccountScreenViewModel.Event>(
initialState = State(),
Expand All @@ -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<MenuItem<Event>> = 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<MenuItem<Event>> = buildItemList(
biometricsSupported = true,
betaUnlocked = false,
usernameClaimed = false,
),
)

internal sealed interface Event {
Expand All @@ -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
Expand All @@ -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 ->
Expand Down Expand Up @@ -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<MenuItem<Event>> = FullMenuList
.filterNot { it == RequireBiometrics && !biometricsSupported }
.filterNot { it == ChangeUsername && !usernameClaimed }
.filter { it !is StaffMenuItem || betaUnlocked }

val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
Expand All @@ -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,
),
)
}
Expand All @@ -166,6 +201,7 @@ internal class MyAccountScreenViewModel @Inject constructor(
items = buildItemList(
biometricsSupported = event.supported,
betaUnlocked = state.betaUnlocked,
usernameClaimed = state.usernameClaimed,
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading