BIT-471 Show confirm log out dialog (#173)

This commit is contained in:
Andrew Haisting 2023-10-30 10:35:19 -05:00 committed by Álison Fernandes
parent 5510ba64f0
commit e2e2c60759
4 changed files with 236 additions and 6 deletions

View file

@ -19,6 +19,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
@ -31,6 +33,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
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.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
/**
* Displays the account security screen.
@ -41,11 +44,30 @@ fun AccountSecurityScreen(
onNavigateBack: () -> Unit,
viewModel: AccountSecurityViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsState()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
AccountSecurityEvent.NavigateBack -> onNavigateBack.invoke()
}
}
if (state.shouldShowConfirmLogoutDialog) {
BitwardenTwoButtonDialog(
title = R.string.log_out.asText(),
message = R.string.logout_confirmation.asText(),
confirmButtonText = R.string.yes.asText(),
onConfirmClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.ConfirmLogoutClick) }
},
dismissButtonText = R.string.cancel.asText(),
onDismissClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.DismissDialog) }
},
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.DismissDialog) }
},
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = Modifier

View file

@ -1,25 +1,70 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the account security screen.
*/
@HiltViewModel
class AccountSecurityViewModel @Inject constructor(
private val authRepository: AuthRepository,
) : BaseViewModel<Unit, AccountSecurityEvent, AccountSecurityAction>(
initialState = Unit,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<AccountSecurityState, AccountSecurityEvent, AccountSecurityAction>(
initialState = savedStateHandle[KEY_STATE]
?: AccountSecurityState(
shouldShowConfirmLogoutDialog = false,
),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: AccountSecurityAction): Unit = when (action) {
AccountSecurityAction.LogoutClick -> authRepository.logout()
AccountSecurityAction.BackClick -> sendEvent(AccountSecurityEvent.NavigateBack)
AccountSecurityAction.LogoutClick -> handleLogoutClick()
AccountSecurityAction.BackClick -> handleBackClick()
AccountSecurityAction.ConfirmLogoutClick -> handleConfirmLogoutClick()
AccountSecurityAction.DismissDialog -> handleDismissDialog()
}
private fun handleLogoutClick() {
mutableStateFlow.update { it.copy(shouldShowConfirmLogoutDialog = true) }
}
private fun handleBackClick() = sendEvent(AccountSecurityEvent.NavigateBack)
private fun handleConfirmLogoutClick() {
mutableStateFlow.update { it.copy(shouldShowConfirmLogoutDialog = false) }
authRepository.logout()
}
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(shouldShowConfirmLogoutDialog = false) }
}
}
/**
* Models state for the Account Security screen.
*/
@Parcelize
data class AccountSecurityState(
val shouldShowConfirmLogoutDialog: Boolean,
) : Parcelable
/**
* Models events for the account security screen.
*/
@ -39,6 +84,16 @@ sealed class AccountSecurityAction {
*/
data object BackClick : AccountSecurityAction()
/**
* User confirmed they want to logout.
*/
data object ConfirmLogoutClick : AccountSecurityAction()
/**
* User dismissed the confirm logout dialog.
*/
data object DismissDialog : AccountSecurityAction()
/**
* User clicked log out.
*/

View file

@ -1,14 +1,22 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.AccountSecurityAction.ConfirmLogoutClick
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.AccountSecurityAction.DismissDialog
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Test
@ -17,6 +25,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
@Test
fun `on Log out click should send LogoutClick`() {
val viewModel: AccountSecurityViewModel = mockk {
every { stateFlow } returns MutableStateFlow(DEFAULT_STATE)
every { eventFlow } returns emptyFlow()
every { trySendAction(AccountSecurityAction.LogoutClick) } returns Unit
}
@ -33,6 +42,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
@Test
fun `on back click should send BackClick`() {
val viewModel: AccountSecurityViewModel = mockk {
every { stateFlow } returns MutableStateFlow(DEFAULT_STATE)
every { eventFlow } returns emptyFlow()
every { trySendAction(AccountSecurityAction.BackClick) } returns Unit
}
@ -50,6 +60,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
fun `on NavigateAccountSecurity should call onNavigateToAccountSecurity`() {
var haveCalledNavigateBack = false
val viewModel = mockk<AccountSecurityViewModel> {
every { stateFlow } returns MutableStateFlow(DEFAULT_STATE)
every { eventFlow } returns flowOf(AccountSecurityEvent.NavigateBack)
}
composeTestRule.setContent {
@ -60,4 +71,86 @@ class AccountSecurityScreenTest : BaseComposeTest() {
}
assertTrue(haveCalledNavigateBack)
}
@Test
fun `confirm dialog be shown or hidden according to the state`() {
val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
val viewModel = mockk<AccountSecurityViewModel> {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns emptyFlow()
every { trySendAction(ConfirmLogoutClick) } returns Unit
}
composeTestRule.setContent {
AccountSecurityScreen(
viewModel = viewModel,
onNavigateBack = { },
)
}
composeTestRule.onNode(isDialog()).assertDoesNotExist()
mutableStateFlow.update { it.copy(shouldShowConfirmLogoutDialog = true) }
composeTestRule
.onNodeWithText("Yes")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Cancel")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Are you sure you want to log out?")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `on confirm logout click should send ConfirmLogoutClick`() {
val viewModel = mockk<AccountSecurityViewModel> {
every { stateFlow } returns MutableStateFlow(
DEFAULT_STATE.copy(shouldShowConfirmLogoutDialog = true),
)
every { eventFlow } returns emptyFlow()
every { trySendAction(ConfirmLogoutClick) } returns Unit
}
composeTestRule.setContent {
AccountSecurityScreen(
viewModel = viewModel,
onNavigateBack = { },
)
}
composeTestRule
.onNodeWithText("Yes")
.assert(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ConfirmLogoutClick) }
}
@Test
fun `on cancel click should send DismissDialog`() {
val viewModel = mockk<AccountSecurityViewModel> {
every { stateFlow } returns MutableStateFlow(
DEFAULT_STATE.copy(shouldShowConfirmLogoutDialog = true),
)
every { eventFlow } returns emptyFlow()
every { trySendAction(DismissDialog) } returns Unit
}
composeTestRule.setContent {
AccountSecurityScreen(
viewModel = viewModel,
onNavigateBack = { },
)
}
composeTestRule
.onNodeWithText("Cancel")
.assert(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(DismissDialog) }
}
companion object {
private val DEFAULT_STATE = AccountSecurityState(
shouldShowConfirmLogoutDialog = false,
)
}
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
@ -12,9 +13,19 @@ import org.junit.jupiter.api.Test
class AccountSecurityViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct`() {
val viewModel = AccountSecurityViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockk(),
)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `on BackClick should emit NavigateBack`() = runTest {
val viewModel = AccountSecurityViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockk(),
)
viewModel.eventFlow.test {
@ -24,14 +35,63 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
}
@Test
fun `on LogoutClick should call logout`() = runTest {
fun `on LogoutClick should show confirm log out dialog`() = runTest {
val viewModel = AccountSecurityViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockk(),
)
viewModel.trySendAction(AccountSecurityAction.LogoutClick)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
shouldShowConfirmLogoutDialog = true,
),
awaitItem(),
)
}
}
@Test
fun `on ConfirmLogoutClick should call logout and hide confirm dialog`() = runTest {
val authRepository: AuthRepository = mockk {
every { logout() } returns Unit
}
val viewModel = AccountSecurityViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = authRepository,
)
viewModel.trySendAction(AccountSecurityAction.LogoutClick)
viewModel.trySendAction(AccountSecurityAction.ConfirmLogoutClick)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
shouldShowConfirmLogoutDialog = false,
),
awaitItem(),
)
}
verify { authRepository.logout() }
}
@Test
fun `on DismissDialog should hide dialog`() = runTest {
val viewModel = AccountSecurityViewModel(
savedStateHandle = SavedStateHandle(),
authRepository = mockk(),
)
viewModel.trySendAction(AccountSecurityAction.DismissDialog)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
shouldShowConfirmLogoutDialog = false,
),
awaitItem(),
)
}
}
companion object {
private val DEFAULT_STATE = AccountSecurityState(
shouldShowConfirmLogoutDialog = false,
)
}
}