mirror of
https://github.com/bitwarden/android.git
synced 2025-12-11 04:39:19 -06:00
PM-21134, PM-21135, PM-21136, PM-21137: Create View Send Screen (#5178)
This commit is contained in:
parent
6d68c3ae24
commit
860a2e265f
@ -364,10 +364,10 @@ fun Modifier.cardStyle(
|
||||
cardStyle = cardStyle,
|
||||
onClick = onClick,
|
||||
clickEnabled = clickEnabled,
|
||||
paddingStart = paddingHorizontal,
|
||||
paddingTop = paddingVertical,
|
||||
paddingEnd = paddingHorizontal,
|
||||
paddingBottom = paddingVertical,
|
||||
padding = PaddingValues(
|
||||
horizontal = paddingHorizontal,
|
||||
vertical = paddingVertical,
|
||||
),
|
||||
containerColor = containerColor,
|
||||
indicationColor = indicationColor,
|
||||
)
|
||||
@ -388,6 +388,34 @@ fun Modifier.cardStyle(
|
||||
paddingBottom: Dp = 12.dp,
|
||||
containerColor: Color = BitwardenTheme.colorScheme.background.secondary,
|
||||
indicationColor: Color = BitwardenTheme.colorScheme.background.pressed,
|
||||
): Modifier =
|
||||
this.cardStyle(
|
||||
cardStyle = cardStyle,
|
||||
onClick = onClick,
|
||||
clickEnabled = clickEnabled,
|
||||
padding = PaddingValues(
|
||||
start = paddingStart,
|
||||
top = paddingTop,
|
||||
end = paddingEnd,
|
||||
bottom = paddingBottom,
|
||||
),
|
||||
containerColor = containerColor,
|
||||
indicationColor = indicationColor,
|
||||
)
|
||||
|
||||
/**
|
||||
* This is a [Modifier] extension that applies a card style to the content.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Stable
|
||||
@Composable
|
||||
fun Modifier.cardStyle(
|
||||
cardStyle: CardStyle?,
|
||||
onClick: (() -> Unit)? = null,
|
||||
clickEnabled: Boolean = true,
|
||||
padding: PaddingValues = PaddingValues(horizontal = 0.dp, vertical = 12.dp),
|
||||
containerColor: Color = BitwardenTheme.colorScheme.background.secondary,
|
||||
indicationColor: Color = BitwardenTheme.colorScheme.background.pressed,
|
||||
): Modifier =
|
||||
this
|
||||
.cardBackground(
|
||||
@ -401,12 +429,7 @@ fun Modifier.cardStyle(
|
||||
)
|
||||
.cardPadding(
|
||||
cardStyle = cardStyle,
|
||||
paddingValues = PaddingValues(
|
||||
start = paddingStart,
|
||||
top = paddingTop,
|
||||
end = paddingEnd,
|
||||
bottom = paddingBottom,
|
||||
),
|
||||
paddingValues = padding,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@ -14,7 +14,9 @@ import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.cardStyle
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.color.bitwardenFilledButtonColors
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
@ -27,6 +29,9 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
* @param modifier The [Modifier] to be applied to the button.
|
||||
* @param icon The icon for the button.
|
||||
* @param isEnabled Whether or not the button is enabled.
|
||||
* @param cardStyle The optional card style to surround the button.
|
||||
* @param cardInsets The internal insets for the card, only applied when the [cardStyle] is not
|
||||
* `null`.
|
||||
*/
|
||||
@Composable
|
||||
fun BitwardenFilledButton(
|
||||
@ -36,9 +41,13 @@ fun BitwardenFilledButton(
|
||||
icon: Painter? = null,
|
||||
isEnabled: Boolean = true,
|
||||
colors: ButtonColors = bitwardenFilledButtonColors(),
|
||||
cardStyle: CardStyle? = null,
|
||||
cardInsets: PaddingValues = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
Button(
|
||||
modifier = modifier.semantics(mergeDescendants = true) {},
|
||||
modifier = modifier
|
||||
.semantics(mergeDescendants = true) {}
|
||||
.cardStyle(cardStyle = cardStyle, padding = cardInsets),
|
||||
onClick = onClick,
|
||||
enabled = isEnabled,
|
||||
contentPadding = PaddingValues(
|
||||
|
||||
@ -17,7 +17,9 @@ import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.cardStyle
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.color.bitwardenOutlinedButtonColors
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
@ -30,6 +32,10 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
* @param modifier The [Modifier] to be applied to the button.
|
||||
* @param icon The icon for the button.
|
||||
* @param isEnabled Whether or not the button is enabled.
|
||||
* @param cardStyle The optional card style to surround the button.
|
||||
* `null`.
|
||||
* @param cardInsets The internal insets for the card, only applied when the [cardStyle] is not
|
||||
* `null`.
|
||||
*/
|
||||
@Composable
|
||||
fun BitwardenOutlinedButton(
|
||||
@ -39,9 +45,13 @@ fun BitwardenOutlinedButton(
|
||||
icon: Painter? = null,
|
||||
isEnabled: Boolean = true,
|
||||
colors: BitwardenOutlinedButtonColors = bitwardenOutlinedButtonColors(),
|
||||
cardStyle: CardStyle? = null,
|
||||
cardInsets: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = modifier.semantics(mergeDescendants = true) { },
|
||||
modifier = modifier
|
||||
.semantics(mergeDescendants = true) { }
|
||||
.cardStyle(cardStyle = cardStyle, padding = cardInsets),
|
||||
onClick = onClick,
|
||||
enabled = isEnabled,
|
||||
contentPadding = PaddingValues(
|
||||
|
||||
@ -34,7 +34,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.bitwarden.ui.util.concat
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.cardStyle
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
@ -378,10 +377,7 @@ private fun AddSendOptions(
|
||||
state.common.currentAccessCount.takeUnless { isAddMode }?.let {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = R.string.current_access_count
|
||||
.asText()
|
||||
.concat(": ".asText(), it.toString().asText())
|
||||
.invoke(),
|
||||
text = R.string.current_access_count.asText(it).invoke(),
|
||||
style = BitwardenTheme.typography.bodySmall,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@ -1,49 +1,110 @@
|
||||
package com.x8bit.bitwarden.ui.tools.feature.send.viewsend
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.cardStyle
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedErrorButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenErrorContent
|
||||
import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingContent
|
||||
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.fab.BitwardenFloatingActionButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
|
||||
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenExpandingHeader
|
||||
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
|
||||
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.stepper.BitwardenStepper
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
|
||||
/**
|
||||
* Displays view send screen.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ViewSendScreen(
|
||||
viewModel: ViewSendViewModel = hiltViewModel(),
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToEditSend: (sendId: String) -> Unit,
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
val resources = context.resources
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
is ViewSendEvent.NavigateBack -> onNavigateBack()
|
||||
is ViewSendEvent.NavigateToEdit -> onNavigateToEditSend(event.sendId)
|
||||
is ViewSendEvent.ShareText -> {
|
||||
intentManager.shareText(text = event.text(resources).toString())
|
||||
}
|
||||
|
||||
is ViewSendEvent.ShowToast -> {
|
||||
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ViewSendDialogs(
|
||||
dialogState = state.dialogState,
|
||||
onDismissRequest = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ViewSendAction.DialogDismiss) }
|
||||
},
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
@ -82,18 +143,59 @@ fun ViewSendScreen(
|
||||
ViewSendScreenContent(
|
||||
state = state,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onCopyClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ViewSendAction.CopyClick) }
|
||||
},
|
||||
onDeleteClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ViewSendAction.DeleteClick) }
|
||||
},
|
||||
onShareClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ViewSendAction.ShareClick) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ViewSendDialogs(
|
||||
dialogState: ViewSendState.DialogState?,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
when (dialogState) {
|
||||
is ViewSendState.DialogState.Error -> {
|
||||
BitwardenBasicDialog(
|
||||
title = dialogState.title?.invoke(),
|
||||
message = dialogState.message(),
|
||||
throwable = dialogState.throwable,
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
||||
is ViewSendState.DialogState.Loading -> {
|
||||
BitwardenLoadingDialog(text = dialogState.message())
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ViewSendScreenContent(
|
||||
state: ViewSendState,
|
||||
onCopyClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
onShareClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (val viewState = state.viewState) {
|
||||
ViewSendState.ViewState.Content -> {
|
||||
// TODO: Build out the UI (PM-21135)
|
||||
is ViewSendState.ViewState.Content -> {
|
||||
ViewStateContent(
|
||||
state = viewState,
|
||||
onCopyClick = onCopyClick,
|
||||
onDeleteClick = onDeleteClick,
|
||||
onShareClick = onShareClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
is ViewSendState.ViewState.Error -> {
|
||||
@ -108,3 +210,260 @@ private fun ViewSendScreenContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun ViewStateContent(
|
||||
state: ViewSendState.ViewState.Content,
|
||||
onCopyClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
onShareClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(state = rememberScrollState()),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
ShareLinkSection(
|
||||
shareLink = state.shareLink,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = R.string.copy),
|
||||
onClick = onCopyClick,
|
||||
icon = rememberVectorPainter(id = R.drawable.ic_copy_small),
|
||||
cardStyle = CardStyle.Middle(hasDivider = false),
|
||||
cardInsets = PaddingValues(top = 16.dp, bottom = 6.dp, start = 16.dp, end = 16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
BitwardenOutlinedButton(
|
||||
label = stringResource(id = R.string.share),
|
||||
onClick = onShareClick,
|
||||
icon = rememberVectorPainter(id = R.drawable.ic_share_small),
|
||||
cardStyle = CardStyle.Bottom,
|
||||
cardInsets = PaddingValues(top = 6.dp, bottom = 16.dp, start = 16.dp, end = 16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(height = 16.dp))
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = R.string.send_details),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
when (val sendType = state.sendType) {
|
||||
is ViewSendState.ViewState.Content.SendType.FileType -> {
|
||||
FileSendContent(
|
||||
fileType = sendType,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
|
||||
is ViewSendState.ViewState.Content.SendType.TextType -> {
|
||||
TextSendContent(
|
||||
textType = sendType,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(id = R.string.send_name_required),
|
||||
value = state.sendName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
cardStyle = CardStyle.Full,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
|
||||
BitwardenTextField(
|
||||
label = stringResource(id = R.string.deletion_date),
|
||||
value = state.deletionDate,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
cardStyle = CardStyle.Full,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
AdditionalOptions(state = state)
|
||||
|
||||
BitwardenOutlinedErrorButton(
|
||||
label = stringResource(id = R.string.delete_send),
|
||||
onClick = onDeleteClick,
|
||||
icon = rememberVectorPainter(id = R.drawable.ic_trash_small),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 88.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A default content block which displays a header with an optional subtitle and an icon.
|
||||
* Implemented to match design component.
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareLinkSection(
|
||||
shareLink: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.cardStyle(
|
||||
cardStyle = CardStyle.Top(dividerPadding = 0.dp),
|
||||
paddingHorizontal = 16.dp,
|
||||
paddingVertical = 12.dp,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.share_link),
|
||||
style = BitwardenTheme.typography.titleSmall,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(height = 4.dp))
|
||||
Text(
|
||||
text = shareLink,
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileSendContent(
|
||||
fileType: ViewSendState.ViewState.Content.SendType.FileType,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minHeight = 60.dp)
|
||||
.cardStyle(
|
||||
cardStyle = CardStyle.Full,
|
||||
paddingHorizontal = 16.dp,
|
||||
paddingVertical = 12.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = fileType.fileName,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
style = BitwardenTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(weight = 1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = fileType.fileSize,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
style = BitwardenTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextSendContent(
|
||||
textType: ViewSendState.ViewState.Content.SendType.TextType,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BitwardenTextField(
|
||||
label = stringResource(id = R.string.text_to_share),
|
||||
value = textType.textToShare,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
cardStyle = CardStyle.Full,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun ColumnScope.AdditionalOptions(
|
||||
state: ViewSendState.ViewState.Content,
|
||||
) {
|
||||
if (state.maxAccessCount == null && state.notes == null) {
|
||||
Spacer(modifier = Modifier.height(height = 16.dp))
|
||||
return
|
||||
}
|
||||
var isExpanded by rememberSaveable { mutableStateOf(value = false) }
|
||||
BitwardenExpandingHeader(
|
||||
isExpanded = isExpanded,
|
||||
onClick = { isExpanded = !isExpanded },
|
||||
modifier = Modifier
|
||||
.testTag(tag = "ViewSendAdditionalOptions")
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
// Hide all content if not expanded:
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = fadeIn() + expandVertically(expandFrom = Alignment.Top),
|
||||
exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top),
|
||||
modifier = Modifier.clipToBounds(),
|
||||
) {
|
||||
Column {
|
||||
state.maxAccessCount?.let {
|
||||
BitwardenStepper(
|
||||
label = stringResource(id = R.string.maximum_access_count),
|
||||
value = it,
|
||||
supportingText = R.string.current_access_count
|
||||
.asText(state.currentAccessCount)
|
||||
.invoke(),
|
||||
onValueChange = {},
|
||||
isDecrementEnabled = false,
|
||||
isIncrementEnabled = false,
|
||||
range = 0..Int.MAX_VALUE,
|
||||
cardStyle = CardStyle.Full,
|
||||
modifier = Modifier
|
||||
.testTag(tag = "SendMaxAccessCount")
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
state.notes?.let {
|
||||
if (state.maxAccessCount != null) {
|
||||
// If the stepper is present, we need a spacer between these 2 items
|
||||
Spacer(modifier = Modifier.height(height = 8.dp))
|
||||
}
|
||||
BitwardenTextField(
|
||||
label = stringResource(id = R.string.private_notes),
|
||||
readOnly = true,
|
||||
value = it,
|
||||
singleLine = false,
|
||||
onValueChange = {},
|
||||
cardStyle = CardStyle.Full,
|
||||
modifier = Modifier
|
||||
.testTag(tag = "ViewSendNotes")
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(height = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,13 +2,30 @@ package com.x8bit.bitwarden.ui.tools.feature.send.viewsend
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.data.repository.util.baseWebSendUrl
|
||||
import com.bitwarden.send.SendView
|
||||
import com.bitwarden.ui.platform.base.BackgroundEvent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.bitwarden.ui.util.concat
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.viewsend.util.toViewSendViewStateContent
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Clock
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
@ -16,8 +33,13 @@ private const val KEY_STATE = "state"
|
||||
/**
|
||||
* View model for the view send screen.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class ViewSendViewModel @Inject constructor(
|
||||
private val clipboardManager: BitwardenClipboardManager,
|
||||
private val clock: Clock,
|
||||
private val vaultRepository: VaultRepository,
|
||||
environmentRepository: EnvironmentRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<ViewSendState, ViewSendEvent, ViewSendAction>(
|
||||
// We load the state from the savedStateHandle for testing purposes.
|
||||
@ -27,13 +49,35 @@ class ViewSendViewModel @Inject constructor(
|
||||
sendType = args.sendType,
|
||||
sendId = args.sendId,
|
||||
viewState = ViewSendState.ViewState.Loading,
|
||||
dialogState = null,
|
||||
baseWebSendUrl = environmentRepository.environment.environmentUrlData.baseWebSendUrl,
|
||||
)
|
||||
},
|
||||
) {
|
||||
init {
|
||||
vaultRepository
|
||||
.getSendStateFlow(sendId = state.sendId)
|
||||
.map { ViewSendAction.Internal.SendDataReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: ViewSendAction) {
|
||||
when (action) {
|
||||
ViewSendAction.CloseClick -> handleCloseClick()
|
||||
ViewSendAction.CopyClick -> handleCopyClick()
|
||||
ViewSendAction.DeleteClick -> handleDeleteClick()
|
||||
ViewSendAction.DialogDismiss -> handleDialogDismiss()
|
||||
ViewSendAction.EditClick -> handleEditClick()
|
||||
ViewSendAction.ShareClick -> handleShareClick()
|
||||
is ViewSendAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: ViewSendAction.Internal) {
|
||||
when (action) {
|
||||
is ViewSendAction.Internal.SendDataReceive -> handleSendDataReceive(action)
|
||||
is ViewSendAction.Internal.DeleteResultReceive -> handleDeleteResultReceive(action)
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,9 +85,127 @@ class ViewSendViewModel @Inject constructor(
|
||||
sendEvent(ViewSendEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleCopyClick() {
|
||||
onContent { clipboardManager.setText(text = it.shareLink) }
|
||||
}
|
||||
|
||||
private fun handleDeleteClick() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = ViewSendState.DialogState.Loading(R.string.deleting.asText()))
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = vaultRepository.deleteSend(sendId = state.sendId)
|
||||
sendAction(ViewSendAction.Internal.DeleteResultReceive(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDialogDismiss() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
private fun handleEditClick() {
|
||||
sendEvent(ViewSendEvent.NavigateToEdit(sendType = state.sendType, sendId = state.sendId))
|
||||
}
|
||||
|
||||
private fun handleShareClick() {
|
||||
onContent { sendEvent(ViewSendEvent.ShareText(text = it.shareLink.asText())) }
|
||||
}
|
||||
|
||||
private fun handleDeleteResultReceive(
|
||||
action: ViewSendAction.Internal.DeleteResultReceive,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is DeleteSendResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ViewSendState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.generic_error_message.asText(),
|
||||
throwable = result.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DeleteSendResult.Success -> {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
sendEvent(ViewSendEvent.ShowToast(message = R.string.send_deleted.asText()))
|
||||
sendEvent(ViewSendEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSendDataReceive(action: ViewSendAction.Internal.SendDataReceive) {
|
||||
when (val dataState = action.sendDataState) {
|
||||
is DataState.Error -> sendErrorReceive(dataState = dataState)
|
||||
is DataState.Loaded -> sendLoadedReceive(dataState = dataState)
|
||||
is DataState.Loading -> sendLoadingReceive()
|
||||
is DataState.NoNetwork -> sendNoNetworkReceive(dataState = dataState)
|
||||
is DataState.Pending -> sendPendingReceive(dataState = dataState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendLoadedReceive(dataState: DataState.Loaded<SendView?>) {
|
||||
dataState
|
||||
.data
|
||||
?.let { updateStateWithSendView(sendView = it) }
|
||||
?: updateStateWithErrorMessage(
|
||||
message = R.string.missing_send_resync_your_vault.asText(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendLoadingReceive() {
|
||||
mutableStateFlow.update { it.copy(viewState = ViewSendState.ViewState.Loading) }
|
||||
}
|
||||
|
||||
private fun sendErrorReceive(dataState: DataState.Error<SendView?>) {
|
||||
dataState
|
||||
.data
|
||||
?.let { updateStateWithSendView(sendView = it) }
|
||||
?: updateStateWithErrorMessage(message = R.string.generic_error_message.asText())
|
||||
}
|
||||
|
||||
private fun sendNoNetworkReceive(dataState: DataState.NoNetwork<SendView?>) {
|
||||
dataState
|
||||
.data
|
||||
?.let { updateStateWithSendView(sendView = it) }
|
||||
?: updateStateWithErrorMessage(
|
||||
message = R.string.internet_connection_required_title
|
||||
.asText()
|
||||
.concat(
|
||||
" ".asText(),
|
||||
R.string.internet_connection_required_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendPendingReceive(dataState: DataState.Pending<SendView?>) {
|
||||
dataState
|
||||
.data
|
||||
?.let { updateStateWithSendView(sendView = it) }
|
||||
?: updateStateWithErrorMessage(message = R.string.generic_error_message.asText())
|
||||
}
|
||||
|
||||
private fun updateStateWithSendView(sendView: SendView) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = sendView.toViewSendViewStateContent(
|
||||
baseWebSendUrl = it.baseWebSendUrl,
|
||||
clock = clock,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStateWithErrorMessage(message: Text) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = ViewSendState.ViewState.Error(message = message))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onContent(block: (ViewSendState.ViewState.Content) -> Unit) {
|
||||
(state.viewState as? ViewSendState.ViewState.Content)?.let(block = block)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -54,6 +216,8 @@ data class ViewSendState(
|
||||
val sendType: SendItemType,
|
||||
val sendId: String,
|
||||
val viewState: ViewState,
|
||||
val dialogState: DialogState?,
|
||||
val baseWebSendUrl: String,
|
||||
) : Parcelable {
|
||||
/**
|
||||
* Helper to determine the screen display name.
|
||||
@ -89,7 +253,60 @@ data class ViewSendState(
|
||||
* Represents a loaded content state for the view send screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Content : ViewState()
|
||||
data class Content(
|
||||
val sendType: SendType,
|
||||
val shareLink: String,
|
||||
val sendName: String,
|
||||
val deletionDate: String,
|
||||
val maxAccessCount: Int?,
|
||||
val currentAccessCount: Int,
|
||||
val notes: String?,
|
||||
) : ViewState() {
|
||||
/**
|
||||
* Content data specific to a send type.
|
||||
*/
|
||||
sealed class SendType : Parcelable {
|
||||
/**
|
||||
* Content data specific to a file send type.
|
||||
*/
|
||||
@Parcelize
|
||||
data class FileType(
|
||||
val fileName: String,
|
||||
val fileSize: String,
|
||||
) : SendType()
|
||||
|
||||
/**
|
||||
* Content data specific to a text send type.
|
||||
*/
|
||||
@Parcelize
|
||||
data class TextType(
|
||||
val textToShare: String,
|
||||
) : SendType()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a dialog displayed on the view send screen.
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
/**
|
||||
* Represents an error dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text?,
|
||||
val message: Text,
|
||||
val throwable: Throwable? = null,
|
||||
) : DialogState()
|
||||
|
||||
/**
|
||||
* Represents a loading dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Loading(
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,6 +326,20 @@ sealed class ViewSendEvent {
|
||||
val sendType: SendItemType,
|
||||
val sendId: String,
|
||||
) : ViewSendEvent()
|
||||
|
||||
/**
|
||||
* Shares the [text] via the share sheet.
|
||||
*/
|
||||
data class ShareText(
|
||||
val text: Text,
|
||||
) : ViewSendEvent()
|
||||
|
||||
/**
|
||||
* Shows the [message] via a toast.
|
||||
*/
|
||||
data class ShowToast(
|
||||
val message: Text,
|
||||
) : ViewSendEvent(), BackgroundEvent
|
||||
}
|
||||
|
||||
/**
|
||||
@ -120,8 +351,43 @@ sealed class ViewSendAction {
|
||||
*/
|
||||
data object CloseClick : ViewSendAction()
|
||||
|
||||
/**
|
||||
* The user has clicked the copy button.
|
||||
*/
|
||||
data object CopyClick : ViewSendAction()
|
||||
|
||||
/**
|
||||
* The user has clicked the delete button.
|
||||
*/
|
||||
data object DeleteClick : ViewSendAction()
|
||||
|
||||
/**
|
||||
* The user has dismissed the dialog.
|
||||
*/
|
||||
data object DialogDismiss : ViewSendAction()
|
||||
|
||||
/**
|
||||
* The user has clicked the edit button.
|
||||
*/
|
||||
data object EditClick : ViewSendAction()
|
||||
|
||||
/**
|
||||
* The user has clicked the share button.
|
||||
*/
|
||||
data object ShareClick : ViewSendAction()
|
||||
|
||||
/**
|
||||
* Models actions that the ViewModel itself might send.
|
||||
*/
|
||||
sealed class Internal : ViewSendAction() {
|
||||
/**
|
||||
* Indicates a result for deleting the send has been received.
|
||||
*/
|
||||
data class DeleteResultReceive(val result: DeleteSendResult) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the send item data has been received.
|
||||
*/
|
||||
data class SendDataReceive(val sendDataState: DataState<SendView?>) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
package com.x8bit.bitwarden.ui.tools.feature.send.viewsend.util
|
||||
|
||||
import com.bitwarden.send.SendFileView
|
||||
import com.bitwarden.send.SendTextView
|
||||
import com.bitwarden.send.SendType
|
||||
import com.bitwarden.send.SendView
|
||||
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.viewsend.ViewSendState
|
||||
import java.time.Clock
|
||||
|
||||
/**
|
||||
* Transforms the given [SendView] to a [ViewSendState.ViewState.Content].
|
||||
*/
|
||||
fun SendView.toViewSendViewStateContent(
|
||||
baseWebSendUrl: String,
|
||||
clock: Clock,
|
||||
): ViewSendState.ViewState.Content =
|
||||
ViewSendState.ViewState.Content(
|
||||
sendType = when (this.type) {
|
||||
SendType.FILE -> requireNotNull(this.file).toFileType()
|
||||
SendType.TEXT -> requireNotNull(this.text).toTextType()
|
||||
},
|
||||
shareLink = this.toSendUrl(baseWebSendUrl = baseWebSendUrl),
|
||||
sendName = this.name,
|
||||
deletionDate = this
|
||||
.deletionDate
|
||||
.toFormattedPattern(pattern = "d MMM, yyyy, h:mma", clock = clock),
|
||||
maxAccessCount = this.maxAccessCount?.toInt(),
|
||||
currentAccessCount = this.accessCount.toInt(),
|
||||
notes = this.notes,
|
||||
)
|
||||
|
||||
private fun SendFileView.toFileType(): ViewSendState.ViewState.Content.SendType.FileType =
|
||||
ViewSendState.ViewState.Content.SendType.FileType(
|
||||
fileName = this.fileName,
|
||||
fileSize = this.sizeName.orEmpty(),
|
||||
)
|
||||
|
||||
private fun SendTextView.toTextType(): ViewSendState.ViewState.Content.SendType.TextType =
|
||||
ViewSendState.ViewState.Content.SendType.TextType(
|
||||
textToShare = this.text.orEmpty(),
|
||||
)
|
||||
10
app/src/main/res/drawable/ic_copy_small.xml
Normal file
10
app/src/main/res/drawable/ic_copy_small.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="16"
|
||||
android:viewportHeight="16">
|
||||
<path
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M5,3C5,1.895 5.895,1 7,1H13C14.105,1 15,1.895 15,3V9C15,10.105 14.105,11 13,11H11V13C11,14.105 10.105,15 9,15H3C1.895,15 1,14.105 1,13V7C1,5.895 1.895,5 3,5H5V3ZM7,2.5H13C13.276,2.5 13.5,2.724 13.5,3V9C13.5,9.276 13.276,9.5 13,9.5H7C6.724,9.5 6.5,9.276 6.5,9V3C6.5,2.724 6.724,2.5 7,2.5ZM5,6.5H3C2.724,6.5 2.5,6.724 2.5,7V13C2.5,13.276 2.724,13.5 3,13.5H9C9.276,13.5 9.5,13.276 9.5,13V11H7C5.895,11 5,10.105 5,9V6.5Z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_share_small.xml
Normal file
10
app/src/main/res/drawable/ic_share_small.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="17dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="17"
|
||||
android:viewportHeight="16">
|
||||
<path
|
||||
android:fillColor="#175DDC"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M10.237,5.97,7.434,7.372c.043.202.066.412.066.628,0,.216-.023.426-.066.628l2.803,1.402C10.787,9.399,11.597,9,12.5,9c1.657,0,3,1.343,3,3s-1.343,3-3,3-3-1.343-3-3c0-.216.023-.426.066-.628L6.763,9.97C6.213,10.601,5.403,11,4.5,11c-1.657,0-3-1.343-3-3s1.343-3,3-3c.903,0,1.713.399,2.263,1.03L9.566,4.628C9.523,4.426,9.5,4.216,9.5,4c0-1.657,1.343-3,3-3s3,1.343,3,3-1.343,3-3,3c-.903,0-1.713-.399-2.263-1.03ZM12.5,5.5c.828,0,1.5-.672,1.5-1.5,0-.828-.672-1.5-1.5-1.5S11,3.172,11,4c0,.199.039.388.109.562.023.032.044.066.062.103.018.036.033.073.045.11.263.435.739.725,1.284.725ZM5.784,7.225C5.522,6.79,5.045,6.5,4.5,6.5,3.672,6.5,3,7.172,3,8s.672,1.5,1.5,1.5c.545,0,1.022-.29,1.284-.725.012-.037.027-.074.045-.11.018-.037.039-.071.062-.103C5.961,8.388,6,8.199,6,8s-.039-.388-.109-.562c-.023-.032-.044-.066-.062-.103-.018-.036-.033-.073-.045-.11ZM11,12c0-.199.039-.388.109-.562.023-.032.044-.066.062-.103.018-.036.033-.073.045-.11.263-.435.739-.725,1.284-.725.828,0,1.5.672,1.5,1.5s-.672,1.5-1.5,1.5S11,12.828,11,12Z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_trash_small.xml
Normal file
10
app/src/main/res/drawable/ic_trash_small.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="16"
|
||||
android:viewportHeight="16">
|
||||
<path
|
||||
android:fillColor="#CB263A"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M6.471,0C5.718,0 5.049,0.482 4.811,1.197L4.376,2.5H1.75C1.336,2.5 1,2.836 1,3.25C1,3.664 1.336,4 1.75,4H2C2,4.022 2.001,4.044 2.003,4.066L2.841,13.493C2.967,14.912 4.156,16 5.58,16H10.42C11.844,16 13.033,14.912 13.159,13.493L13.997,4.066C13.999,4.044 14,4.022 14,4H14.25C14.664,4 15,3.664 15,3.25C15,2.836 14.664,2.5 14.25,2.5H11.624L11.189,1.197C10.951,0.482 10.283,0 9.529,0H6.471ZM10.043,2.5L9.766,1.671C9.732,1.569 9.637,1.5 9.529,1.5L6.471,1.5C6.363,1.5 6.268,1.569 6.234,1.671L5.957,2.5H10.043ZM3.503,4H12.497L11.665,13.361C11.608,14.006 11.067,14.5 10.42,14.5H5.58C4.933,14.5 4.392,14.006 4.335,13.361L3.503,4Z" />
|
||||
</vector>
|
||||
@ -627,7 +627,7 @@ Scanning will happen automatically.</string>
|
||||
<string name="maximum_access_count">Maximum access count</string>
|
||||
<string name="maximum_access_count_info">If set, users will no longer be able to access this Send once the maximum access count is reached.</string>
|
||||
<string name="maximum_access_count_reached">Max access count reached</string>
|
||||
<string name="current_access_count">Current access count</string>
|
||||
<string name="current_access_count">Current access count: %s</string>
|
||||
<string name="new_password">New password</string>
|
||||
<string name="password_info">Require this password to view the Send.</string>
|
||||
<string name="remove_password">Remove password</string>
|
||||
@ -1268,4 +1268,6 @@ Do you want to switch to this account?</string>
|
||||
<string name="this_account_will_soon_be_deleted_log_in_at_x_to_continue_using_bitwarden">This account will soon be deleted. Log in at %1$s to continue using Bitwarden.</string>
|
||||
<string name="view_file_send">View file Send</string>
|
||||
<string name="view_text_send">View text Send</string>
|
||||
<string name="delete_send">Delete send</string>
|
||||
<string name="missing_send_resync_your_vault">Missing Send re-sync your vault</string>
|
||||
</resources>
|
||||
|
||||
@ -4,51 +4,79 @@ import com.bitwarden.send.SendFileView
|
||||
import com.bitwarden.send.SendTextView
|
||||
import com.bitwarden.send.SendType
|
||||
import com.bitwarden.send.SendView
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* Create a mock [SendView] with a given [number].
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun createMockSendView(
|
||||
number: Int,
|
||||
id: String? = "mockId-$number",
|
||||
accessId: String? = "mockAccessId-$number",
|
||||
name: String = "mockName-$number",
|
||||
notes: String? = "mockNotes-$number",
|
||||
key: String = "mockKey-$number",
|
||||
newPassword: String? = "mockPassword-$number",
|
||||
hasPassword: Boolean = true,
|
||||
type: SendType = SendType.FILE,
|
||||
file: SendFileView = createMockFileView(number = number),
|
||||
text: SendTextView = createMockTextView(number = number),
|
||||
maxAccessCount: UInt? = 1U,
|
||||
accessCount: UInt = 1U,
|
||||
disabled: Boolean = false,
|
||||
hideEmail: Boolean = false,
|
||||
revisionDate: Instant = ZonedDateTime.parse("2023-10-27T12:00:00Z").toInstant(),
|
||||
deletionDate: Instant = ZonedDateTime.parse("2023-10-27T12:00:00Z").toInstant(),
|
||||
expirationDate: Instant? = ZonedDateTime.parse("2023-10-27T12:00:00Z").toInstant(),
|
||||
): SendView =
|
||||
SendView(
|
||||
id = "mockId-$number",
|
||||
accessId = "mockAccessId-$number",
|
||||
name = "mockName-$number",
|
||||
notes = "mockNotes-$number",
|
||||
key = "mockKey-$number",
|
||||
newPassword = "mockPassword-$number",
|
||||
hasPassword = true,
|
||||
id = id,
|
||||
accessId = accessId,
|
||||
name = name,
|
||||
notes = notes,
|
||||
key = key,
|
||||
newPassword = newPassword,
|
||||
hasPassword = hasPassword,
|
||||
type = type,
|
||||
file = createMockFileView(number = number).takeIf { type == SendType.FILE },
|
||||
text = createMockTextView(number = number).takeIf { type == SendType.TEXT },
|
||||
maxAccessCount = 1u,
|
||||
accessCount = 1u,
|
||||
disabled = false,
|
||||
hideEmail = false,
|
||||
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z").toInstant(),
|
||||
deletionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z").toInstant(),
|
||||
expirationDate = ZonedDateTime.parse("2023-10-27T12:00:00Z").toInstant(),
|
||||
file = file.takeIf { type == SendType.FILE },
|
||||
text = text.takeIf { type == SendType.TEXT },
|
||||
maxAccessCount = maxAccessCount,
|
||||
accessCount = accessCount,
|
||||
disabled = disabled,
|
||||
hideEmail = hideEmail,
|
||||
revisionDate = revisionDate,
|
||||
deletionDate = deletionDate,
|
||||
expirationDate = expirationDate,
|
||||
)
|
||||
|
||||
/**
|
||||
* Create a mock [SendFileView] with a given [number].
|
||||
*/
|
||||
fun createMockFileView(number: Int): SendFileView =
|
||||
fun createMockFileView(
|
||||
number: Int,
|
||||
id: String? = "mockId-$number",
|
||||
fileName: String = "mockFileName-$number",
|
||||
size: String? = "1",
|
||||
sizeName: String? = "mockSizeName-$number",
|
||||
): SendFileView =
|
||||
SendFileView(
|
||||
fileName = "mockFileName-$number",
|
||||
size = "1",
|
||||
sizeName = "mockSizeName-$number",
|
||||
id = "mockId-$number",
|
||||
id = id,
|
||||
fileName = fileName,
|
||||
size = size,
|
||||
sizeName = sizeName,
|
||||
)
|
||||
|
||||
/**
|
||||
* Create a mock [SendTextView] with a given [number].
|
||||
*/
|
||||
fun createMockTextView(number: Int): SendTextView =
|
||||
fun createMockTextView(
|
||||
number: Int,
|
||||
text: String? = "mockText-$number",
|
||||
hidden: Boolean = false,
|
||||
): SendTextView =
|
||||
SendTextView(
|
||||
hidden = false,
|
||||
text = "mockText-$number",
|
||||
text = text,
|
||||
hidden = hidden,
|
||||
)
|
||||
|
||||
@ -1,23 +1,34 @@
|
||||
package com.x8bit.bitwarden.ui.tools.feature.send.viewsend
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.isPopup
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
|
||||
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.mockkStatic
|
||||
import io.mockk.runs
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
@ -34,9 +45,16 @@ class ViewSendScreenTest : BaseComposeTest() {
|
||||
every { trySendAction(action = any()) } just runs
|
||||
}
|
||||
|
||||
private var intentManager = mockk<IntentManager> {
|
||||
every { shareText(any()) } just runs
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setContent {
|
||||
mockkStatic(Toast::class)
|
||||
setContent(
|
||||
intentManager = intentManager,
|
||||
) {
|
||||
ViewSendScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { onNavigateBackCalled = true },
|
||||
@ -45,6 +63,11 @@ class ViewSendScreenTest : BaseComposeTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(Toast::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(ViewSendEvent.NavigateBack)
|
||||
@ -69,6 +92,50 @@ class ViewSendScreenTest : BaseComposeTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on ShareText event should call IntentManager ShareText`() {
|
||||
val text = "Shared Stuff"
|
||||
mutableEventFlow.tryEmit(ViewSendEvent.ShareText(text = text.asText()))
|
||||
verify(exactly = 1) {
|
||||
intentManager.shareText(text = text)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on ShowToast event should call onNavigateToEdit`() {
|
||||
val message = "message"
|
||||
val toast = mockk<Toast> {
|
||||
every { show() } just runs
|
||||
}
|
||||
every { Toast.makeText(any(), message, Toast.LENGTH_SHORT) } returns toast
|
||||
mutableEventFlow.tryEmit(ViewSendEvent.ShowToast(message = message.asText()))
|
||||
verify(exactly = 1) {
|
||||
toast.show()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on copy click should send CopyClick`() {
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Copy")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
verify(exactly = 1) {
|
||||
viewModel.trySendAction(ViewSendAction.CopyClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on delete click should send DeleteClick`() {
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Delete send")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
verify(exactly = 1) {
|
||||
viewModel.trySendAction(ViewSendAction.DeleteClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on edit click should send EditClick`() {
|
||||
composeTestRule
|
||||
@ -79,6 +146,17 @@ class ViewSendScreenTest : BaseComposeTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on app bar title should updated based on state`() {
|
||||
mutableStateFlow.update { it.copy(sendType = SendItemType.TEXT) }
|
||||
composeTestRule.onNodeWithText(text = "View file Send").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(text = "View text Send").assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update { it.copy(sendType = SendItemType.FILE) }
|
||||
composeTestRule.onNodeWithText(text = "View file Send").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(text = "View text Send").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress bar should be displayed based on ViewState`() {
|
||||
mutableStateFlow.update { it.copy(viewState = ViewSendState.ViewState.Loading) }
|
||||
@ -101,10 +179,158 @@ class ViewSendScreenTest : BaseComposeTest() {
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_STATE.viewState) }
|
||||
composeTestRule.onNodeWithText(text = errorMessage).assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `file type content should be displayed based on ViewState`() {
|
||||
mutableStateFlow.update { it.copy(viewState = ViewSendState.ViewState.Loading) }
|
||||
composeTestRule.onNodeWithText(text = "share_link").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(text = "text_to_share").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(text = "send_name").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(text = "deletion_date").assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
|
||||
sendType = ViewSendState.ViewState.Content.SendType.TextType(
|
||||
textToShare = "text_to_share",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "share_link")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "text_to_share")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "send_name")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "deletion_date")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text type content should be displayed based on ViewState`() {
|
||||
mutableStateFlow.update { it.copy(viewState = ViewSendState.ViewState.Loading) }
|
||||
composeTestRule.onNodeWithText(text = "file_name").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(text = "file_size").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(text = "send_name").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(text = "deletion_date").assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE.copy(
|
||||
sendType = ViewSendState.ViewState.Content.SendType.FileType(
|
||||
fileName = "file_name",
|
||||
fileSize = "file_size",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "share_link")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "file_name")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "file_size")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "send_name")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "deletion_date")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `additional options should reveal themselves when clicked`() {
|
||||
composeTestRule.onNodeWithText(text = "Maximum access count").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(text = "Current access count: 1").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(text = "Private notes").assertDoesNotExist()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Additional options")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Maximum access count")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Current access count: 1")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Private notes")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dialog should be displayed based on ViewState`() {
|
||||
composeTestRule.assertNoDialogExists()
|
||||
val errorMessage = "Fail!"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ViewSendState.DialogState.Error(
|
||||
title = null,
|
||||
message = errorMessage.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule
|
||||
.onNodeWithText(text = errorMessage)
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
val loadingMessage = "Loading!"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ViewSendState.DialogState.Loading(
|
||||
message = loadingMessage.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
composeTestRule
|
||||
.onNodeWithText(text = loadingMessage)
|
||||
.assert(hasAnyAncestor(isPopup()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_CONTENT_VIEW_STATE = ViewSendState.ViewState.Content(
|
||||
sendType = ViewSendState.ViewState.Content.SendType.TextType(
|
||||
textToShare = "text_to_share",
|
||||
),
|
||||
shareLink = "share_link",
|
||||
sendName = "send_name",
|
||||
deletionDate = "deletion_date",
|
||||
maxAccessCount = 1,
|
||||
currentAccessCount = 1,
|
||||
notes = "notes",
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE = ViewSendState(
|
||||
sendType = SendItemType.TEXT,
|
||||
sendId = "send_id",
|
||||
viewState = ViewSendState.ViewState.Content,
|
||||
viewState = DEFAULT_CONTENT_VIEW_STATE,
|
||||
dialogState = null,
|
||||
baseWebSendUrl = "https://send.bitwarden.com/#",
|
||||
)
|
||||
|
||||
@ -2,22 +2,56 @@ package com.x8bit.bitwarden.ui.tools.feature.send.viewsend
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.send.SendView
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.bitwarden.ui.util.concat
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.viewsend.util.toViewSendViewStateContent
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.runs
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class ViewSendViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val clipboardManager = mockk<BitwardenClipboardManager> {
|
||||
every { setText(text = any<String>()) } just runs
|
||||
}
|
||||
private val mutableSendStateFlow = MutableStateFlow<DataState<SendView?>>(DataState.Loading)
|
||||
private val vaultRepository = mockk<VaultRepository> {
|
||||
every { getSendStateFlow(sendId = any()) } returns mutableSendStateFlow
|
||||
}
|
||||
private val environmentRepository = mockk<EnvironmentRepository> {
|
||||
every { environment } returns Environment.Us
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(
|
||||
SavedStateHandle::toViewSendArgs,
|
||||
SendView::toViewSendViewStateContent,
|
||||
)
|
||||
}
|
||||
|
||||
@ -25,6 +59,7 @@ class ViewSendViewModelTest : BaseViewModelTest() {
|
||||
fun tearDown() {
|
||||
unmockkStatic(
|
||||
SavedStateHandle::toViewSendArgs,
|
||||
SendView::toViewSendViewStateContent,
|
||||
)
|
||||
}
|
||||
|
||||
@ -46,6 +81,111 @@ class ViewSendViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CopyClick should call setText on ClipboardManger`() {
|
||||
val viewModel = createViewModel()
|
||||
val sendView = createMockSendView(number = 1)
|
||||
every {
|
||||
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
|
||||
} returns DEFAULT_CONTENT_VIEW_STATE
|
||||
mutableSendStateFlow.value = DataState.Loaded(data = sendView)
|
||||
viewModel.trySendAction(ViewSendAction.CopyClick)
|
||||
verify(exactly = 1) {
|
||||
clipboardManager.setText(text = "share_link")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on DeleteClick with failure should display error dialog`() = runTest {
|
||||
val initialState = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE)
|
||||
val sendView = createMockSendView(number = 1)
|
||||
every {
|
||||
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
|
||||
} returns DEFAULT_CONTENT_VIEW_STATE
|
||||
val throwable = Throwable("Fail!")
|
||||
coEvery {
|
||||
vaultRepository.deleteSend(sendId = "send_id")
|
||||
} returns DeleteSendResult.Error(error = throwable)
|
||||
mutableSendStateFlow.value = DataState.Loaded(data = sendView)
|
||||
|
||||
val viewModel = createViewModel(state = initialState)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(initialState, awaitItem())
|
||||
viewModel.trySendAction(ViewSendAction.DeleteClick)
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialogState = ViewSendState.DialogState.Loading(
|
||||
message = R.string.deleting.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialogState = ViewSendState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.generic_error_message.asText(),
|
||||
throwable = throwable,
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on DeleteClick with success should display toast`() = runTest {
|
||||
val initialState = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE)
|
||||
val sendView = createMockSendView(number = 1)
|
||||
every {
|
||||
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
|
||||
} returns DEFAULT_CONTENT_VIEW_STATE
|
||||
coEvery { vaultRepository.deleteSend(sendId = "send_id") } returns DeleteSendResult.Success
|
||||
mutableSendStateFlow.value = DataState.Loaded(data = sendView)
|
||||
|
||||
val viewModel = createViewModel(state = initialState)
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFLow ->
|
||||
assertEquals(initialState, stateFlow.awaitItem())
|
||||
viewModel.trySendAction(ViewSendAction.DeleteClick)
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialogState = ViewSendState.DialogState.Loading(
|
||||
message = R.string.deleting.asText(),
|
||||
),
|
||||
),
|
||||
stateFlow.awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
initialState.copy(dialogState = null),
|
||||
stateFlow.awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
ViewSendEvent.ShowToast(message = R.string.send_deleted.asText()),
|
||||
eventFLow.awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
ViewSendEvent.NavigateBack,
|
||||
eventFLow.awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on DialogDismiss should send clear the dialogState`() = runTest {
|
||||
val initialState = DEFAULT_STATE.copy(
|
||||
dialogState = ViewSendState.DialogState.Loading(message = "Loading".asText()),
|
||||
)
|
||||
val viewModel = createViewModel(state = initialState)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(initialState, awaitItem())
|
||||
viewModel.trySendAction(ViewSendAction.DialogDismiss)
|
||||
assertEquals(initialState.copy(dialogState = null), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on EditClick should send NavigateToEdit`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
@ -61,10 +201,190 @@ class ViewSendViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on ShareClick should send ShareText`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val sendView = createMockSendView(number = 1)
|
||||
every {
|
||||
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
|
||||
} returns DEFAULT_CONTENT_VIEW_STATE
|
||||
mutableSendStateFlow.value = DataState.Loaded(data = sendView)
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ViewSendAction.ShareClick)
|
||||
assertEquals(
|
||||
ViewSendEvent.ShareText(text = "share_link".asText()),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on SendDataReceive with loading should set viewState to loading`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val sendView = createMockSendView(number = 1)
|
||||
every {
|
||||
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
|
||||
} returns DEFAULT_CONTENT_VIEW_STATE
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableSendStateFlow.value = DataState.Loading
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on SendDataReceive with error and no data should set viewState to error`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableSendStateFlow.value = DataState.Error(error = Throwable("Fail!"))
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = ViewSendState.ViewState.Error(
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on SendDataReceive with error and data should set viewState to content`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val sendView = createMockSendView(number = 1)
|
||||
every {
|
||||
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
|
||||
} returns DEFAULT_CONTENT_VIEW_STATE
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableSendStateFlow.value = DataState.Error(
|
||||
error = Throwable("Fail!"),
|
||||
data = sendView,
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on SendDataReceive with no network and no data should set viewState to error`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableSendStateFlow.value = DataState.NoNetwork()
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = ViewSendState.ViewState.Error(
|
||||
message = R.string.internet_connection_required_title
|
||||
.asText()
|
||||
.concat(
|
||||
" ".asText(),
|
||||
R.string.internet_connection_required_message.asText(),
|
||||
),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on SendDataReceive with no network and data should set viewState to content`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val sendView = createMockSendView(number = 1)
|
||||
every {
|
||||
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
|
||||
} returns DEFAULT_CONTENT_VIEW_STATE
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableSendStateFlow.value = DataState.NoNetwork(data = sendView)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on SendDataReceive with pending and no data should set viewState to error`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableSendStateFlow.value = DataState.Pending(data = null)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = ViewSendState.ViewState.Error(
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on SendDataReceive with pending and data should set viewState to content`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val sendView = createMockSendView(number = 1)
|
||||
every {
|
||||
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
|
||||
} returns DEFAULT_CONTENT_VIEW_STATE
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableSendStateFlow.value = DataState.Pending(data = sendView)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on SendDataReceive with loaded and no data should set viewState to error`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableSendStateFlow.value = DataState.Loaded(data = null)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
viewState = ViewSendState.ViewState.Error(
|
||||
message = R.string.missing_send_resync_your_vault.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on SendDataReceive with loaded and data should set viewState to content`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val sendView = createMockSendView(number = 1)
|
||||
every {
|
||||
sendView.toViewSendViewStateContent(baseWebSendUrl = any(), clock = FIXED_CLOCK)
|
||||
} returns DEFAULT_CONTENT_VIEW_STATE
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
mutableSendStateFlow.value = DataState.Loaded(data = sendView)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
state: ViewSendState? = null,
|
||||
): ViewSendViewModel = ViewSendViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply {
|
||||
clipboardManager = clipboardManager,
|
||||
clock = FIXED_CLOCK,
|
||||
vaultRepository = vaultRepository,
|
||||
environmentRepository = environmentRepository,
|
||||
savedStateHandle = SavedStateHandle().apply
|
||||
{
|
||||
set(key = "state", value = state)
|
||||
every { toViewSendArgs() } returns ViewSendArgs(
|
||||
sendId = (state ?: DEFAULT_STATE).sendId,
|
||||
@ -74,8 +394,27 @@ class ViewSendViewModelTest : BaseViewModelTest() {
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_CONTENT_VIEW_STATE = ViewSendState.ViewState.Content(
|
||||
sendType = ViewSendState.ViewState.Content.SendType.TextType(
|
||||
textToShare = "text_to_share",
|
||||
),
|
||||
shareLink = "share_link",
|
||||
sendName = "send_name",
|
||||
deletionDate = "deletion_date",
|
||||
maxAccessCount = 1,
|
||||
currentAccessCount = 1,
|
||||
notes = "notes",
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE = ViewSendState(
|
||||
sendType = SendItemType.TEXT,
|
||||
sendId = "send_id",
|
||||
viewState = ViewSendState.ViewState.Content,
|
||||
viewState = ViewSendState.ViewState.Loading,
|
||||
dialogState = null,
|
||||
baseWebSendUrl = "https://send.bitwarden.com/#",
|
||||
)
|
||||
|
||||
private val FIXED_CLOCK: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
package com.x8bit.bitwarden.ui.tools.feature.send.viewsend.util
|
||||
|
||||
import com.bitwarden.send.SendType
|
||||
import com.bitwarden.send.SendView
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.viewsend.ViewSendState
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
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.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class SendViewExtensionsTest {
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(SendView::toSendUrl)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(SendView::toSendUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toViewSendViewStateContent should create content with file data`() {
|
||||
val fileSendView = createMockSendView(number = 1, type = SendType.FILE)
|
||||
val baseWebSendUrl = "https://send.bitwarden.com/#"
|
||||
val sendUrl = "send_url"
|
||||
every { fileSendView.toSendUrl(baseWebSendUrl = baseWebSendUrl) } returns sendUrl
|
||||
|
||||
val result = fileSendView.toViewSendViewStateContent(
|
||||
baseWebSendUrl = baseWebSendUrl,
|
||||
clock = FIXED_CLOCK,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
ViewSendState.ViewState.Content(
|
||||
sendType = ViewSendState.ViewState.Content.SendType.FileType(
|
||||
fileName = "mockFileName-1",
|
||||
fileSize = "mockSizeName-1",
|
||||
),
|
||||
shareLink = sendUrl,
|
||||
sendName = "mockName-1",
|
||||
deletionDate = "27 Oct, 2023, 12:00PM",
|
||||
maxAccessCount = 1,
|
||||
currentAccessCount = 1,
|
||||
notes = "mockNotes-1",
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toViewSendViewStateContent should create content with text data`() {
|
||||
val fileSendView = createMockSendView(number = 2, type = SendType.TEXT)
|
||||
val baseWebSendUrl = "https://send.bitwarden.com/#"
|
||||
val sendUrl = "send_url"
|
||||
every { fileSendView.toSendUrl(baseWebSendUrl = baseWebSendUrl) } returns sendUrl
|
||||
|
||||
val result = fileSendView.toViewSendViewStateContent(
|
||||
baseWebSendUrl = baseWebSendUrl,
|
||||
clock = FIXED_CLOCK,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
ViewSendState.ViewState.Content(
|
||||
sendType = ViewSendState.ViewState.Content.SendType.TextType(
|
||||
textToShare = "mockText-2",
|
||||
),
|
||||
shareLink = sendUrl,
|
||||
sendName = "mockName-2",
|
||||
deletionDate = "27 Oct, 2023, 12:00PM",
|
||||
maxAccessCount = 1,
|
||||
currentAccessCount = 1,
|
||||
notes = "mockNotes-2",
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val FIXED_CLOCK: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user