Populate the send screen with real data (#488)

This commit is contained in:
David Perez 2024-01-04 11:30:50 -06:00 committed by Álison Fernandes
parent da53c72a61
commit 15fcfce0b2
16 changed files with 1023 additions and 34 deletions

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.platform.base.util
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarScrollBehavior
@ -7,6 +8,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
@ -31,3 +37,35 @@ fun Modifier.scrolledContainerBackground(
)
}
}
/**
* This is a [Modifier] extension for drawing a divider at the bottom of the composable.
*/
@OmitFromCoverage
@Stable
@Composable
fun Modifier.bottomDivider(
paddingStart: Dp = 0.dp,
paddingEnd: Dp = 0.dp,
thickness: Dp = DividerDefaults.Thickness,
color: Color = DividerDefaults.color,
enabled: Boolean = true,
): Modifier = drawWithCache {
onDrawWithContent {
drawContent()
if (enabled) {
drawLine(
color = color,
strokeWidth = thickness.toPx(),
start = Offset(
x = paddingStart.toPx(),
y = size.height - thickness.toPx() / 2,
),
end = Offset(
x = size.width - paddingEnd.toPx(),
y = size.height - thickness.toPx() / 2,
),
)
}
}
}

View file

@ -0,0 +1,105 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.bottomDivider
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A reusable composable function that displays a group item.
* The list item consists of a start icon, a label, a supporting label and an optional divider.
*
* @param label The main text label to be displayed in the group item.
* @param supportingLabel The secondary supporting text label to be displayed beside the label.
* @param startIcon The [Painter] object used to draw the icon at the start of the group item.
* @param onClick A lambda function that is invoked when the group is clicked.
* @param modifier The [Modifier] to be applied to the [Row] composable that holds the list item.
* @param showDivider Indicates whether the divider should be shown or not.
*/
@Composable
fun BitwardenGroupItem(
label: String,
supportingLabel: String,
startIcon: Painter,
onClick: () -> Unit,
modifier: Modifier = Modifier,
showDivider: Boolean = true,
) {
Row(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = onClick,
)
.bottomDivider(
enabled = showDivider,
paddingStart = 16.dp,
)
.padding(
top = 16.dp,
bottom = 16.dp,
end = 8.dp,
)
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
painter = startIcon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(24.dp),
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Text(
text = supportingLabel,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
painter = painterResource(id = R.drawable.ic_navigate_next),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
}
}
@Preview(showBackground = true)
@Composable
private fun BitwardenGroupItem_preview() {
BitwardenTheme {
BitwardenGroupItem(
label = "Sample Label",
supportingLabel = "5",
startIcon = painterResource(id = R.drawable.ic_send_text),
onClick = {},
)
}
}

View file

