BIT-1133: Add account switcher to Landing Screen (#323)

This commit is contained in:
Brian Yencho 2023-12-05 15:35:27 -06:00 committed by Álison Fernandes
parent c729d7da1b
commit a106f0852a
9 changed files with 250 additions and 8 deletions

View file

@ -266,7 +266,8 @@ class AuthRepositoryImpl constructor(
val previousActiveUserId = currentUserState.activeUserId
if (userId == previousActiveUserId) {
// Nothing to do
// No switching to do but clear any special circumstances
specialCircumstance = null
return SwitchAccountResult.NoChange
}
@ -281,6 +282,10 @@ class AuthRepositoryImpl constructor(
// Lock and clear data for the previous user
vaultRepository.lockVaultIfNecessary(previousActiveUserId)
vaultRepository.clearUnlockedData()
// Clear any special circumstances
specialCircumstance = null
return SwitchAccountResult.AccountSwitched
}

View file

@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
@ -46,14 +47,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenPlaceholderAccountActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import kotlinx.collections.immutable.toImmutableList
/**
* The top level composable for the Landing screen.
@ -86,17 +91,34 @@ fun LandingScreen(
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val isAppBarVisible = state.accountSummaries.isNotEmpty()
var isAccountMenuVisible by rememberSaveable { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
state = rememberTopAppBarState(),
canScroll = { !isAccountMenuVisible },
)
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
// Empty
if (isAppBarVisible) {
BitwardenTopAppBar(
title = "",
scrollBehavior = scrollBehavior,
navigationIcon = null,
actions = {
BitwardenPlaceholderAccountActionItem(
onClick = { isAccountMenuVisible = !isAccountMenuVisible },
)
},
)
}
},
) { innerPadding ->
LandingScreenContent(
state = state,
isAppBarVisible = isAppBarVisible,
onEmailInputChange = remember(viewModel) {
{ viewModel.trySendAction(LandingAction.EmailInputChanged(it)) }
},
@ -116,6 +138,23 @@ fun LandingScreen(
.padding(innerPadding)
.fillMaxSize(),
)
BitwardenAccountSwitcher(
isVisible = isAccountMenuVisible,
accountSummaries = state.accountSummaries.toImmutableList(),
onAccountSummaryClick = remember(viewModel) {
{ viewModel.trySendAction(LandingAction.SwitchAccountClick(it)) }
},
onAddAccountClick = {
// Not available
},
onDismissRequest = { isAccountMenuVisible = false },
isAddAccountAvailable = false,
topAppBarScrollBehavior = scrollBehavior,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
@ -124,6 +163,7 @@ fun LandingScreen(
@Composable
private fun LandingScreenContent(
state: LandingState,
isAppBarVisible: Boolean,
onEmailInputChange: (String) -> Unit,
onEnvironmentTypeSelect: (Environment.Type) -> Unit,
onRememberMeToggle: (Boolean) -> Unit,
@ -138,7 +178,8 @@ private fun LandingScreenContent(
.imePadding()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(104.dp))
val topPadding = if (isAppBarVisible) 40.dp else 104.dp
Spacer(modifier = Modifier.height(topPadding))
Image(
painter = painterResource(id = R.drawable.logo),

View file

@ -11,6 +11,8 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -36,6 +38,7 @@ class LandingViewModel @Inject constructor(
isRememberMeEnabled = authRepository.rememberedEmailAddress != null,
selectedEnvironmentType = environmentRepository.environment.type,
errorDialogState = BasicDialogState.Hidden,
accountSummaries = authRepository.userStateFlow.value?.toAccountSummaries().orEmpty(),
),
) {
@ -61,6 +64,7 @@ class LandingViewModel @Inject constructor(
override fun handleAction(action: LandingAction) {
when (action) {
is LandingAction.SwitchAccountClick -> handleSwitchAccountClicked(action)
is LandingAction.ContinueButtonClick -> handleContinueButtonClicked()
LandingAction.CreateAccountClick -> handleCreateAccountClicked()
is LandingAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
@ -73,6 +77,10 @@ class LandingViewModel @Inject constructor(
}
}
private fun handleSwitchAccountClicked(action: LandingAction.SwitchAccountClick) {
authRepository.switchAccount(userId = action.account.userId)
}
private fun handleEmailInputUpdated(action: LandingAction.EmailInputChanged) {
val email = action.input
mutableStateFlow.update {
@ -156,6 +164,7 @@ data class LandingState(
val isRememberMeEnabled: Boolean,
val selectedEnvironmentType: Environment.Type,
val errorDialogState: BasicDialogState,
val accountSummaries: List<AccountSummary>,
) : Parcelable
/**
@ -184,6 +193,13 @@ sealed class LandingEvent {
* Models actions for the landing screen.
*/
sealed class LandingAction {
/**
* Indicates the user has clicked on the given [account] information in order to switch to it.
*/
data class SwitchAccountClick(
val account: AccountSummary,
) : LandingAction()
/**
* Indicates that the continue button has been clicked and the app should navigate to Login.
*/

View file

@ -66,6 +66,9 @@ private const val MAXIMUM_ACCOUNT_LIMIT = 5
* @param onAddAccountClick A callback when the Add Account row is clicked.
* @param onDismissRequest A callback when the component requests to be dismissed. This is triggered
* whenever the user clicks on the scrim or any of the switcher items.
* @param isAddAccountAvailable Whether or not the "Add account" button is available. Note that even
* when `true`, this button may be hidden when there are more than [MAXIMUM_ACCOUNT_LIMIT] accounts
* present.
* @param modifier A [Modifier] for the composable.
* @param topAppBarScrollBehavior Used to derive the background color of the content and keep it in
* sync with the associated app bar.
@ -79,6 +82,7 @@ fun BitwardenAccountSwitcher(
onAddAccountClick: () -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
isAddAccountAvailable: Boolean = true,
topAppBarScrollBehavior: TopAppBarScrollBehavior,
) {
Box(modifier = modifier) {
@ -99,6 +103,7 @@ fun BitwardenAccountSwitcher(
onDismissRequest()
onAddAccountClick()
},
isAddAccountAvailable = isAddAccountAvailable,
topAppBarScrollBehavior = topAppBarScrollBehavior,
modifier = Modifier
.fillMaxWidth(),
@ -113,6 +118,7 @@ private fun AnimatedAccountSwitcher(
accountSummaries: ImmutableList<AccountSummary>,
onAccountSummaryClick: (AccountSummary) -> Unit,
onAddAccountClick: () -> Unit,
isAddAccountAvailable: Boolean,
modifier: Modifier = Modifier,
topAppBarScrollBehavior: TopAppBarScrollBehavior,
) {
@ -130,11 +136,16 @@ private fun AnimatedAccountSwitcher(
.padding(bottom = 24.dp)
// Match the color of the switcher the different states of the app bar.
.drawBehind {
val progressFraction = if (topAppBarScrollBehavior.isPinned) {
topAppBarScrollBehavior.state.overlappedFraction
} else {
topAppBarScrollBehavior.state.collapsedFraction
}
val contentBackgroundColor =
lerp(
start = expandedColor,
stop = collapsedColor,
fraction = topAppBarScrollBehavior.state.collapsedFraction,
fraction = progressFraction,
)
drawRect(contentBackgroundColor)
},
@ -154,7 +165,7 @@ private fun AnimatedAccountSwitcher(
color = MaterialTheme.colorScheme.outlineVariant,
)
}
if (accountSummaries.size < MAXIMUM_ACCOUNT_LIMIT) {
if (accountSummaries.size < MAXIMUM_ACCOUNT_LIMIT && isAddAccountAvailable) {
item {
AddAccountItem(
onClick = onAddAccountClick,

View file

@ -0,0 +1,60 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A placeholder item to be used to represent an account.
*
* @param onClick An action to be invoked when the icon is clicked.
*/
@Composable
fun BitwardenPlaceholderAccountActionItem(
onClick: () -> Unit,
) {
IconButton(
onClick = onClick,
modifier = Modifier
.semantics(mergeDescendants = true) {},
) {
Icon(
painter = painterResource(id = R.drawable.ic_account_initials_container),
contentDescription = null,
tint = MaterialTheme.colorScheme.secondaryContainer,
)
Icon(
painter = painterResource(id = R.drawable.ic_dots),
contentDescription = stringResource(id = R.string.account),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@Preview
@Composable
private fun BitwardenPlaceholderAccountActionItem_preview_light() {
BitwardenTheme(darkTheme = false) {
BitwardenPlaceholderAccountActionItem(
onClick = {},
)
}
}
@Preview
@Composable
private fun BitwardenPlaceholderAccountActionItem_preview_dark() {
BitwardenTheme(darkTheme = true) {
BitwardenPlaceholderAccountActionItem(
onClick = {},
)
}
}

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.663,12.05C13.663,11.756 13.753,11.509 13.934,11.309C14.122,11.103 14.391,11 14.741,11C15.091,11 15.356,11.103 15.538,11.309C15.725,11.509 15.819,11.756 15.819,12.05C15.819,12.337 15.725,12.581 15.538,12.781C15.356,12.981 15.091,13.081 14.741,13.081C14.391,13.081 14.122,12.981 13.934,12.781C13.753,12.581 13.663,12.337 13.663,12.05Z"
android:fillColor="#151B2C"/>
<path
android:pathData="M8,12.05C8,11.756 8.091,11.509 8.272,11.309C8.459,11.103 8.728,11 9.078,11C9.428,11 9.694,11.103 9.875,11.309C10.063,11.509 10.156,11.756 10.156,12.05C10.156,12.337 10.063,12.581 9.875,12.781C9.694,12.981 9.428,13.081 9.078,13.081C8.728,13.081 8.459,12.981 8.272,12.781C8.091,12.581 8,12.337 8,12.05Z"
android:fillColor="#151B2C"/>
</vector>

View file

@ -1099,7 +1099,7 @@ class AuthRepositoryTest {
@Suppress("MaxLineLength")
@Test
fun `switchAccount when the given userId is the same as the current activeUserId should do nothing`() {
fun `switchAccount when the given userId is the same as the current activeUserId should only clear any special circumstances`() {
val originalUserId = USER_ID_1
val originalUserState = SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_STATE,
@ -1110,6 +1110,7 @@ class AuthRepositoryTest {
originalUserState,
repository.userStateFlow.value,
)
repository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition
assertEquals(
SwitchAccountResult.NoChange,
@ -1120,6 +1121,7 @@ class AuthRepositoryTest {
originalUserState,
repository.userStateFlow.value,
)
assertNull(repository.specialCircumstance)
verify(exactly = 0) { vaultRepository.lockVaultIfNecessary(originalUserId) }
verify(exactly = 0) { vaultRepository.clearUnlockedData() }
}
@ -1154,7 +1156,7 @@ class AuthRepositoryTest {
@Suppress("MaxLineLength")
@Test
fun `switchAccount when the userId is valid should update the current UserState, lock the vault of the previous active user, and clear the previously unlocked data`() {
fun `switchAccount when the userId is valid should update the current UserState, lock the vault of the previous active user, clear the previously unlocked data, and reset the special circumstance`() {
val originalUserId = USER_ID_1
val updatedUserId = USER_ID_2
val originalUserState = MULTI_USER_STATE.toUserState(
@ -1166,6 +1168,7 @@ class AuthRepositoryTest {
originalUserState,
repository.userStateFlow.value,
)
repository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition
assertEquals(
SwitchAccountResult.AccountSwitched,
@ -1176,6 +1179,7 @@ class AuthRepositoryTest {
originalUserState.copy(activeUserId = updatedUserId),
repository.userStateFlow.value,
)
assertNull(repository.specialCircumstance)
verify { vaultRepository.lockVaultIfNecessary(originalUserId) }
verify { vaultRepository.clearUnlockedData() }
}

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
@ -21,6 +22,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -63,6 +65,58 @@ class LandingScreenTest : BaseComposeTest() {
}
}
@Test
fun `account menu icon is present according to the state`() {
composeTestRule.onNodeWithContentDescription("Account").assertDoesNotExist()
mutableStateFlow.update {
it.copy(accountSummaries = listOf(ACTIVE_ACCOUNT_SUMMARY))
}
composeTestRule.onNodeWithContentDescription("Account").assertIsDisplayed()
}
@Test
fun `account menu icon click should show the account switcher`() {
mutableStateFlow.update {
it.copy(accountSummaries = listOf(ACTIVE_ACCOUNT_SUMMARY))
}
composeTestRule.onNodeWithContentDescription("Account").performClick()
composeTestRule.onNodeWithText("active@bitwarden.com").assertIsDisplayed()
}
@Suppress("MaxLineLength")
@Test
fun `account click in the account switcher should send SwitchAccountClick and close switcher`() {
// Show the account switcher
mutableStateFlow.update {
it.copy(accountSummaries = listOf(ACTIVE_ACCOUNT_SUMMARY))
}
composeTestRule.onNodeWithContentDescription("Account").performClick()
composeTestRule.onNodeWithText("active@bitwarden.com").assertIsDisplayed()
composeTestRule.onNodeWithText("active@bitwarden.com").performClick()
verify {
viewModel.trySendAction(LandingAction.SwitchAccountClick(ACTIVE_ACCOUNT_SUMMARY))
}
composeTestRule.onNodeWithText("active@bitwarden.com").assertDoesNotExist()
}
@Test
fun `add account button in the account switcher does not exist`() {
// Show the account switcher
mutableStateFlow.update {
it.copy(accountSummaries = listOf(ACTIVE_ACCOUNT_SUMMARY))
}
composeTestRule.onNodeWithContentDescription("Account").performClick()
composeTestRule.onNodeWithText("active@bitwarden.com").assertIsDisplayed()
composeTestRule.onNodeWithText("Add account").assertDoesNotExist()
}
@Test
fun `continue button should be enabled or disabled according to the state`() {
composeTestRule.onNodeWithText("Continue").assertIsEnabled()
@ -224,10 +278,19 @@ class LandingScreenTest : BaseComposeTest() {
}
}
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
userId = "activeUserId",
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
status = AccountSummary.Status.ACTIVE,
)
private val DEFAULT_STATE = LandingState(
emailInput = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
selectedEnvironmentType = Environment.Type.US,
errorDialogState = BasicDialogState.Hidden,
accountSummaries = emptyList(),
)

View file

@ -3,13 +3,16 @@ package com.x8bit.bitwarden.ui.auth.feature.landing
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -42,6 +45,30 @@ class LandingViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `initial state should set the account summaries based on the UserState`() {
val userState = UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
isPremium = true,
isVaultUnlocked = true,
),
),
)
val viewModel = createViewModel(userState = userState)
assertEquals(
DEFAULT_STATE.copy(
accountSummaries = userState.toAccountSummaries(),
),
viewModel.stateFlow.value,
)
}
@Test
fun `initial state should pull from saved state handle when present`() = runTest {
val expectedState = DEFAULT_STATE.copy(
@ -180,10 +207,12 @@ class LandingViewModelTest : BaseViewModelTest() {
private fun createViewModel(
rememberedEmail: String? = null,
userState: UserState? = null,
savedStateHandle: SavedStateHandle = SavedStateHandle(),
): LandingViewModel = LandingViewModel(
authRepository = mockk(relaxed = true) {
every { rememberedEmailAddress } returns rememberedEmail
every { userStateFlow } returns MutableStateFlow(userState)
},
environmentRepository = fakeEnvironmentRepository,
savedStateHandle = savedStateHandle,
@ -198,6 +227,7 @@ class LandingViewModelTest : BaseViewModelTest() {
isRememberMeEnabled = false,
selectedEnvironmentType = Environment.Type.US,
errorDialogState = BasicDialogState.Hidden,
accountSummaries = emptyList(),
)
}
}