From b0e3aca323164d8b555992140d8f777f0f4e2ff5 Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Wed, 6 Dec 2023 11:37:29 -0600 Subject: [PATCH] BIT-732: Add account swither to login screen (#333) --- .../ui/auth/feature/login/LoginScreen.kt | 30 +++++++++ .../ui/auth/feature/login/LoginViewModel.kt | 16 +++++ .../ui/auth/feature/login/LoginScreenTest.kt | 64 +++++++++++++++++++ .../auth/feature/login/LoginViewModelTest.kt | 31 ++++++++- 4 files changed, 139 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt index d45eb520e..41699aaff 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt @@ -18,7 +18,10 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState 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 import androidx.compose.ui.Modifier @@ -36,16 +39,19 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect 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.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButtonWithIcon import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem 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.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList /** * 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()) BitwardenScaffold( modifier = Modifier @@ -88,6 +96,11 @@ fun LoginScreen( { viewModel.trySendAction(LoginAction.CloseButtonClick) } }, actions = { + if (isAccountButtonVisible) { + BitwardenPlaceholderAccountActionItem( + onClick = { isAccountMenuVisible = !isAccountMenuVisible }, + ) + } BitwardenOverflowActionItem( menuItemDataList = persistentListOf( OverflowMenuItemData( @@ -126,6 +139,23 @@ fun LoginScreen( .padding(innerPadding) .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(), + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index b40a0de10..00f9ce63e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -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.components.BasicDialogState 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.vault.feature.vault.util.toAccountSummaries import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -46,6 +48,7 @@ class LoginViewModel @Inject constructor( loadingDialogState = LoadingDialogState.Hidden, errorDialogState = BasicDialogState.Hidden, captchaToken = LoginArgs(savedStateHandle).captchaToken, + accountSummaries = authRepository.userStateFlow.value?.toAccountSummaries().orEmpty(), ), ) { @@ -67,6 +70,7 @@ class LoginViewModel @Inject constructor( override fun handleAction(action: LoginAction) { when (action) { + is LoginAction.SwitchAccountClick -> handleSwitchAccountClicked(action) is LoginAction.CloseButtonClick -> handleCloseButtonClicked() LoginAction.LoginButtonClick -> handleLoginButtonClicked() 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) { when (val loginResult = action.loginResult) { is LoginResult.CaptchaRequired -> { @@ -201,6 +209,7 @@ data class LoginState( val isLoginButtonEnabled: Boolean, val loadingDialogState: LoadingDialogState, val errorDialogState: BasicDialogState, + val accountSummaries: List, ) : Parcelable /** @@ -227,6 +236,13 @@ sealed class LoginEvent { * Models actions for the login screen. */ 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. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt index 2ede57645..d19aede8d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.auth.feature.login import android.net.Uri import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.filter import androidx.compose.ui.test.filterToOne 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.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState +import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import io.mockk.every import io.mockk.mockk import io.mockk.verify import junit.framework.TestCase.assertTrue import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.junit.Before 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 fun `close button click should send CloseButtonClick action`() { 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 = LoginState( emailAddress = "", @@ -126,4 +189,5 @@ private val DEFAULT_STATE = environmentLabel = "".asText(), loadingDialogState = LoadingDialogState.Hidden, errorDialogState = BasicDialogState.Hidden, + accountSummaries = emptyList(), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt index 2d981f47a..93276e9b0 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt @@ -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.components.BasicDialogState 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.coVerify import io.mockk.every @@ -38,10 +39,10 @@ class LoginViewModelTest : BaseViewModelTest() { private val mutableCaptchaTokenResultFlow = MutableSharedFlow( extraBufferCapacity = Int.MAX_VALUE, ) - private val mutableStateFlow = MutableStateFlow(null) + private val mutableUserStateFlow = MutableStateFlow(null) private val authRepository: AuthRepository = mockk(relaxed = true) { every { captchaTokenResultFlow } returns mutableCaptchaTokenResultFlow - every { userStateFlow } returns mutableStateFlow + every { userStateFlow } returns mutableUserStateFlow } 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 fun `initial state should pull from handle when present`() = runTest { val expectedState = DEFAULT_STATE.copy( @@ -301,6 +327,7 @@ class LoginViewModelTest : BaseViewModelTest() { loadingDialogState = LoadingDialogState.Hidden, errorDialogState = BasicDialogState.Hidden, captchaToken = null, + accountSummaries = emptyList(), ) private const val LOGIN_RESULT_PATH =