mirror of
https://github.com/bitwarden/android.git
synced 2024-11-23 01:46:00 +03:00
BIT-1017: Add "Account already added" dialog to Landing Screen (#344)
This commit is contained in:
parent
5ffe9c914d
commit
a91de90fcd
4 changed files with 226 additions and 1 deletions
|
@ -60,6 +60,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -87,6 +88,32 @@ fun LandingScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val dialog = state.dialog) {
|
when (val dialog = state.dialog) {
|
||||||
|
is LandingState.DialogState.AccountAlreadyAdded -> {
|
||||||
|
BitwardenTwoButtonDialog(
|
||||||
|
title = stringResource(id = R.string.account_already_added),
|
||||||
|
message = stringResource(
|
||||||
|
id = R.string.switch_to_already_added_account_confirmation,
|
||||||
|
),
|
||||||
|
confirmButtonText = stringResource(id = R.string.yes),
|
||||||
|
dismissButtonText = stringResource(id = R.string.cancel),
|
||||||
|
onConfirmClick = remember(viewModel) {
|
||||||
|
{
|
||||||
|
viewModel.trySendAction(
|
||||||
|
LandingAction.ConfirmSwitchToMatchingAccountClick(
|
||||||
|
account = dialog.accountSummary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissClick = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(LandingAction.DialogDismiss) }
|
||||||
|
},
|
||||||
|
onDismissRequest = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(LandingAction.DialogDismiss) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
is LandingState.DialogState.Error -> {
|
is LandingState.DialogState.Error -> {
|
||||||
BitwardenBasicDialog(
|
BitwardenBasicDialog(
|
||||||
visibilityState = BasicDialogState.Shown(
|
visibilityState = BasicDialogState.Shown(
|
||||||
|
|
|
@ -42,6 +42,17 @@ class LandingViewModel @Inject constructor(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the [AccountSummary] from the current state that matches the current email input,
|
||||||
|
* of `null` if there is no match.
|
||||||
|
*/
|
||||||
|
private val matchingAccountSummary: AccountSummary?
|
||||||
|
get() {
|
||||||
|
val currentEmail = state.emailInput
|
||||||
|
val accountSummaries = state.accountSummaries
|
||||||
|
return accountSummaries.find { it.email == currentEmail }
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// As state updates:
|
// As state updates:
|
||||||
// - write to saved state handle
|
// - write to saved state handle
|
||||||
|
@ -65,6 +76,10 @@ class LandingViewModel @Inject constructor(
|
||||||
override fun handleAction(action: LandingAction) {
|
override fun handleAction(action: LandingAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is LandingAction.SwitchAccountClick -> handleSwitchAccountClicked(action)
|
is LandingAction.SwitchAccountClick -> handleSwitchAccountClicked(action)
|
||||||
|
is LandingAction.ConfirmSwitchToMatchingAccountClick -> {
|
||||||
|
handleConfirmSwitchToMatchingAccountClicked(action)
|
||||||
|
}
|
||||||
|
|
||||||
is LandingAction.ContinueButtonClick -> handleContinueButtonClicked()
|
is LandingAction.ContinueButtonClick -> handleContinueButtonClicked()
|
||||||
LandingAction.CreateAccountClick -> handleCreateAccountClicked()
|
LandingAction.CreateAccountClick -> handleCreateAccountClicked()
|
||||||
is LandingAction.DialogDismiss -> handleDialogDismiss()
|
is LandingAction.DialogDismiss -> handleDialogDismiss()
|
||||||
|
@ -81,6 +96,12 @@ class LandingViewModel @Inject constructor(
|
||||||
authRepository.switchAccount(userId = action.account.userId)
|
authRepository.switchAccount(userId = action.account.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleConfirmSwitchToMatchingAccountClicked(
|
||||||
|
action: LandingAction.ConfirmSwitchToMatchingAccountClick,
|
||||||
|
) {
|
||||||
|
authRepository.switchAccount(userId = action.account.userId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleEmailInputUpdated(action: LandingAction.EmailInputChanged) {
|
private fun handleEmailInputUpdated(action: LandingAction.EmailInputChanged) {
|
||||||
val email = action.input
|
val email = action.input
|
||||||
mutableStateFlow.update {
|
mutableStateFlow.update {
|
||||||
|
@ -103,6 +124,17 @@ class LandingViewModel @Inject constructor(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
matchingAccountSummary?.let { accountSummary ->
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialog = LandingState.DialogState.AccountAlreadyAdded(
|
||||||
|
accountSummary = accountSummary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val email = mutableStateFlow.value.emailInput
|
val email = mutableStateFlow.value.emailInput
|
||||||
val isRememberMeEnabled = mutableStateFlow.value.isRememberMeEnabled
|
val isRememberMeEnabled = mutableStateFlow.value.isRememberMeEnabled
|
||||||
|
|
||||||
|
@ -170,6 +202,15 @@ data class LandingState(
|
||||||
*/
|
*/
|
||||||
sealed class DialogState : Parcelable {
|
sealed class DialogState : Parcelable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a dialog indicating that the current email matches the existing
|
||||||
|
* [accountSummary].
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data class AccountAlreadyAdded(
|
||||||
|
val accountSummary: AccountSummary,
|
||||||
|
) : DialogState()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents an error dialog with the given [message].
|
* Represents an error dialog with the given [message].
|
||||||
*/
|
*/
|
||||||
|
@ -213,6 +254,13 @@ sealed class LandingAction {
|
||||||
val account: AccountSummary,
|
val account: AccountSummary,
|
||||||
) : LandingAction()
|
) : LandingAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates the user has confirmed they would like to switch to the existing [account].
|
||||||
|
*/
|
||||||
|
data class ConfirmSwitchToMatchingAccountClick(
|
||||||
|
val account: AccountSummary,
|
||||||
|
) : LandingAction()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates that the continue button has been clicked and the app should navigate to Login.
|
* Indicates that the continue button has been clicked and the app should navigate to Login.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -273,6 +273,80 @@ class LandingScreenTest : BaseComposeTest() {
|
||||||
.performClick()
|
.performClick()
|
||||||
verify { viewModel.trySendAction(LandingAction.DialogDismiss) }
|
verify { viewModel.trySendAction(LandingAction.DialogDismiss) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `account already added dialog should be shown or hidden according to the state`() {
|
||||||
|
composeTestRule.onNode(isDialog()).assertDoesNotExist()
|
||||||
|
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialog = LandingState.DialogState.AccountAlreadyAdded(
|
||||||
|
accountSummary = mockk(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNode(isDialog()).assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Account already added")
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Would you like to switch to it now?")
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Yes")
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Cancel")
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `account already added dialog Cancel click should send DialogDismiss action`() {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialog = LandingState.DialogState.AccountAlreadyAdded(
|
||||||
|
accountSummary = mockk(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Cancel")
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
verify { viewModel.trySendAction(LandingAction.DialogDismiss) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `account already added dialog Yes click should send ConfirmSwitchToMatchingAccountClick action`() {
|
||||||
|
val accountSummary = mockk<AccountSummary>()
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialog = LandingState.DialogState.AccountAlreadyAdded(
|
||||||
|
accountSummary = accountSummary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Yes")
|
||||||
|
.assert(hasAnyAncestor(isDialog()))
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
viewModel.trySendAction(
|
||||||
|
LandingAction.ConfirmSwitchToMatchingAccountClick(account = accountSummary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
|
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
|
||||||
|
|
|
@ -3,14 +3,18 @@ package com.x8bit.bitwarden.ui.auth.feature.landing
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
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.model.Environment
|
||||||
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
|
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
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.model.AccountSummary
|
||||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
|
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
|
||||||
|
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummary
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
@ -18,6 +22,7 @@ import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class LandingViewModelTest : BaseViewModelTest() {
|
class LandingViewModelTest : BaseViewModelTest() {
|
||||||
|
|
||||||
|
private val authRepository: AuthRepository = mockk(relaxed = true)
|
||||||
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
|
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -82,6 +87,32 @@ class LandingViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SwitchAccountClick should call switchAccount for the given account`() {
|
||||||
|
val matchingAccountUserId = "matchingAccountUserId"
|
||||||
|
val accountSummary = mockk<AccountSummary> {
|
||||||
|
every { userId } returns matchingAccountUserId
|
||||||
|
}
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
|
viewModel.trySendAction(LandingAction.SwitchAccountClick(accountSummary))
|
||||||
|
|
||||||
|
verify { authRepository.switchAccount(userId = matchingAccountUserId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ConfirmSwitchToMatchingAccountClick should call switchAccount for the given account`() {
|
||||||
|
val matchingAccountUserId = "matchingAccountUserId"
|
||||||
|
val accountSummary = mockk<AccountSummary> {
|
||||||
|
every { userId } returns matchingAccountUserId
|
||||||
|
}
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
|
viewModel.trySendAction(LandingAction.ConfirmSwitchToMatchingAccountClick(accountSummary))
|
||||||
|
|
||||||
|
verify { authRepository.switchAccount(userId = matchingAccountUserId) }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ContinueButtonClick with valid email should emit NavigateToLogin`() = runTest {
|
fun `ContinueButtonClick with valid email should emit NavigateToLogin`() = runTest {
|
||||||
val validEmail = "email@bitwarden.com"
|
val validEmail = "email@bitwarden.com"
|
||||||
|
@ -120,6 +151,51 @@ class LandingViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `ContinueButtonClick with an email input matching an existing account should show the account already added dialog`() {
|
||||||
|
val rememberedEmail = "active@bitwarden.com"
|
||||||
|
val activeAccount = UserState.Account(
|
||||||
|
userId = "activeUserId",
|
||||||
|
name = "name",
|
||||||
|
email = rememberedEmail,
|
||||||
|
avatarColorHex = "avatarColorHex",
|
||||||
|
isPremium = true,
|
||||||
|
isVaultUnlocked = true,
|
||||||
|
)
|
||||||
|
val userState = UserState(
|
||||||
|
activeUserId = "activeUserId",
|
||||||
|
accounts = listOf(activeAccount),
|
||||||
|
)
|
||||||
|
val viewModel = createViewModel(
|
||||||
|
rememberedEmail = rememberedEmail,
|
||||||
|
userState = userState,
|
||||||
|
)
|
||||||
|
val activeAccountSummary = activeAccount.toAccountSummary(isActive = true)
|
||||||
|
val accountSummaries = userState.toAccountSummaries()
|
||||||
|
val initialState = DEFAULT_STATE.copy(
|
||||||
|
emailInput = rememberedEmail,
|
||||||
|
isContinueButtonEnabled = true,
|
||||||
|
isRememberMeEnabled = true,
|
||||||
|
accountSummaries = accountSummaries,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
initialState,
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.trySendAction(LandingAction.ContinueButtonClick)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
initialState.copy(
|
||||||
|
dialog = LandingState.DialogState.AccountAlreadyAdded(
|
||||||
|
accountSummary = activeAccountSummary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `CreateAccountClick should emit NavigateToCreateAccount`() = runTest {
|
fun `CreateAccountClick should emit NavigateToCreateAccount`() = runTest {
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
|
@ -232,7 +308,7 @@ class LandingViewModelTest : BaseViewModelTest() {
|
||||||
initialState = mapOf("state" to initialState),
|
initialState = mapOf("state" to initialState),
|
||||||
),
|
),
|
||||||
): LandingViewModel = LandingViewModel(
|
): LandingViewModel = LandingViewModel(
|
||||||
authRepository = mockk(relaxed = true) {
|
authRepository = authRepository.apply {
|
||||||
every { rememberedEmailAddress } returns rememberedEmail
|
every { rememberedEmailAddress } returns rememberedEmail
|
||||||
every { userStateFlow } returns MutableStateFlow(userState)
|
every { userStateFlow } returns MutableStateFlow(userState)
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue