BIT-2234: Delete Account Confirmation Screen (Navigation) (#1278)

This commit is contained in:
Ramsey Smith
2024-04-18 10:42:39 -06:00
committed by Álison Fernandes
parent 4ac9d05036
commit 9648f720be
11 changed files with 702 additions and 18 deletions

View File

@@ -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,
)
}
}

View File

@@ -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,
)
}

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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
}
}
}

View File

@@ -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()
}

View File

@@ -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() },