Implement context menu on item long press (#31)

This commit is contained in:
Patrick Honkonen
2024-04-15 21:08:26 -04:00
committed by GitHub
parent 8da98f95e1
commit 612c8e8aa3
8 changed files with 281 additions and 53 deletions

View File

@@ -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,34 +242,53 @@ fun ItemListingScreen(
)
}
}
when (val dialog = state.dialog) {
ItemListingState.DialogState.Syncing -> {
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)
},
)
}
null -> Unit
}
}
}
}
@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 = onDismissRequest,
)
}
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
}
}
/**
* Displays the item listing screen with no existing items.
*/

View File

@@ -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()
}
}
/**
* 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
/**
* Indicates a result for deleting an item has been received.
*/
data class DeleteItemReceive(val result: DeleteItemResult) : Internal()
}
/**
* 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()
}

View File

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

View File

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

View File

@@ -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<VerificationCodeItem>.toViewState(
alertThresholdSeconds: Int,

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M15,3V4H20V6H19V19C19,20.1 18.1,21 17,21H7C5.9,21 5,20.1 5,19V6H4V4H9V3H15ZM7,19H17V6H7V19ZM9,8H11V17H9V8ZM15,8H13V17H15V8Z"
android:fillColor="#41484D"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19.06,3.59L20.41,4.94C21.2,5.72 21.2,6.99 20.41,7.77L7.18,21H3V16.82L13.4,6.41L16.23,3.59C17.01,2.81 18.28,2.81 19.06,3.59ZM5,19L6.41,19.06L16.23,9.23L14.82,7.82L5,17.64V19Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

View File

@@ -87,4 +87,8 @@
<string name="help">Help</string>
<string name="tutorial">Tutorial</string>
<string name="value_has_been_copied">%1$s copied</string>
<string name="delete_item">Delete item</string>
<string name="item_deleted">Item deleted</string>
<string name="delete">Delete</string>
<string name="do_you_really_want_to_permanently_delete_cipher">Do you really want to permanently delete? This cannot be undone.</string>
</resources>