BIT-732: Add account swither to login screen (#333)

This commit is contained in:
Brian Yencho 2023-12-06 11:37:29 -06:00 committed by Álison Fernandes
parent 2e2fede945
commit b0e3aca323
4 changed files with 139 additions and 2 deletions

View file

@ -18,7 +18,10 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -36,16 +39,19 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButtonWithIcon import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButtonWithIcon
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenPlaceholderAccountActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/** /**
* The top level composable for the Login screen. * The top level composable for the Login screen.
@ -73,6 +79,8 @@ fun LoginScreen(
} }
} }
val isAccountButtonVisible = state.accountSummaries.isNotEmpty()
var isAccountMenuVisible by rememberSaveable { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold( BitwardenScaffold(
modifier = Modifier modifier = Modifier
@ -88,6 +96,11 @@ fun LoginScreen(
{ viewModel.trySendAction(LoginAction.CloseButtonClick) } { viewModel.trySendAction(LoginAction.CloseButtonClick) }
}, },
actions = { actions = {
if (isAccountButtonVisible) {
BitwardenPlaceholderAccountActionItem(
onClick = { isAccountMenuVisible = !isAccountMenuVisible },
)
}
BitwardenOverflowActionItem( BitwardenOverflowActionItem(
menuItemDataList = persistentListOf( menuItemDataList = persistentListOf(
OverflowMenuItemData( OverflowMenuItemData(
@ -126,6 +139,23 @@ fun LoginScreen(
.padding(innerPadding) .padding(innerPadding)
.fillMaxSize(), .fillMaxSize(),
) )
BitwardenAccountSwitcher(
isVisible = isAccountMenuVisible,
accountSummaries = state.accountSummaries.toImmutableList(),
onAccountSummaryClick = remember(viewModel) {
{ viewModel.trySendAction(LoginAction.SwitchAccountClick(it)) }
},
onAddAccountClick = {
// Not available
},
onDismissRequest = { isAccountMenuVisible = false },
isAddAccountAvailable = false,
topAppBarScrollBehavior = scrollBehavior,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
} }
} }

View file

@ -17,7 +17,9 @@ import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import com.x8bit.bitwarden.ui.platform.util.labelOrBaseUrlHost import com.x8bit.bitwarden.ui.platform.util.labelOrBaseUrlHost
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -46,6 +48,7 @@ class LoginViewModel @Inject constructor(
loadingDialogState = LoadingDialogState.Hidden, loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden, errorDialogState = BasicDialogState.Hidden,
captchaToken = LoginArgs(savedStateHandle).captchaToken, captchaToken = LoginArgs(savedStateHandle).captchaToken,
accountSummaries = authRepository.userStateFlow.value?.toAccountSummaries().orEmpty(),
), ),
) { ) {
@ -67,6 +70,7 @@ class LoginViewModel @Inject constructor(
override fun handleAction(action: LoginAction) { override fun handleAction(action: LoginAction) {
when (action) { when (action) {
is LoginAction.SwitchAccountClick -> handleSwitchAccountClicked(action)
is LoginAction.CloseButtonClick -> handleCloseButtonClicked() is LoginAction.CloseButtonClick -> handleCloseButtonClicked()
LoginAction.LoginButtonClick -> handleLoginButtonClicked() LoginAction.LoginButtonClick -> handleLoginButtonClicked()
LoginAction.MasterPasswordHintClick -> handleMasterPasswordHintClicked() LoginAction.MasterPasswordHintClick -> handleMasterPasswordHintClicked()
@ -84,6 +88,10 @@ class LoginViewModel @Inject constructor(
} }
} }
private fun handleSwitchAccountClicked(action: LoginAction.SwitchAccountClick) {
authRepository.switchAccount(userId = action.account.userId)
}
private fun handleReceiveLoginResult(action: LoginAction.Internal.ReceiveLoginResult) { private fun handleReceiveLoginResult(action: LoginAction.Internal.ReceiveLoginResult) {
when (val loginResult = action.loginResult) { when (val loginResult = action.loginResult) {
is LoginResult.CaptchaRequired -> { is LoginResult.CaptchaRequired -> {
@ -201,6 +209,7 @@ data class LoginState(
val isLoginButtonEnabled: Boolean, val isLoginButtonEnabled: Boolean,
val loadingDialogState: LoadingDialogState, val loadingDialogState: LoadingDialogState,
val errorDialogState: BasicDialogState, val errorDialogState: BasicDialogState,
val accountSummaries: List<AccountSummary>,
) : Parcelable ) : Parcelable
/** /**
@ -227,6 +236,13 @@ sealed class LoginEvent {
* Models actions for the login screen. * Models actions for the login screen.
*/ */
sealed class LoginAction { sealed class LoginAction {
/**
* Indicates the user has clicked on the given [account] information in order to switch to it.
*/
data class SwitchAccountClick(
val account: AccountSummary,
) : LoginAction()
/** /**
* Indicates that the top-bar close button was clicked. * Indicates that the top-bar close button was clicked.
*/ */

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.auth.feature.login
import android.net.Uri import android.net.Uri
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filter import androidx.compose.ui.test.filter
import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
@ -17,12 +18,14 @@ import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import junit.framework.TestCase.assertTrue import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -51,6 +54,58 @@ class LoginScreenTest : 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(LoginAction.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 @Test
fun `close button click should send CloseButtonClick action`() { fun `close button click should send CloseButtonClick action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick() composeTestRule.onNodeWithContentDescription("Close").performClick()
@ -117,6 +172,14 @@ class LoginScreenTest : 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 = private val DEFAULT_STATE =
LoginState( LoginState(
emailAddress = "", emailAddress = "",
@ -126,4 +189,5 @@ private val DEFAULT_STATE =
environmentLabel = "".asText(), environmentLabel = "".asText(),
loadingDialogState = LoadingDialogState.Hidden, loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden, errorDialogState = BasicDialogState.Hidden,
accountSummaries = emptyList(),
) )

View file

@ -16,6 +16,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
@ -38,10 +39,10 @@ class LoginViewModelTest : BaseViewModelTest() {
private val mutableCaptchaTokenResultFlow = MutableSharedFlow<CaptchaCallbackTokenResult>( private val mutableCaptchaTokenResultFlow = MutableSharedFlow<CaptchaCallbackTokenResult>(
extraBufferCapacity = Int.MAX_VALUE, extraBufferCapacity = Int.MAX_VALUE,
) )
private val mutableStateFlow = MutableStateFlow<UserState?>(null) private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val authRepository: AuthRepository = mockk(relaxed = true) { private val authRepository: AuthRepository = mockk(relaxed = true) {
every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow
every { userStateFlow } returns mutableStateFlow every { userStateFlow } returns mutableUserStateFlow
} }
private val fakeEnvironmentRepository = FakeEnvironmentRepository() private val fakeEnvironmentRepository = FakeEnvironmentRepository()
@ -100,6 +101,31 @@ class LoginViewModelTest : 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,
),
),
)
mutableUserStateFlow.value = userState
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(
accountSummaries = userState.toAccountSummaries(),
),
viewModel.stateFlow.value,
)
}
@Test @Test
fun `initial state should pull from handle when present`() = runTest { fun `initial state should pull from handle when present`() = runTest {
val expectedState = DEFAULT_STATE.copy( val expectedState = DEFAULT_STATE.copy(
@ -301,6 +327,7 @@ class LoginViewModelTest : BaseViewModelTest() {
loadingDialogState = LoadingDialogState.Hidden, loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden, errorDialogState = BasicDialogState.Hidden,
captchaToken = null, captchaToken = null,
accountSummaries = emptyList(),
) )
private const val LOGIN_RESULT_PATH = private const val LOGIN_RESULT_PATH =