From 6fb630ba29409ae1a34a35536bc269c42f3a3c06 Mon Sep 17 00:00:00 2001 From: Andrew Haisting <142518658+ahaisting-livefront@users.noreply.github.com> Date: Mon, 30 Oct 2023 10:35:19 -0500 Subject: [PATCH] BIT-471 Show confirm log out dialog (#173) --- .../accountsecurity/AccountSecurityScreen.kt | 22 +++++ .../AccountSecurityViewModel.kt | 63 ++++++++++++- .../AccountSecurityScreenTest.kt | 93 +++++++++++++++++++ .../AccountSecurityViewModelTest.kt | 64 ++++++++++++- 4 files changed, 236 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt index bdcdd1046c..10c1f96f28 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt index c40602524e..b827f6d865 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt @@ -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( - initialState = Unit, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + 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. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt index 71e4aa6d00..141bc90645 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt @@ -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 { + 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 { + 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 { + 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 { + 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, + ) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt index 3f1aed59b5..019a283983 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt @@ -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, + ) + } }