mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
BIT-2234: Delete Account Confirmation Screen (Navigation) (#1278)
This commit is contained in:
parent
4ac9d05036
commit
9648f720be
11 changed files with 702 additions and 18 deletions
|
@ -12,11 +12,15 @@ private const val DELETE_ACCOUNT_ROUTE = "delete_account"
|
|||
*/
|
||||
fun NavGraphBuilder.deleteAccountDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToDeleteAccountConfirmation: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions(
|
||||
route = DELETE_ACCOUNT_ROUTE,
|
||||
) {
|
||||
DeleteAccountScreen(onNavigateBack = onNavigateBack)
|
||||
DeleteAccountScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToDeleteAccountConfirmation = onNavigateToDeleteAccountConfirmation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -54,6 +54,7 @@ import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
|||
fun DeleteAccountScreen(
|
||||
viewModel: DeleteAccountViewModel = hiltViewModel(),
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToDeleteAccountConfirmation: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
@ -65,6 +66,10 @@ fun DeleteAccountScreen(
|
|||
is DeleteAccountEvent.ShowToast -> {
|
||||
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen -> {
|
||||
onNavigateToDeleteAccountConfirmation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -149,9 +154,17 @@ fun DeleteAccountScreen(
|
|||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
DeleteAccountButton(
|
||||
onConfirmationClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(it)) }
|
||||
onDeleteAccountConfirmDialogClick = remember(viewModel) {
|
||||
{
|
||||
viewModel.trySendAction(
|
||||
DeleteAccountAction.DeleteAccountConfirmDialogClick(it),
|
||||
)
|
||||
}
|
||||
},
|
||||
onDeleteAccountClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick) }
|
||||
},
|
||||
isUnlockWithPasswordEnabled = state.isUnlockWithPasswordEnabled,
|
||||
modifier = Modifier
|
||||
.semantics { testTag = "DELETE ACCOUNT" }
|
||||
.fillMaxWidth()
|
||||
|
@ -175,7 +188,9 @@ fun DeleteAccountScreen(
|
|||
|
||||
@Composable
|
||||
private fun DeleteAccountButton(
|
||||
onConfirmationClick: (masterPassword: String) -> Unit,
|
||||
onDeleteAccountConfirmDialogClick: (masterPassword: String) -> Unit,
|
||||
onDeleteAccountClick: () -> Unit,
|
||||
isUnlockWithPasswordEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||
|
@ -183,7 +198,7 @@ private fun DeleteAccountButton(
|
|||
BitwardenMasterPasswordDialog(
|
||||
onConfirmClick = {
|
||||
showPasswordDialog = false
|
||||
onConfirmationClick(it)
|
||||
onDeleteAccountConfirmDialogClick(it)
|
||||
},
|
||||
onDismissRequest = { showPasswordDialog = false },
|
||||
)
|
||||
|
@ -191,7 +206,13 @@ private fun DeleteAccountButton(
|
|||
|
||||
BitwardenErrorButton(
|
||||
label = stringResource(id = R.string.delete_account),
|
||||
onClick = { showPasswordDialog = true },
|
||||
onClick = {
|
||||
if (isUnlockWithPasswordEnabled) {
|
||||
showPasswordDialog = true
|
||||
} else {
|
||||
onDeleteAccountClick()
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ import javax.inject.Inject
|
|||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* View model for the account security screen.
|
||||
* View model for the [DeleteAccountScreen].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class DeleteAccountViewModel @Inject constructor(
|
||||
|
@ -29,6 +29,10 @@ class DeleteAccountViewModel @Inject constructor(
|
|||
) : BaseViewModel<DeleteAccountState, DeleteAccountEvent, DeleteAccountAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountState(
|
||||
dialog = null,
|
||||
isUnlockWithPasswordEnabled = requireNotNull(authRepository.userStateFlow.value)
|
||||
.activeAccount
|
||||
.trustedDevice
|
||||
?.hasMasterPassword != false,
|
||||
),
|
||||
) {
|
||||
|
||||
|
@ -42,15 +46,23 @@ class DeleteAccountViewModel @Inject constructor(
|
|||
when (action) {
|
||||
DeleteAccountAction.CancelClick -> handleCancelClick()
|
||||
DeleteAccountAction.CloseClick -> handleCloseClick()
|
||||
is DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick(action)
|
||||
is DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick()
|
||||
DeleteAccountAction.AccountDeletionConfirm -> handleAccountDeletionConfirm()
|
||||
DeleteAccountAction.DismissDialog -> handleDismissDialog()
|
||||
is DeleteAccountAction.Internal.DeleteAccountComplete -> {
|
||||
handleDeleteAccountComplete(action)
|
||||
}
|
||||
|
||||
is DeleteAccountAction.DeleteAccountConfirmDialogClick -> {
|
||||
handleDeleteAccountConfirmDialogClick(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeleteAccountClick() {
|
||||
sendEvent(DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen)
|
||||
}
|
||||
|
||||
private fun handleCancelClick() {
|
||||
sendEvent(DeleteAccountEvent.NavigateBack)
|
||||
}
|
||||
|
@ -59,7 +71,9 @@ class DeleteAccountViewModel @Inject constructor(
|
|||
sendEvent(DeleteAccountEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleDeleteAccountClick(action: DeleteAccountAction.DeleteAccountClick) {
|
||||
private fun handleDeleteAccountConfirmDialogClick(
|
||||
action: DeleteAccountAction.DeleteAccountConfirmDialogClick,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Loading)
|
||||
}
|
||||
|
@ -103,10 +117,15 @@ class DeleteAccountViewModel @Inject constructor(
|
|||
|
||||
/**
|
||||
* Models state for the Delete Account screen.
|
||||
*
|
||||
* @param dialog The dialog for the [DeleteAccountScreen].
|
||||
* @param isUnlockWithPasswordEnabled Whether or not the user is able to unlock the vault with
|
||||
* their master password.
|
||||
*/
|
||||
@Parcelize
|
||||
data class DeleteAccountState(
|
||||
val dialog: DeleteAccountDialog?,
|
||||
val isUnlockWithPasswordEnabled: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
|
@ -144,6 +163,11 @@ sealed class DeleteAccountEvent {
|
|||
*/
|
||||
data object NavigateBack : DeleteAccountEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the [DeleteAccountConfirmationScreen].
|
||||
*/
|
||||
data object NavigateToDeleteAccountConfirmationScreen : DeleteAccountEvent()
|
||||
|
||||
/**
|
||||
* Displays the [message] in a toast.
|
||||
*/
|
||||
|
@ -169,7 +193,14 @@ sealed class DeleteAccountAction {
|
|||
/**
|
||||
* The user has clicked the delete account button.
|
||||
*/
|
||||
data class DeleteAccountClick(
|
||||
data object DeleteAccountClick : DeleteAccountAction()
|
||||
|
||||
/**
|
||||
* The user has clicked the delete account confirmation dialog button.
|
||||
*
|
||||
* @param masterPassword The master password of the user.
|
||||
*/
|
||||
data class DeleteAccountConfirmDialogClick(
|
||||
val masterPassword: String,
|
||||
) : DeleteAccountAction()
|
||||
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
|
||||
private const val DELETE_ACCOUNT_CONFIRMATION_ROUTE = "delete_account_confirmation"
|
||||
|
||||
/**
|
||||
* Add delete account confirmation destinations to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.deleteAccountConfirmationDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions(
|
||||
route = DELETE_ACCOUNT_CONFIRMATION_ROUTE,
|
||||
) {
|
||||
DeleteAccountConfirmationScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the [DeleteAccountConfirmationScreen].
|
||||
*/
|
||||
fun NavController.navigateToDeleteAccountConfirmation(navOptions: NavOptions? = null) {
|
||||
navigate(DELETE_ACCOUNT_CONFIRMATION_ROUTE, navOptions)
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
|
||||
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
|
||||
/**
|
||||
* Displays the delete account confirmation screen.
|
||||
*/
|
||||
@Composable
|
||||
fun DeleteAccountConfirmationScreen(
|
||||
viewModel: DeleteAccountConfirmationViewModel = hiltViewModel(),
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val resources = context.resources
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
DeleteAccountConfirmationEvent.NavigateBack -> onNavigateBack()
|
||||
|
||||
is DeleteAccountConfirmationEvent.ShowToast -> {
|
||||
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DeleteAccountConfirmationDialogs(
|
||||
dialogState = state.dialog,
|
||||
onDeleteAccountAcknowledge = remember(viewModel) {
|
||||
{ viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountAcknowledge) }
|
||||
},
|
||||
onDismissDialog = remember(viewModel) {
|
||||
{ viewModel.trySendAction(DeleteAccountConfirmationAction.DismissDialog) }
|
||||
},
|
||||
)
|
||||
|
||||
DeleteAccountConfirmationScaffold(
|
||||
state = state,
|
||||
onCloseClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(DeleteAccountConfirmationAction.CloseClick) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteAccountConfirmationDialogs(
|
||||
dialogState: DeleteAccountConfirmationState.DeleteAccountConfirmationDialog?,
|
||||
onDismissDialog: () -> Unit,
|
||||
onDeleteAccountAcknowledge: () -> Unit,
|
||||
) {
|
||||
when (dialogState) {
|
||||
is DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess -> {
|
||||
BitwardenBasicDialog(
|
||||
visibilityState = BasicDialogState.Shown(
|
||||
title = null,
|
||||
message = dialogState.message,
|
||||
),
|
||||
onDismissRequest = onDeleteAccountAcknowledge,
|
||||
)
|
||||
}
|
||||
|
||||
is DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error -> {
|
||||
BitwardenBasicDialog(
|
||||
visibilityState = BasicDialogState.Shown(
|
||||
title = dialogState.title,
|
||||
message = dialogState.message,
|
||||
),
|
||||
onDismissRequest = onDismissDialog,
|
||||
)
|
||||
}
|
||||
|
||||
is DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading -> {
|
||||
BitwardenLoadingDialog(
|
||||
visibilityState = LoadingDialogState.Shown(dialogState.title),
|
||||
)
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DeleteAccountConfirmationScaffold(
|
||||
state: DeleteAccountConfirmationState,
|
||||
onCloseClick: () -> Unit,
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = R.string.verification_code),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = painterResource(id = R.drawable.ic_close),
|
||||
navigationIconContentDescription = stringResource(id = R.string.close),
|
||||
onNavigationIconClick = onCloseClick,
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding)) {
|
||||
// TODO finish UI in BIT-2234
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,149 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
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 [DeleteAccountConfirmationScreen].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class DeleteAccountConfirmationViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<
|
||||
DeleteAccountConfirmationState,
|
||||
DeleteAccountConfirmationEvent,
|
||||
DeleteAccountConfirmationAction,>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountConfirmationState(
|
||||
dialog = null,
|
||||
),
|
||||
) {
|
||||
|
||||
init {
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: DeleteAccountConfirmationAction) {
|
||||
when (action) {
|
||||
DeleteAccountConfirmationAction.CloseClick -> handleCloseClick()
|
||||
DeleteAccountConfirmationAction.DeleteAccountAcknowledge -> {
|
||||
handleDeleteAccountAcknowledge()
|
||||
}
|
||||
|
||||
DeleteAccountConfirmationAction.DismissDialog -> handleDismissDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCloseClick() {
|
||||
sendEvent(DeleteAccountConfirmationEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleDeleteAccountAcknowledge() {
|
||||
authRepository.clearPendingAccountDeletion()
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
private fun handleDismissDialog() {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models state for the [DeleteAccountConfirmationScreen].
|
||||
*/
|
||||
@Parcelize
|
||||
data class DeleteAccountConfirmationState(
|
||||
val dialog: DeleteAccountConfirmationDialog?,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Displays a dialog.
|
||||
*/
|
||||
sealed class DeleteAccountConfirmationDialog : Parcelable {
|
||||
/**
|
||||
* Dialog to confirm to the user that the account has been deleted.
|
||||
*
|
||||
* @param message The message for the dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
data class DeleteSuccess(
|
||||
val message: Text =
|
||||
R.string.your_account_has_been_permanently_deleted.asText(),
|
||||
) : DeleteAccountConfirmationDialog()
|
||||
|
||||
/**
|
||||
* Displays the error dialog when deleting an account fails.
|
||||
*
|
||||
* @param title The title for the dialog.
|
||||
* @param message The message for the dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text = R.string.an_error_has_occurred.asText(),
|
||||
val message: Text,
|
||||
) : DeleteAccountConfirmationDialog()
|
||||
|
||||
/**
|
||||
* Displays the loading dialog when deleting an account.
|
||||
*
|
||||
* @param title The title for the dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Loading(
|
||||
val title: Text = R.string.loading.asText(),
|
||||
) : DeleteAccountConfirmationDialog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models events for the [DeleteAccountConfirmationScreen].
|
||||
*/
|
||||
sealed class DeleteAccountConfirmationEvent {
|
||||
/**
|
||||
* Navigates back.
|
||||
*/
|
||||
data object NavigateBack : DeleteAccountConfirmationEvent()
|
||||
|
||||
/**
|
||||
* Displays the [message] in a toast.
|
||||
*/
|
||||
data class ShowToast(
|
||||
val message: Text,
|
||||
) : DeleteAccountConfirmationEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Models actions for the [DeleteAccountConfirmationScreen].
|
||||
*/
|
||||
sealed class DeleteAccountConfirmationAction {
|
||||
/**
|
||||
* The user has clicked the close button.
|
||||
*/
|
||||
data object CloseClick : DeleteAccountConfirmationAction()
|
||||
|
||||
/**
|
||||
* The user has dismissed the dialog.
|
||||
*/
|
||||
data object DismissDialog : DeleteAccountConfirmationAction()
|
||||
|
||||
/**
|
||||
* The user has acknowledged the account deletion.
|
||||
*/
|
||||
data object DeleteAccountAcknowledge : DeleteAccountConfirmationAction()
|
||||
}
|
|
@ -8,6 +8,8 @@ import com.x8bit.bitwarden.ui.platform.feature.search.navigateToSearch
|
|||
import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.navigateToDeleteAccount
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation.deleteAccountConfirmationDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation.navigateToDeleteAccountConfirmation
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.loginApprovalDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.navigateToPendingRequests
|
||||
|
@ -97,7 +99,15 @@ fun NavGraphBuilder.vaultUnlockedGraph(
|
|||
)
|
||||
},
|
||||
)
|
||||
deleteAccountDestination(onNavigateBack = { navController.popBackStack() })
|
||||
deleteAccountDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToDeleteAccountConfirmation = {
|
||||
navController.navigateToDeleteAccountConfirmation()
|
||||
},
|
||||
)
|
||||
deleteAccountConfirmationDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
loginApprovalDestination(onNavigateBack = { navController.popBackStack() })
|
||||
pendingRequestsDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
|
|
|
@ -26,6 +26,7 @@ import org.junit.Test
|
|||
class DeleteAccountScreenTest : BaseComposeTest() {
|
||||
|
||||
private var onNavigateBackCalled = false
|
||||
private var onNavigateToDeleteAccountConfirmationScreenCalled = false
|
||||
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<DeleteAccountEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
|
@ -39,6 +40,9 @@ class DeleteAccountScreenTest : BaseComposeTest() {
|
|||
composeTestRule.setContent {
|
||||
DeleteAccountScreen(
|
||||
onNavigateBack = { onNavigateBackCalled = true },
|
||||
onNavigateToDeleteAccountConfirmation = {
|
||||
onNavigateToDeleteAccountConfirmationScreenCalled = true
|
||||
},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
|
@ -50,6 +54,13 @@ class DeleteAccountScreenTest : BaseComposeTest() {
|
|||
assertTrue(onNavigateBackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `NavigateToDeleteAccountConfirmationScreen should call onNavigateToDeleteAccountConfirmationScreenCalled`() {
|
||||
mutableEventFlow.tryEmit(DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen)
|
||||
assertTrue(onNavigateToDeleteAccountConfirmationScreenCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel click should emit CancelClick`() {
|
||||
composeTestRule.onNodeWithText("Cancel").performScrollTo().performClick()
|
||||
|
@ -195,11 +206,16 @@ class DeleteAccountScreenTest : BaseComposeTest() {
|
|||
.assertDoesNotExist()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(password))
|
||||
viewModel.trySendAction(
|
||||
DeleteAccountAction.DeleteAccountConfirmDialogClick(
|
||||
password,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState(
|
||||
dialog = null,
|
||||
isUnlockWithPasswordEnabled = true,
|
||||
)
|
||||
|
|
|
@ -5,6 +5,8 @@ import app.cash.turbine.test
|
|||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.coEvery
|
||||
|
@ -14,18 +16,36 @@ import io.mockk.just
|
|||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DeleteAccountViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val authRepo: AuthRepository = mockk(relaxed = true)
|
||||
private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE)
|
||||
private val authRepo: AuthRepository = mockk {
|
||||
every { userStateFlow } returns mutableUserStateFlow
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when not set`() {
|
||||
mutableUserStateFlow.update { currentState ->
|
||||
currentState.copy(
|
||||
accounts = currentState.accounts.map { account ->
|
||||
account.copy(
|
||||
trustedDevice = account.trustedDevice?.copy(
|
||||
hasMasterPassword = false,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
val viewModel = createViewModel(state = null)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(isUnlockWithPasswordEnabled = false),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -56,13 +76,18 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `on DeleteAccountClick should update dialog state when delete account succeeds`() =
|
||||
@Suppress("MaxLineLength")
|
||||
fun `on DeleteAccountConfirmDialogClick should update dialog state when delete account succeeds`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
val masterPassword = "ckasb kcs ja"
|
||||
coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Success
|
||||
|
||||
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword))
|
||||
viewModel.trySendAction(
|
||||
DeleteAccountAction.DeleteAccountConfirmDialogClick(
|
||||
masterPassword,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(dialog = DeleteAccountState.DeleteAccountDialog.DeleteSuccess),
|
||||
|
@ -74,13 +99,32 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `on DeleteAccountClick should emit NavigateToDeleteAccountConfirmationScreen`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel(
|
||||
state = DEFAULT_STATE.copy(
|
||||
isUnlockWithPasswordEnabled = false,
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick)
|
||||
assertEquals(
|
||||
DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on DeleteAccountClick should update dialog state when deleteAccount fails`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val masterPassword = "ckasb kcs ja"
|
||||
coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Error
|
||||
|
||||
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword))
|
||||
viewModel.trySendAction(DeleteAccountAction.DeleteAccountConfirmDialogClick(masterPassword))
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
|
@ -135,6 +179,34 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_USER_STATE: UserState = UserState(
|
||||
activeUserId = "activeUserId",
|
||||
accounts = listOf(
|
||||
UserState.Account(
|
||||
userId = "activeUserId",
|
||||
name = "name",
|
||||
email = "email",
|
||||
avatarColorHex = "avatarColorHex",
|
||||
environment = Environment.Us,
|
||||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
needsMasterPassword = false,
|
||||
trustedDevice = UserState.TrustedDevice(
|
||||
isDeviceTrusted = true,
|
||||
hasMasterPassword = true,
|
||||
hasAdminApproval = true,
|
||||
hasLoginApprovingDevice = true,
|
||||
hasResetPasswordPermission = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState(
|
||||
dialog = null,
|
||||
isUnlockWithPasswordEnabled = true,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,129 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation
|
||||
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class DeleteAccountConfirmationScreenTest : BaseComposeTest() {
|
||||
private var onNavigateBackCalled = false
|
||||
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<DeleteAccountConfirmationEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<DeleteAccountConfirmationViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
composeTestRule.setContent {
|
||||
DeleteAccountConfirmationScreen(
|
||||
onNavigateBack = { onNavigateBackCalled = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on NavigateBack should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(DeleteAccountConfirmationEvent.NavigateBack)
|
||||
assertTrue(onNavigateBackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading dialog presence should update with dialog state`() {
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Loading")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Loading")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog presence should update with dialog state`() {
|
||||
val message = "hello world"
|
||||
composeTestRule
|
||||
.onAllNodesWithText(message)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error(
|
||||
message = message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(message)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete success dialog presence should update with dialog state`() {
|
||||
val message = "Your account has been permanently deleted"
|
||||
composeTestRule
|
||||
.onAllNodesWithText(message)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
DEFAULT_STATE.copy(
|
||||
dialog =
|
||||
DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess(),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(message)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete success dialog dismiss should emit DeleteAccountAction`() {
|
||||
mutableStateFlow.update {
|
||||
DEFAULT_STATE.copy(
|
||||
dialog =
|
||||
DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess(),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountAcknowledge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE: DeleteAccountConfirmationState =
|
||||
DeleteAccountConfirmationState(dialog = null)
|
|
@ -0,0 +1,93 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation
|
||||
|
||||
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
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DeleteAccountConfirmationViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val authRepo: AuthRepository = mockk(relaxed = true)
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when not set`() {
|
||||
val viewModel = createViewModel(state = null)
|
||||
assertEquals(
|
||||
DEFAULT_STATE,
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when set`() {
|
||||
val state = DEFAULT_STATE.copy(
|
||||
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error(
|
||||
message = "Hello".asText(),
|
||||
),
|
||||
)
|
||||
val viewModel = createViewModel(state = state)
|
||||
assertEquals(state, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CloseClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(DeleteAccountConfirmationAction.CloseClick)
|
||||
assertEquals(DeleteAccountConfirmationEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DeleteAccountAcknowledge should clear dialog and call clearPendingAccountDeletion`() =
|
||||
runTest {
|
||||
every { authRepo.clearPendingAccountDeletion() } just runs
|
||||
val state = DEFAULT_STATE.copy(
|
||||
dialog =
|
||||
DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess(),
|
||||
)
|
||||
val viewModel = createViewModel(state = state)
|
||||
|
||||
viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountAcknowledge)
|
||||
assertEquals(
|
||||
DEFAULT_STATE,
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
verify {
|
||||
authRepo.clearPendingAccountDeletion()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on DismissDialog should clear dialog state`() = runTest {
|
||||
val state = DEFAULT_STATE.copy(
|
||||
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error(
|
||||
message = "Hello".asText(),
|
||||
),
|
||||
)
|
||||
val viewModel = createViewModel(state = state)
|
||||
|
||||
viewModel.trySendAction(DeleteAccountConfirmationAction.DismissDialog)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
authenticationRepository: AuthRepository = authRepo,
|
||||
state: DeleteAccountConfirmationState? = null,
|
||||
): DeleteAccountConfirmationViewModel = DeleteAccountConfirmationViewModel(
|
||||
authRepository = authenticationRepository,
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", state) },
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE: DeleteAccountConfirmationState =
|
||||
DeleteAccountConfirmationState(dialog = null)
|
Loading…
Reference in a new issue