@ -0,0 +1,139 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.Icon
import androidx.compose.material3.IconButton
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.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A Composable function that displays a row item.
*
* @param label The primary text label to display for the item.
* @param supportingLabel An secondary text label to display beneath the label.
* @param startIcon The [Painter] object used to draw the icon at the start of the item.
* @param onClick The lambda to be invoked when the item is clicked.
* @param modifier An optional [Modifier] for this Composable, defaulting to an empty Modifier.
* This allows the caller to specify things like padding, size, etc.
* @param selectionDataList A list of all the selection items to be displayed in the overflow
* dialog.
*/
@Suppress("LongMethod")
@Composable
fun BitwardenListItem(
label: String,
supportingLabel: String,
startIcon: Painter,
onClick: () -> Unit,
selectionDataList: List<SelectionItemData>,
modifier: Modifier = Modifier,
) {
var shouldShowDialog by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = onClick,
)
.defaultMinSize(minHeight = 72.dp)
.padding(vertical = 8.dp)
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
painter = startIcon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(24.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = supportingLabel,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(
onClick = { shouldShowDialog = true },
) {
Icon(
painter = painterResource(id = R.drawable.ic_more_horizontal),
contentDescription = stringResource(id = R.string.options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
}
}
if (shouldShowDialog) {
BitwardenSelectionDialog(
title = label,
onDismissRequest = { shouldShowDialog = false },
selectionItems = {
selectionDataList.forEach {
BitwardenBasicDialogRow(
text = it.text,
onClick = {
shouldShowDialog = false
it.onClick()
},
)
}
},
)
}
}
/**
* Wrapper for the an individual selection item's data.
*/
data class SelectionItemData(
val text: String,
val onClick: () -> Unit,
)
@Preview(showBackground = true)
@Composable
private fun BitwardenListItem_preview() {
BitwardenTheme {
BitwardenListItem(
label = "Sample Label",
supportingLabel = "Jan 3, 2024, 10:35 AM",
startIcon = painterResource(id = R.drawable.ic_send_text),
onClick = {},
selectionDataList = emptyList(),
)
}
}

View file

@ -6,31 +6,89 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenGroupItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderTextWithSupportLabel
import com.x8bit.bitwarden.ui.tools.feature.send.handlers.SendHandlers
/**
* Content view for the [SendScreen].
*/
@Suppress("LongMethod")
@Composable
fun SendContent(
state: SendState.ViewState.Content,
sendHandlers: SendHandlers,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
item {
// TODO: Populate with real data BIT-481
Text(
text = "Not yet implemented",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
BitwardenListHeaderText(
label = stringResource(id = R.string.types),
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
BitwardenGroupItem(
label = stringResource(id = R.string.type_text),
supportingLabel = state.textTypeCount.toString(),
startIcon = painterResource(id = R.drawable.ic_send_text),
onClick = sendHandlers.onTextTypeClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
BitwardenGroupItem(
label = stringResource(id = R.string.type_file),
supportingLabel = state.fileTypeCount.toString(),
startIcon = painterResource(id = R.drawable.ic_send_file),
onClick = sendHandlers.onFileTypeClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
BitwardenListHeaderTextWithSupportLabel(
label = stringResource(id = R.string.all_sends),
supportingLabel = state.sendItems.size.toString(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
items(state.sendItems) {
SendListItem(
startIcon = painterResource(id = it.type.iconRes),
label = it.name,
supportingLabel = it.deletionDate,
onClick = { sendHandlers.onSendClick(it) },
onCopyClick = { sendHandlers.onCopySendClick(it) },
onEditClick = { sendHandlers.onEditSendClick(it) },
onShareClick = { sendHandlers.onShareSendClick(it) },
modifier = Modifier
.padding(
start = 16.dp,
// There is some built-in padding to the menu button that makes up
// the visual difference here.
end = 12.dp,
)
.fillMaxWidth(),
)
}

View file

@ -0,0 +1,76 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenListItem
import com.x8bit.bitwarden.ui.platform.components.SelectionItemData
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A Composable function that displays a row send item.
*
* @param label The primary text label to display for the item.
* @param supportingLabel An secondary text label to display beneath the label.
* @param startIcon The [Painter] object used to draw the icon at the start of the item.
* @param onClick The lambda to be invoked when the item is clicked.
* @param onEditClick The lambda to be invoked when the edit option is clicked from the menu.
* @param onCopyClick The lambda to be invoked when the copy option is clicked from the menu.
* @param onShareClick The lambda to be invoked when the share option is clicked from the menu.
* @param modifier An optional [Modifier] for this Composable, defaulting to an empty Modifier.
* This allows the caller to specify things like padding, size, etc.
*/
@Suppress("LongMethod")
@Composable
fun SendListItem(
label: String,
supportingLabel: String,
startIcon: Painter,
onClick: () -> Unit,
onEditClick: () -> Unit,
onCopyClick: () -> Unit,
onShareClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BitwardenListItem(
label = label,
supportingLabel = supportingLabel,
startIcon = startIcon,
onClick = onClick,
selectionDataList = listOf(
SelectionItemData(
text = stringResource(id = R.string.edit),
onClick = onEditClick,
),
SelectionItemData(
text = stringResource(id = R.string.copy_link),
onClick = onCopyClick,
),
SelectionItemData(
text = stringResource(id = R.string.share_link),
onClick = onShareClick,
),
),
modifier = modifier,
)
}
@Preview(showBackground = true)
@Composable
private fun SendListItem_preview() {
BitwardenTheme {
SendListItem(
label = "Sample Label",
supportingLabel = "Jan 3, 2024, 10:35 AM",
startIcon = painterResource(id = R.drawable.ic_send_text),
onClick = {},
onCopyClick = {},
onEditClick = {},
onShareClick = {},
)
}
}

View file

@ -17,7 +17,7 @@ fun NavGraphBuilder.sendDestination(
route = SEND_ROUTE,
) {
SendScreen(
onNavigateAddSend = onNavigateToAddSend,
onNavigateToAddSend = onNavigateToAddSend,
)
}
}

View file

@ -18,6 +18,8 @@ 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.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -27,6 +29,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar
@ -34,6 +37,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import com.x8bit.bitwarden.ui.tools.feature.send.handlers.SendHandlers
import kotlinx.collections.immutable.persistentListOf
/**
@ -43,15 +47,22 @@ import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SendScreen(
onNavigateAddSend: () -> Unit,
onNavigateToAddSend: () -> Unit,
viewModel: SendViewModel = hiltViewModel(),
clipboardManager: ClipboardManager = LocalClipboardManager.current,
intentHandler: IntentHandler = IntentHandler(context = LocalContext.current),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is SendEvent.NavigateNewSend -> onNavigateAddSend()
is SendEvent.CopyToClipboard -> {
clipboardManager.setText(
event.message(context.resources).toString().toAnnotatedString(),
)
}
is SendEvent.NavigateNewSend -> onNavigateToAddSend()
is SendEvent.NavigateToAboutSend -> {
intentHandler.launchUri("https://bitwarden.com/products/send".toUri())
@ -65,6 +76,7 @@ fun SendScreen(
}
}
val sendHandlers = remember(viewModel) { SendHandlers.create(viewModel) }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
state = rememberTopAppBarState(),
)
@ -135,6 +147,7 @@ fun SendScreen(
is SendState.ViewState.Content -> SendContent(
modifier = modifier,
state = viewState,
sendHandlers = sendHandlers,
)
SendState.ViewState.Empty -> SendEmpty(

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
@ -26,6 +27,7 @@ private const val KEY_STATE = "state"
/**
* View model for the send screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class SendViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
@ -53,6 +55,11 @@ class SendViewModel @Inject constructor(
SendAction.RefreshClick -> handleRefreshClick()
SendAction.SearchClick -> handleSearchClick()
SendAction.SyncClick -> handleSyncClick()
is SendAction.CopyClick -> handleCopyClick(action)
SendAction.FileTypeClick -> handleFileTypeClick()
is SendAction.SendClick -> handleSendClick(action)
is SendAction.ShareClick -> handleShareClick(action)
SendAction.TextTypeClick -> handleTextTypeClick()
is SendAction.Internal -> handleInternalAction(action)
}
@ -130,6 +137,31 @@ class SendViewModel @Inject constructor(
// TODO: Add loading dialog state BIT-481
vaultRepo.sync()
}
private fun handleCopyClick(action: SendAction.CopyClick) {
// TODO: Create a link and copy it to the clipboard BIT-??
sendEvent(SendEvent.ShowToast("Not yet implemented".asText()))
}
private fun handleSendClick(action: SendAction.SendClick) {
// TODO: Navigate to the edit send screen BIT-??
sendEvent(SendEvent.ShowToast("Not yet implemented".asText()))
}
private fun handleShareClick(action: SendAction.ShareClick) {
// TODO: Create a link and use the share sheet BIT-??
sendEvent(SendEvent.ShowToast("Not yet implemented".asText()))
}
private fun handleFileTypeClick() {
// TODO: Navigate to the file type send list screen BIT-??
sendEvent(SendEvent.ShowToast("Not yet implemented".asText()))
}
private fun handleTextTypeClick() {
// TODO: Navigate to the text type send list screen BIT-??
sendEvent(SendEvent.ShowToast("Not yet implemented".asText()))
}
}
/**
@ -150,12 +182,34 @@ data class SendState(
abstract val shouldDisplayFab: Boolean
/**
* Show the empty state.
* Show the populated state.
*/
@Parcelize
// TODO: Add actual content BIT-481
data object Content : ViewState() {
data class Content(
val textTypeCount: Int,
val fileTypeCount: Int,
val sendItems: List<SendItem>,
) : ViewState() {
override val shouldDisplayFab: Boolean get() = true
/**
* Represents the an individual send item to be displayed.
*/
@Parcelize
data class SendItem(
val id: String,
val name: String,
val deletionDate: String,
val type: Type,
) : Parcelable {
/**
* Indicates the type of send this, a text or file.
*/
enum class Type(@DrawableRes val iconRes: Int) {
FILE(iconRes = R.drawable.ic_send_file),
TEXT(iconRes = R.drawable.ic_send_text),
}
}
}
/**
@ -220,6 +274,37 @@ sealed class SendAction {
*/
data object SyncClick : SendAction()
/**
* User clicked the file type button.
*/
data object FileTypeClick : SendAction()
/**
* User clicked the text type button.
*/
data object TextTypeClick : SendAction()
/**
* User clicked the item row.
*/
data class SendClick(
val sendItem: SendState.ViewState.Content.SendItem,
) : SendAction()
/**
* User clicked the copy item button.
*/
data class CopyClick(
val sendItem: SendState.ViewState.Content.SendItem,
) : SendAction()
/**
* User clicked the share item button.
*/
data class ShareClick(
val sendItem: SendState.ViewState.Content.SendItem,
) : SendAction()
/**
* Models actions that the [SendViewModel] itself will send.
*/
@ -237,6 +322,11 @@ sealed class SendAction {
* Models events for the send screen.
*/
sealed class SendEvent {
/**
* Copies the given [message] to the clipboard.
*/
data class CopyToClipboard(val message: Text) : SendEvent()
/**
* Navigate to the new send screen.
*/

View file

@ -0,0 +1,35 @@
package com.x8bit.bitwarden.ui.tools.feature.send.handlers
import com.x8bit.bitwarden.ui.tools.feature.send.SendAction
import com.x8bit.bitwarden.ui.tools.feature.send.SendState
import com.x8bit.bitwarden.ui.tools.feature.send.SendViewModel
/**
* A collection of handler functions for managing actions within the context of viewing
* send items.
*/
data class SendHandlers(
val onTextTypeClick: () -> Unit,
val onFileTypeClick: () -> Unit,
val onSendClick: (SendState.ViewState.Content.SendItem) -> Unit,
val onEditSendClick: (SendState.ViewState.Content.SendItem) -> Unit,
val onCopySendClick: (SendState.ViewState.Content.SendItem) -> Unit,
val onShareSendClick: (SendState.ViewState.Content.SendItem) -> Unit,
) {
companion object {
/**
* Creates an instance of [SendHandlers] by binding actions to the provided [SendViewModel].
*/
fun create(
viewModel: SendViewModel,
): SendHandlers =
SendHandlers(
onTextTypeClick = { viewModel.trySendAction(SendAction.TextTypeClick) },
onFileTypeClick = { viewModel.trySendAction(SendAction.FileTypeClick) },
onSendClick = { viewModel.trySendAction(SendAction.SendClick(it)) },
onEditSendClick = { viewModel.trySendAction(SendAction.SendClick(it)) },
onCopySendClick = { viewModel.trySendAction(SendAction.CopyClick(it)) },
onShareSendClick = { viewModel.trySendAction(SendAction.ShareClick(it)) },
)
}
}

View file

@ -1,9 +1,13 @@
package com.x8bit.bitwarden.ui.tools.feature.send.util
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.ui.tools.feature.generator.util.toFormattedPattern
import com.x8bit.bitwarden.ui.tools.feature.send.SendState
private const val DELETION_DATE_PATTERN: String = "MMM d, uuuu, hh:mm a"
/**
* Transforms [SendData] into [SendState.ViewState].
*/
@ -15,6 +19,19 @@ fun SendData.toViewState(): SendState.ViewState =
?: SendState.ViewState.Empty
private fun List<SendView>.toSendContent(): SendState.ViewState.Content {
// TODO: Populate with real data BIT-481
return SendState.ViewState.Content
return SendState.ViewState.Content(
textTypeCount = this.count { it.type == SendType.TEXT },
fileTypeCount = this.count { it.type == SendType.FILE },
sendItems = this.map {
SendState.ViewState.Content.SendItem(
id = requireNotNull(it.id),
name = it.name,
deletionDate = it.deletionDate.toFormattedPattern(DELETION_DATE_PATTERN),
type = when (it.type) {
SendType.TEXT -> SendState.ViewState.Content.SendItem.Type.TEXT
SendType.FILE -> SendState.ViewState.Content.SendItem.Type.FILE
},
)
},
)
}

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportHeight="20"
android:viewportWidth="20">
<group>
<clip-path android:pathData="M0,0h20v20h-20z" />
<path
android:fillColor="#1B1B1F"
android:fillType="evenOdd"
android:pathData="M1.25,18.125V1.875C1.25,0.84 2.089,0 3.125,0H10.536C11.055,0 11.552,0.216 11.906,0.596L16.996,6.051C17.32,6.398 17.5,6.855 17.5,7.33V18.125C17.5,19.161 16.66,20 15.625,20H3.125C2.089,20 1.25,19.161 1.25,18.125ZM16.25,7.5V18.125C16.25,18.47 15.97,18.75 15.625,18.75H3.125C2.78,18.75 2.5,18.47 2.5,18.125V1.875C2.5,1.53 2.78,1.25 3.125,1.25H10V6.25C10,6.94 10.56,7.5 11.25,7.5H16.25ZM11.25,1.725L15.472,6.25H11.25V1.725Z" />
</group>
</vector>

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportHeight="20"
android:viewportWidth="20">
<path
android:fillColor="#1B1B1F"
android:fillType="evenOdd"
android:pathData="M1.25,18.125V1.875C1.25,0.839 2.089,0 3.125,0H10.536C11.055,0 11.552,0.216 11.906,0.596L16.996,6.05C17.32,6.398 17.5,6.855 17.5,7.33V18.125C17.5,19.16 16.66,20 15.625,20H3.125C2.089,20 1.25,19.16 1.25,18.125ZM16.25,7.5V18.125C16.25,18.47 15.97,18.75 15.625,18.75H3.125C2.78,18.75 2.5,18.47 2.5,18.125V1.875C2.5,1.53 2.78,1.25 3.125,1.25H10V6.25C10,6.94 10.56,7.5 11.25,7.5H16.25ZM11.25,1.724L15.472,6.25H11.25V1.724Z" />
<path
android:fillColor="#1B1B1F"
android:fillType="evenOdd"
android:pathData="M5,10.625C5,10.28 5.28,10 5.625,10H13.125C13.47,10 13.75,10.28 13.75,10.625C13.75,10.97 13.47,11.25 13.125,11.25H5.625C5.28,11.25 5,10.97 5,10.625Z" />
<path
android:fillColor="#1B1B1F"
android:fillType="evenOdd"
android:pathData="M5,13.125C5,12.78 5.28,12.5 5.625,12.5H13.125C13.47,12.5 13.75,12.78 13.75,13.125C13.75,13.47 13.47,13.75 13.125,13.75H5.625C5.28,13.75 5,13.47 5,13.125Z" />
<path
android:fillColor="#1B1B1F"
android:fillType="evenOdd"
android:pathData="M5,15.625C5,15.28 5.28,15 5.625,15H13.125C13.47,15 13.75,15.28 13.75,15.625C13.75,15.97 13.47,16.25 13.125,16.25H5.625C5.28,16.25 5,15.97 5,15.625Z" />
</vector>

View file

@ -9,7 +9,10 @@ import java.time.ZonedDateTime
/**
* Create a mock [SendView] with a given [number].
*/
fun createMockSendView(number: Int): SendView =
fun createMockSendView(
number: Int,
type: SendType = SendType.FILE,
): SendView =
SendView(
id = "mockId-$number",
accessId = "mockAccessId-$number",
@ -17,7 +20,7 @@ fun createMockSendView(number: Int): SendView =
notes = "mockNotes-$number",
key = "mockKey-$number",
password = "mockPassword-$number",
type = SendType.FILE,
type = type,
file = createMockFileView(number = number),
text = createMockTextView(number = number),
maxAccessCount = 1u,

View file

@ -1,24 +1,39 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.isPopup
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.core.net.toUri
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.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.isProgressBar
import io.mockk.every
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 org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@ -26,7 +41,12 @@ class SendScreenTest : BaseComposeTest() {
private var onNavigateToNewSendCalled = false
private val intentHandler = mockk<IntentHandler>()
private val clipboardManager = mockk<ClipboardManager> {
every { setText(any()) } just runs
}
private val intentHandler = mockk<IntentHandler> {
every { launchUri(any()) } just runs
}
private val mutableEventFlow = bufferedMutableSharedFlow<SendEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<SendViewModel>(relaxed = true) {
@ -39,12 +59,36 @@ class SendScreenTest : BaseComposeTest() {
composeTestRule.setContent {
SendScreen(
viewModel = viewModel,
onNavigateAddSend = { onNavigateToNewSendCalled = true },
onNavigateToAddSend = { onNavigateToNewSendCalled = true },
clipboardManager = clipboardManager,
intentHandler = intentHandler,
)
}
}
@Test
fun `on CopyToClipboard should call setText on the clipboardManager`() {
val text = "copy text"
mutableEventFlow.tryEmit(SendEvent.CopyToClipboard(text.asText()))
verify {
clipboardManager.setText(text.toAnnotatedString())
}
}
@Test
fun `on NavigateToNewSend should call onNavigateToNewSend`() {
mutableEventFlow.tryEmit(SendEvent.NavigateNewSend)
assertTrue(onNavigateToNewSendCalled)
}
@Test
fun `on NavigateToAboutSend should call launchUri on intentHandler`() {
mutableEventFlow.tryEmit(SendEvent.NavigateToAboutSend)
verify {
intentHandler.launchUri("https://bitwarden.com/products/send".toUri())
}
}
@Test
fun `on overflow item click should display menu`() {
composeTestRule
@ -131,7 +175,7 @@ class SendScreenTest : BaseComposeTest() {
composeTestRule.onNodeWithContentDescription("Add item").assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Content)
it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE)
}
composeTestRule.onNodeWithContentDescription("Add item").assertIsDisplayed()
}
@ -166,12 +210,6 @@ class SendScreenTest : BaseComposeTest() {
verify { viewModel.trySendAction(SendAction.SearchClick) }
}
@Test
fun `on NavigateToNewSend should call onNavigateToNewSend`() {
mutableEventFlow.tryEmit(SendEvent.NavigateNewSend)
assert(onNavigateToNewSendCalled)
}
@Test
fun `progressbar should be displayed according to state`() {
mutableStateFlow.update {
@ -190,7 +228,7 @@ class SendScreenTest : BaseComposeTest() {
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = SendState.ViewState.Content)
it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE)
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
}
@ -216,8 +254,265 @@ class SendScreenTest : BaseComposeTest() {
viewModel.trySendAction(SendAction.RefreshClick)
}
}
@Test
fun `text type count should be updated according to state`() {
val rowText = "Text"
mutableStateFlow.update {
it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(rowText))
composeTestRule
.onAllNodes(hasText(rowText))
.filterToOne(hasClickAction())
.assertTextEquals(rowText, 1.toString())
mutableStateFlow.update {
it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE.copy(textTypeCount = 3))
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(rowText))
composeTestRule
.onAllNodes(hasText(rowText))
.filterToOne(hasClickAction())
.assertTextEquals(rowText, 3.toString())
}
@Test
fun `text type row click should send TextTypeClick`() {
val rowText = "Text"
mutableStateFlow.update {
it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(rowText))
composeTestRule
.onAllNodes(hasText(rowText))
.filterToOne(hasClickAction())
.performClick()
verify {
viewModel.trySendAction(SendAction.TextTypeClick)
}
}
@Test
fun `file type count should be updated according to state`() {
val rowText = "File"
mutableStateFlow.update {
it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(rowText))
composeTestRule
.onAllNodes(hasText(rowText))
.filterToOne(hasClickAction())
.assertTextEquals(rowText, 1.toString())
mutableStateFlow.update {
it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE.copy(fileTypeCount = 3))
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(rowText))
composeTestRule
.onAllNodes(hasText(rowText))
.filterToOne(hasClickAction())
.assertTextEquals(rowText, 3.toString())
}
@Test
fun `file type row click should send FileTypeClick`() {
val rowText = "File"
mutableStateFlow.update {
it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(rowText))
composeTestRule
.onAllNodes(hasText(rowText))
.filterToOne(hasClickAction())
.performClick()
verify {
viewModel.trySendAction(SendAction.FileTypeClick)
}
}
@Test
fun `on send item click should send SendClick`() {
val rowText = "mockName-1"
mutableStateFlow.update {
it.copy(viewState = DEFAULT_CONTENT_VIEW_STATE)
}
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(rowText))
composeTestRule
.onAllNodes(hasText(rowText))
.filterToOne(hasClickAction())
.performClick()
verify {
viewModel.trySendAction(SendAction.SendClick(DEFAULT_SEND_ITEM))
}
}
@Test
fun `on send item overflow click should display dialog`() {
mutableStateFlow.update {
it.copy(
viewState = SendState.ViewState.Content(
textTypeCount = 0,
fileTypeCount = 1,
sendItems = listOf(DEFAULT_SEND_ITEM),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNode(isDialog())
.onChildren()
.filterToOne(hasText(DEFAULT_SEND_ITEM.name))
.assertIsDisplayed()
}
@Test
fun `on send item overflow dialog edit click should send SendClick`() {
mutableStateFlow.update {
it.copy(
viewState = SendState.ViewState.Content(
textTypeCount = 0,
fileTypeCount = 1,
sendItems = listOf(DEFAULT_SEND_ITEM),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Edit")
.assert(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(SendAction.SendClick(DEFAULT_SEND_ITEM))
}
composeTestRule.assertNoDialogExists()
}
@Test
fun `on send item overflow dialog copy click should send CopyClick`() {
mutableStateFlow.update {
it.copy(
viewState = SendState.ViewState.Content(
textTypeCount = 0,
fileTypeCount = 1,
sendItems = listOf(DEFAULT_SEND_ITEM),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Copy link")
.assert(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(SendAction.CopyClick(DEFAULT_SEND_ITEM))
}
composeTestRule.assertNoDialogExists()
}
@Test
fun `on send item overflow dialog share link click should send ShareClick`() {
mutableStateFlow.update {
it.copy(
viewState = SendState.ViewState.Content(
textTypeCount = 0,
fileTypeCount = 1,
sendItems = listOf(DEFAULT_SEND_ITEM),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Share link")
.assert(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(SendAction.ShareClick(DEFAULT_SEND_ITEM))
}
composeTestRule.assertNoDialogExists()
}
@Test
fun `on send item overflow dialog cancel click should close the dialog`() {
mutableStateFlow.update {
it.copy(
viewState = SendState.ViewState.Content(
textTypeCount = 0,
fileTypeCount = 1,
sendItems = listOf(DEFAULT_SEND_ITEM),
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithContentDescription("Options")
.assertIsDisplayed()
.performClick()
composeTestRule
.onNodeWithText("Cancel")
.assert(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
}
}
private val DEFAULT_STATE: SendState = SendState(
viewState = SendState.ViewState.Loading,
)
private val DEFAULT_SEND_ITEM: SendState.ViewState.Content.SendItem =
SendState.ViewState.Content.SendItem(
id = "mockId-1",
name = "mockName-1",
deletionDate = "1",
type = SendState.ViewState.Content.SendItem.Type.FILE,
)
private val DEFAULT_CONTENT_VIEW_STATE: SendState.ViewState.Content = SendState.ViewState.Content(
textTypeCount = 1,
fileTypeCount = 1,
sendItems = listOf(
DEFAULT_SEND_ITEM,
SendState.ViewState.Content.SendItem(
id = "mockId-2",
name = "mockName-2",
deletionDate = "1",
type = SendState.ViewState.Content.SendItem.Type.TEXT,
),
),
)

View file

@ -110,6 +110,54 @@ class SendViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `CopyClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
val sendItem = mockk<SendState.ViewState.Content.SendItem>()
viewModel.eventFlow.test {
viewModel.trySendAction(SendAction.CopyClick(sendItem))
assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem())
}
}
@Test
fun `SendClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
val sendItem = mockk<SendState.ViewState.Content.SendItem>()
viewModel.eventFlow.test {
viewModel.trySendAction(SendAction.SendClick(sendItem))
assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem())
}
}
@Test
fun `ShareClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
val sendItem = mockk<SendState.ViewState.Content.SendItem>()
viewModel.eventFlow.test {
viewModel.trySendAction(SendAction.ShareClick(sendItem))
assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem())
}
}
@Test
fun `FileTypeClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(SendAction.FileTypeClick)
assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem())
}
}
@Test
fun `TextTypeClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(SendAction.TextTypeClick)
assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem())
}
}
@Test
fun `VaultRepository SendData Error should update view state to Error`() {
val viewModel = createViewModel()
@ -129,7 +177,7 @@ class SendViewModelTest : BaseViewModelTest() {
@Test
fun `VaultRepository SendData Loaded should update view state`() {
val viewModel = createViewModel()
val viewState = SendState.ViewState.Content
val viewState = mockk<SendState.ViewState.Content>()
val sendData = mockk<SendData> {
every { toViewState() } returns viewState
}
@ -172,7 +220,7 @@ class SendViewModelTest : BaseViewModelTest() {
@Test
fun `VaultRepository SendData Pending should update view state`() {
val viewModel = createViewModel()
val viewState = SendState.ViewState.Content
val viewState = mockk<SendState.ViewState.Content>()
val sendData = mockk<SendData> {
every { toViewState() } returns viewState
}

View file

@ -1,13 +1,29 @@
package com.x8bit.bitwarden.ui.tools.feature.send.util
import com.bitwarden.core.SendType
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.ui.tools.feature.send.SendState
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.TimeZone
class SendDataExtensionsTest {
@BeforeEach
fun setup() {
// Setting the timezone so the tests pass consistently no matter the environment.
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
}
@AfterEach
fun tearDown() {
// Clearing the timezone after the test.
TimeZone.setDefault(null)
}
@Test
fun `toViewState should return Empty when SendData is empty`() {
val sendData = SendData(emptyList())
@ -20,12 +36,33 @@ class SendDataExtensionsTest {
@Test
fun `toViewState should return Content when SendData is not empty`() {
val list = listOf(
createMockSendView(number = 1),
createMockSendView(number = 1, type = SendType.FILE),
createMockSendView(number = 2, type = SendType.TEXT),
)
val sendData = SendData(list)
val result = sendData.toViewState()
assertEquals(SendState.ViewState.Content, result)
assertEquals(
SendState.ViewState.Content(
textTypeCount = 1,
fileTypeCount = 1,
sendItems = listOf(
SendState.ViewState.Content.SendItem(
id = "mockId-1",
name = "mockName-1",
deletionDate = "Oct 27, 2023, 12:00 PM",
type = SendState.ViewState.Content.SendItem.Type.FILE,
),
SendState.ViewState.Content.SendItem(
id = "mockId-2",
name = "mockName-2",
deletionDate = "Oct 27, 2023, 12:00 PM",
type = SendState.ViewState.Content.SendItem.Type.TEXT,
),
),
),
result,
)
}
}