diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt index 0038e19bd..cf1b072cf 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -45,6 +46,7 @@ import com.x8bit.bitwarden.authenticator.ui.platform.components.button.Bitwarden import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BitwardenLoadingDialog +import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState import com.x8bit.bitwarden.authenticator.ui.platform.components.fab.ExpandableFabIcon import com.x8bit.bitwarden.authenticator.ui.platform.components.fab.ExpandableFloatingActionButton @@ -95,6 +97,24 @@ fun ItemListingScreen( } } + ItemListingDialogs( + dialog = state.dialog, + onDismissRequest = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.DialogDismiss, + ) + } + }, + onConfirmDeleteClick = remember(viewModel) { + { itemId -> + viewModel.trySendAction( + ItemListingAction.ConfirmDeleteClick(itemId = itemId), + ) + } + } + ) + BitwardenScaffold( modifier = Modifier .fillMaxSize() @@ -175,10 +195,26 @@ fun ItemListingScreen( timeLeftSeconds = it.timeLeftSeconds, alertThresholdSeconds = it.alertThresholdSeconds, startIcon = it.startIcon, - onItemClick = { - viewModel.trySendAction( - ItemListingAction.ItemClick(it.authCode) - ) + onItemClick = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.ItemClick(it.id) + ) + } + }, + onEditItemClick = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.ItemClick(it.id) + ) + } + }, + onDeleteItemClick = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.DeleteItemClick(it.id) + ) + } }, modifier = Modifier .fillMaxWidth() @@ -206,31 +242,50 @@ fun ItemListingScreen( ) } } + } + } +} - when (val dialog = state.dialog) { - ItemListingState.DialogState.Syncing -> { - BitwardenLoadingDialog( - visibilityState = LoadingDialogState.Shown( - text = R.string.syncing.asText(), - ), - ) - } +@Composable +private fun ItemListingDialogs( + dialog: ItemListingState.DialogState?, + onDismissRequest: () -> Unit, + onConfirmDeleteClick: (itemId: String) -> Unit, +) { + when (dialog) { + ItemListingState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown( + text = R.string.syncing.asText(), + ), + ) + } - is ItemListingState.DialogState.Error -> { - BitwardenBasicDialog( - visibilityState = BasicDialogState.Shown( - title = dialog.title, - message = dialog.message, - ), - onDismissRequest = { - viewModel.trySendAction(ItemListingAction.DialogDismiss) - }, - ) - } + is ItemListingState.DialogState.Error -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialog.title, + message = dialog.message, + ), + onDismissRequest = onDismissRequest, + ) + } - null -> Unit - } + is ItemListingState.DialogState.DeleteConfirmationPrompt -> { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.delete), + message = dialog.message(), + confirmButtonText = stringResource(id = R.string.ok), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + onConfirmDeleteClick(dialog.itemId) + }, + onDismissClick = onDismissRequest, + onDismissRequest = onDismissRequest + ) } + + null -> Unit } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt index e8fce7f6e..4f0e92731 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt @@ -10,16 +10,17 @@ import com.x8bit.bitwarden.authenticator.data.authenticator.datasource.disk.enti import com.x8bit.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem import com.x8bit.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository import com.x8bit.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult +import com.x8bit.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult import com.x8bit.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult import com.x8bit.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.authenticator.data.platform.repository.model.DataState +import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util.toViewState import com.x8bit.bitwarden.authenticator.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text import com.x8bit.bitwarden.authenticator.ui.platform.base.util.asText import com.x8bit.bitwarden.authenticator.ui.platform.base.util.concat -import com.x8bit.bitwarden.authenticator.ui.platform.components.model.IconData import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -81,6 +82,14 @@ class ItemListingViewModel @Inject constructor( sendEvent(ItemListingEvent.NavigateBack) } + is ItemListingAction.DeleteItemClick -> { + handleDeleteItemClick(action) + } + + is ItemListingAction.ConfirmDeleteClick -> { + handleConfirmDeleteClick(action) + } + is ItemListingAction.SearchClick -> { sendEvent(ItemListingEvent.NavigateToSearch) } @@ -108,6 +117,33 @@ class ItemListingViewModel @Inject constructor( ) } + private fun handleDeleteItemClick(action: ItemListingAction.DeleteItemClick) { + mutableStateFlow.update { + it.copy( + dialog = ItemListingState.DialogState.DeleteConfirmationPrompt( + message = R.string.do_you_really_want_to_permanently_delete_cipher.asText(), + itemId = action.itemId, + ) + ) + } + } + + private fun handleConfirmDeleteClick(action: ItemListingAction.ConfirmDeleteClick) { + mutableStateFlow.update { + it.copy( + dialog = ItemListingState.DialogState.Loading, + ) + } + + viewModelScope.launch { + trySendAction( + ItemListingAction.Internal.DeleteItemReceive( + authenticatorRepository.hardDeleteItem(action.itemId) + ) + ) + } + } + private fun handleInternalAction(internalAction: ItemListingAction.Internal) { when (internalAction) { is ItemListingAction.Internal.AuthCodesUpdated -> { @@ -125,6 +161,36 @@ class ItemListingViewModel @Inject constructor( is ItemListingAction.Internal.CreateItemResultReceive -> { handleCreateItemResultReceive(internalAction) } + + is ItemListingAction.Internal.DeleteItemReceive -> { + handleDeleteItemReceive(internalAction.result) + } + } + } + + private fun handleDeleteItemReceive(result: DeleteItemResult) { + when (result) { + DeleteItemResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = ItemListingState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ) + ) + } + } + + DeleteItemResult.Success -> { + mutableStateFlow.update { + it.copy(dialog = null) + } + sendEvent( + ItemListingEvent.ShowToast( + message = R.string.item_deleted.asText(), + ), + ) + } } } @@ -242,7 +308,7 @@ class ItemListingViewModel @Inject constructor( ) { updateStateWithVerificationCodeItems( authenticatorData = authenticatorData.data, - clearDialogState = true + clearDialogState = false ) } @@ -401,10 +467,10 @@ data class ItemListingState( sealed class DialogState : Parcelable { /** - * Displays the syncing dialog to the user. + * Displays the loading dialog to the user. */ @Parcelize - data object Syncing : DialogState() + data object Loading : DialogState() /** * Displays a generic error dialog to the user. @@ -414,6 +480,12 @@ data class ItemListingState( val title: Text, val message: Text, ) : DialogState() + + @Parcelize + data class DeleteConfirmationPrompt( + val message: Text, + val itemId: String, + ) : DialogState() } } @@ -525,21 +597,20 @@ sealed class ItemListingAction { * Indicates a result for creating and item has been received. */ data class CreateItemResultReceive(val result: CreateItemResult) : Internal() + + /** + * Indicates a result for deleting an item has been received. + */ + data class DeleteItemReceive(val result: DeleteItemResult) : Internal() } -} -/** - * The data for the verification code item to display. - */ -@Parcelize -data class VerificationCodeDisplayItem( - val id: String, - val label: String, - val issuer: String?, - val supportingLabel: String?, - val timeLeftSeconds: Int, - val periodSeconds: Int, - val alertThresholdSeconds: Int, - val authCode: String, - val startIcon: IconData = IconData.Local(R.drawable.ic_login_item), -) : Parcelable + /** + * The user clicked Delete. + */ + data class DeleteItemClick(val itemId: String) : ItemListingAction() + + /** + * The user clicked confirm when prompted to delete an item. + */ + data class ConfirmDeleteClick(val itemId: String) : ItemListingAction() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt index 7de350d6d..054d96fec 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -9,12 +10,21 @@ import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -36,6 +46,7 @@ import com.x8bit.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme * @param modifier The modifier for the item. * @param supportingLabel The supporting label for the item. */ +@OptIn(ExperimentalFoundationApi::class) @Suppress("LongMethod", "MagicNumber") @Composable fun VaultVerificationCodeItem( @@ -46,15 +57,21 @@ fun VaultVerificationCodeItem( alertThresholdSeconds: Int, startIcon: IconData, onItemClick: () -> Unit, + onEditItemClick: () -> Unit, + onDeleteItemClick: () -> Unit, modifier: Modifier = Modifier, supportingLabel: String? = null, ) { + var shouldShowDropdownMenu by remember { mutableStateOf(value = false) } Row( modifier = Modifier - .clickable( + .combinedClickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(color = MaterialTheme.colorScheme.primary), onClick = onItemClick, + onLongClick = { + shouldShowDropdownMenu = true + } ) .defaultMinSize(minHeight = 72.dp) .padding(vertical = 8.dp) @@ -107,6 +124,43 @@ fun VaultVerificationCodeItem( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + + DropdownMenu( + expanded = shouldShowDropdownMenu, + onDismissRequest = { shouldShowDropdownMenu = false }, + ) { + DropdownMenuItem( + text = { + Text(text = stringResource(id = R.string.edit_item)) + }, + onClick = { + shouldShowDropdownMenu = false + onEditItemClick() + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_edit_item), + contentDescription = stringResource(R.string.edit_item) + ) + } + ) + HorizontalDivider() + DropdownMenuItem( + text = { + Text(text = stringResource(id = R.string.delete_item)) + }, + onClick = { + shouldShowDropdownMenu = false + onDeleteItemClick() + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_delete_item), + contentDescription = stringResource(id = R.string.delete_item), + ) + } + ) + } } @Suppress("MagicNumber") @@ -122,8 +176,10 @@ private fun VerificationCodeItem_preview() { alertThresholdSeconds = 7, startIcon = IconData.Local(R.drawable.ic_login_item), onItemClick = {}, + onEditItemClick = {}, + onDeleteItemClick = {}, modifier = Modifier.padding(horizontal = 16.dp), - supportingLabel = "Supporting Label" + supportingLabel = "Supporting Label", ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt new file mode 100644 index 000000000..e6bab93b6 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model + +import android.os.Parcelable +import com.x8bit.bitwarden.authenticator.R +import com.x8bit.bitwarden.authenticator.ui.platform.components.model.IconData +import kotlinx.parcelize.Parcelize + +/** + * The data for the verification code item to display. + */ +@Parcelize +data class VerificationCodeDisplayItem( + val id: String, + val label: String, + val issuer: String?, + val supportingLabel: String?, + val timeLeftSeconds: Int, + val periodSeconds: Int, + val alertThresholdSeconds: Int, + val authCode: String, + val startIcon: IconData = IconData.Local(R.drawable.ic_login_item), +) : Parcelable diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt index a8d5c2733..57aaf9531 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt @@ -2,7 +2,7 @@ package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.u import com.x8bit.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.ItemListingState -import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.VerificationCodeDisplayItem +import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem fun List.toViewState( alertThresholdSeconds: Int, diff --git a/app/src/main/res/drawable/ic_delete_item.xml b/app/src/main/res/drawable/ic_delete_item.xml new file mode 100644 index 000000000..f8780ff81 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_item.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_item.xml b/app/src/main/res/drawable/ic_edit_item.xml new file mode 100644 index 000000000..61a5c03a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_item.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2da2acda0..565ace935 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,4 +87,8 @@ Help Tutorial %1$s copied + Delete item + Item deleted + Delete + Do you really want to permanently delete? This cannot be undone.