mirror of
https://github.com/bitwarden/android.git
synced 2025-02-16 20:09:59 +03:00
BIT-732: Add account swither to login screen (#333)
This commit is contained in:
parent
2e2fede945
commit
b0e3aca323
4 changed files with 139 additions and 2 deletions
|
@ -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(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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 =
|
||||||
|
|
Loading…
Add table
Reference in a new issue