BIT-479 Implement new send UI (#215)

This commit is contained in:
Andrew Haisting 2023-11-07 11:19:30 -06:00 committed by Álison Fernandes
parent a9295ff981
commit aeb5ff3734
21 changed files with 1447 additions and 14 deletions

View file

@ -16,19 +16,26 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* An icon button that displays an icon from the provided [IconResource].
*
* @param iconRes Icon to display on the button.
* @param onClick Callback for when the icon button is clicked.
* @param isEnabled Whether or not the button should be enabled.
*/
@Composable
fun BitwardenIconButtonWithResource(iconRes: IconResource, onClick: () -> Unit) {
fun BitwardenIconButtonWithResource(
iconRes: IconResource,
onClick: () -> Unit,
isEnabled: Boolean = true,
) {
FilledIconButton(
modifier = Modifier.semantics(mergeDescendants = true) {},
onClick = onClick,
colors = IconButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer,
disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .12f),
disabledContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
enabled = isEnabled,
) {
Icon(
painter = iconRes.iconPainter,

View file

@ -0,0 +1,47 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MultiChoiceSegmentedButtonRow
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Displays a Bitwarden styled row of segmented buttons.
*
* @param options List of options to display.
* @param modifier Modifier.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitwardenSegmentedButton(
modifier: Modifier = Modifier,
options: List<SegmentedButtonState>,
) {
MultiChoiceSegmentedButtonRow(
modifier = modifier,
) {
options.forEachIndexed { index, option ->
SegmentedButton(
checked = option.isChecked,
onCheckedChange = { option.onClick() },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = options.size,
),
label = { Text(text = option.text) },
)
}
}
}
/**
* Models state for an individual button in a [BitwardenSegmentedButton].
*/
data class SegmentedButtonState(
val text: String,
val onClick: () -> Unit,
val isChecked: Boolean,
)

View file

@ -6,6 +6,8 @@ import androidx.navigation.NavOptions
import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination
import com.x8bit.bitwarden.ui.tools.feature.send.navigateToNewSend
import com.x8bit.bitwarden.ui.tools.feature.send.newSendDestination
import com.x8bit.bitwarden.ui.vault.feature.vault.navigateToVaultAddItem
import com.x8bit.bitwarden.ui.vault.feature.vault.vaultAddItemDestination
@ -30,7 +32,9 @@ fun NavGraphBuilder.vaultUnlockedGraph(
) {
vaultUnlockedNavBarDestination(
onNavigateToVaultAddItem = { navController.navigateToVaultAddItem() },
onNavigateToNewSend = { navController.navigateToNewSend() },
)
vaultAddItemDestination(onNavigateBack = { navController.popBackStack() })
newSendDestination(onNavigateBack = { navController.popBackStack() })
}
}

View file

@ -23,6 +23,7 @@ fun NavController.navigateToVaultUnlockedNavBar(navOptions: NavOptions? = null)
*/
fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToVaultAddItem: () -> Unit,
onNavigateToNewSend: () -> Unit,
) {
composable(
route = VAULT_UNLOCKED_NAV_BAR_ROUTE,
@ -31,6 +32,9 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
popEnterTransition = TransitionProviders.Enter.stay,
popExitTransition = TransitionProviders.Exit.stay,
) {
VaultUnlockedNavBarScreen(onNavigateToVaultAddItem = onNavigateToVaultAddItem)
VaultUnlockedNavBarScreen(
onNavigateToVaultAddItem = onNavigateToVaultAddItem,
onNavigateToNewSend = onNavigateToNewSend,
)
}
}

View file

@ -56,6 +56,7 @@ fun VaultUnlockedNavBarScreen(
viewModel: VaultUnlockedNavBarViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(),
onNavigateToVaultAddItem: () -> Unit,
onNavigateToNewSend: () -> Unit,
) {
EventsEffect(viewModel = viewModel) { event ->
navController.apply {
@ -82,6 +83,7 @@ fun VaultUnlockedNavBarScreen(
VaultUnlockedNavBarScaffold(
navController = navController,
navigateToVaultAddItem = onNavigateToVaultAddItem,
navigateToNewSend = onNavigateToNewSend,
generatorTabClickedAction = {
viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick)
},
@ -109,6 +111,7 @@ private fun VaultUnlockedNavBarScaffold(
generatorTabClickedAction: () -> Unit,
settingsTabClickedAction: () -> Unit,
navigateToVaultAddItem: () -> Unit,
navigateToNewSend: () -> Unit,
) {
// This scaffold will host screens that contain top bars while not hosting one itself.
// We need to ignore the status bar insets here and let the content screens handle
@ -193,7 +196,7 @@ private fun VaultUnlockedNavBarScaffold(
navigateToVaultAddItem()
},
)
sendGraph()
sendGraph(onNavigateToNewSend = navigateToNewSend)
generatorDestination()
settingsGraph(navController)
}

View file

@ -0,0 +1,33 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.x8bit.bitwarden.ui.platform.theme.TransitionProviders
private const val NEW_SEND_ROUTE = "new_send"
/**
* Add the new send screen to the nav graph.
*/
fun NavGraphBuilder.newSendDestination(
onNavigateBack: () -> Unit,
) {
composable(
route = NEW_SEND_ROUTE,
enterTransition = TransitionProviders.Enter.slideUp,
exitTransition = TransitionProviders.Exit.slideDown,
popEnterTransition = TransitionProviders.Enter.slideUp,
popExitTransition = TransitionProviders.Exit.slideDown,
) {
NewSendScreen(onNavigateBack = onNavigateBack)
}
}
/**
* Navigate to the new send screen.
*/
fun NavController.navigateToNewSend(navOptions: NavOptions? = null) {
navigate(NEW_SEND_ROUTE, navOptions)
}

View file

@ -0,0 +1,360 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
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.components.BitwardenFilledTonalButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenIconButtonWithResource
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenReadOnlyTextFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenSegmentedButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.components.SegmentedButtonState
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.ui.tools.feature.send.NewSendAction.MaxAccessCountChange
/**
* Displays new send UX.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewSendScreen(
viewModel: NewSendViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is NewSendEvent.NavigateBack -> onNavigateBack()
is NewSendEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
}
}
Scaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.add_send),
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.CloseClick) }
},
scrollBehavior = scrollBehavior,
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.save),
onClick = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.SaveClick) }
},
)
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(paddingValues = innerPadding)
.imePadding(),
) {
BitwardenTextField(
modifier = Modifier.padding(horizontal = 16.dp),
label = stringResource(id = R.string.name),
hint = stringResource(id = R.string.name_info),
value = state.name,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.NameChange(it)) }
},
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.type),
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenSegmentedButton(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
options = listOf(
SegmentedButtonState(
text = stringResource(id = R.string.file),
onClick = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.FileTypeClick) }
},
isChecked = state.selectedType is NewSendState.SendType.File,
),
SegmentedButtonState(
text = stringResource(id = R.string.text),
onClick = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.TextTypeClick) }
},
isChecked = state.selectedType is NewSendState.SendType.Text,
),
),
)
Spacer(modifier = Modifier.height(16.dp))
when (val type = state.selectedType) {
is NewSendState.SendType.File -> {
BitwardenListHeaderText(
label = stringResource(id = R.string.file),
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = stringResource(id = R.string.no_file_chosen),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenFilledTonalButton(
label = stringResource(id = R.string.choose_file),
onClick = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.ChooseFileClick) }
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(id = R.string.max_file_size),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 32.dp),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.type_file_info),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
is NewSendState.SendType.Text -> {
BitwardenTextField(
modifier = Modifier.padding(horizontal = 16.dp),
label = stringResource(id = R.string.text),
hint = stringResource(id = R.string.type_text_info),
value = type.input,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.TextChange(it)) }
},
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenWideSwitch(
modifier = Modifier.padding(horizontal = 16.dp),
label = stringResource(id = R.string.hide_text_by_default),
isChecked = type.isHideByDefaultChecked,
onCheckedChange = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.HideByDefaultToggle(it)) }
},
)
}
}
Spacer(modifier = Modifier.height(16.dp))
NewSendOptions(
state = state,
onIncrementMaxAccessCountClick = remember(viewModel) {
{ viewModel.trySendAction(MaxAccessCountChange(it)) }
},
onDecrementMaxAccessCountClick = remember(viewModel) {
{ viewModel.trySendAction(MaxAccessCountChange(it)) }
},
onPasswordChange = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.PasswordChange(it)) }
},
onNoteChange = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.NoteChange(it)) }
},
onHideEmailChecked = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.HideMyEmailToggle(it)) }
},
onDeactivateSendChecked = remember(viewModel) {
{ viewModel.trySendAction(NewSendAction.DeactivateThisSendToggle(it)) }
},
)
}
}
}
/**
* Displays a collapsable set of new send options.
*
* @param state state.
* @param onIncrementMaxAccessCountClick called when increment max access count is clicked.
* @param onDecrementMaxAccessCountClick called when decrement max access count is clicked.
* @param onPasswordChange called when the password changes.
* @param onNoteChange called when the notes changes.
* @param onHideEmailChecked called when hide email is checked.
* @param onDeactivateSendChecked called when deactivate send is checked.
*/
@Suppress("LongMethod")
@Composable
private fun NewSendOptions(
state: NewSendState,
onIncrementMaxAccessCountClick: (Int) -> Unit,
onDecrementMaxAccessCountClick: (Int) -> Unit,
onPasswordChange: (String) -> Unit,
onNoteChange: (String) -> Unit,
onHideEmailChecked: (Boolean) -> Unit,
onDeactivateSendChecked: (Boolean) -> Unit,
) {
var isExpanded by rememberSaveable { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
onClickLabel = if (isExpanded) {
stringResource(id = R.string.options_expanded)
} else {
stringResource(id = R.string.options_collapsed)
},
onClick = { isExpanded = !isExpanded },
)
.padding(16.dp)
.semantics(mergeDescendants = true) {},
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(id = R.string.options),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(end = 8.dp),
)
Icon(
painter = if (isExpanded) {
painterResource(R.drawable.ic_expand_up)
} else {
painterResource(R.drawable.ic_expand_down)
},
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
// Hide all content if not expanded:
if (!isExpanded) {
return
}
SendExpirationDateChooser(
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenReadOnlyTextFieldWithActions(
label = stringResource(R.string.maximum_access_count),
// we use a space instead of empty string to make sure label is shown small and above
// the input
value = state.maxAccessCount?.toString() ?: " ",
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_minus),
contentDescription = "\u2212",
),
onClick = {
onIncrementMaxAccessCountClick.invoke((state.maxAccessCount ?: 0) - 1)
},
isEnabled = state.maxAccessCount != null,
)
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_plus),
contentDescription = "+",
),
onClick = {
onDecrementMaxAccessCountClick.invoke((state.maxAccessCount ?: 0) + 1)
},
)
},
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(id = R.string.maximum_access_count_info),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.new_password),
hint = stringResource(id = R.string.password_info),
value = state.passwordInput,
onValueChange = onPasswordChange,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(id = R.string.notes),
hint = stringResource(id = R.string.notes_info),
value = state.noteInput,
onValueChange = onNoteChange,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenWideSwitch(
modifier = Modifier.padding(horizontal = 16.dp),
label = stringResource(id = R.string.hide_email),
isChecked = state.isHideEmailChecked,
onCheckedChange = onHideEmailChecked,
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenWideSwitch(
modifier = Modifier.padding(horizontal = 16.dp),
label = stringResource(id = R.string.disable_send),
isChecked = state.isDeactivateChecked,
onCheckedChange = onDeactivateSendChecked,
)
}

View file

@ -0,0 +1,255 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
import kotlin.math.max
private const val KEY_STATE = "state"
/**
* View model for the new send screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class NewSendViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<NewSendState, NewSendEvent, NewSendAction>(
initialState = savedStateHandle[KEY_STATE] ?: NewSendState(
name = "",
maxAccessCount = null,
passwordInput = "",
noteInput = "",
isHideEmailChecked = false,
isDeactivateChecked = false,
selectedType = NewSendState.SendType.Text(
input = "",
isHideByDefaultChecked = false,
),
),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: NewSendAction): Unit = when (action) {
is NewSendAction.CloseClick -> handleCloseClick()
is NewSendAction.SaveClick -> handleSaveClick()
is NewSendAction.FileTypeClick -> handleFileTypeClick()
is NewSendAction.TextTypeClick -> handleTextTypeClick()
is NewSendAction.ChooseFileClick -> handleChooseFileClick()
is NewSendAction.NameChange -> handleNameChange(action)
is NewSendAction.MaxAccessCountChange -> handleMaxAccessCountChange(action)
is NewSendAction.TextChange -> handleTextChange(action)
is NewSendAction.NoteChange -> handleNoteChange(action)
is NewSendAction.PasswordChange -> handlePasswordChange(action)
is NewSendAction.HideByDefaultToggle -> handleHideByDefaultToggle(action)
is NewSendAction.DeactivateThisSendToggle -> handleDeactivateThisSendToggle(action)
is NewSendAction.HideMyEmailToggle -> handleHideMyEmailToggle(action)
}
private fun handlePasswordChange(action: NewSendAction.PasswordChange) {
mutableStateFlow.update {
it.copy(passwordInput = action.input)
}
}
private fun handleNoteChange(action: NewSendAction.NoteChange) {
mutableStateFlow.update {
it.copy(noteInput = action.input)
}
}
private fun handleHideMyEmailToggle(action: NewSendAction.HideMyEmailToggle) {
mutableStateFlow.update {
it.copy(isHideEmailChecked = action.isChecked)
}
}
private fun handleDeactivateThisSendToggle(action: NewSendAction.DeactivateThisSendToggle) {
mutableStateFlow.update {
it.copy(isDeactivateChecked = action.isChecked)
}
}
private fun handleCloseClick() = sendEvent(NewSendEvent.NavigateBack)
private fun handleSaveClick() = sendEvent(NewSendEvent.ShowToast("Save Not Implemented"))
private fun handleNameChange(action: NewSendAction.NameChange) {
mutableStateFlow.update {
it.copy(name = action.input)
}
}
private fun handleFileTypeClick() {
mutableStateFlow.update {
it.copy(selectedType = NewSendState.SendType.File)
}
}
private fun handleTextTypeClick() {
mutableStateFlow.update {
it.copy(selectedType = NewSendState.SendType.Text("", isHideByDefaultChecked = false))
}
}
private fun handleTextChange(action: NewSendAction.TextChange) {
val currentSendInput =
mutableStateFlow.value.selectedType as? NewSendState.SendType.Text ?: return
mutableStateFlow.update {
it.copy(selectedType = currentSendInput.copy(input = action.input))
}
}
private fun handleHideByDefaultToggle(action: NewSendAction.HideByDefaultToggle) {
val currentSendInput =
mutableStateFlow.value.selectedType as? NewSendState.SendType.Text ?: return
mutableStateFlow.update {
it.copy(selectedType = currentSendInput.copy(isHideByDefaultChecked = action.isChecked))
}
}
private fun handleChooseFileClick() {
// TODO: allow for file upload: BIT-1085
sendEvent(NewSendEvent.ShowToast("Not Implemented: File Upload"))
}
private fun handleMaxAccessCountChange(action: NewSendAction.MaxAccessCountChange) {
mutableStateFlow.update {
it.copy(maxAccessCount = max(1, action.newValue))
}
}
}
/**
* Models state for the new send screen.
*/
@Parcelize
data class NewSendState(
val name: String,
val selectedType: SendType,
// Null here means "not set"
val maxAccessCount: Int?,
val passwordInput: String,
val noteInput: String,
val isHideEmailChecked: Boolean,
val isDeactivateChecked: Boolean,
) : Parcelable {
/**
* Models what type the user is trying to send.
*/
sealed class SendType : Parcelable {
/**
* Sending a file.
*/
@Parcelize
data object File : SendType()
/**
* Sending text.
*/
@Parcelize
data class Text(
val input: String,
val isHideByDefaultChecked: Boolean,
) : SendType()
}
}
/**
* Models events for the new send screen.
*/
sealed class NewSendEvent {
/**
* Navigate back.
*/
data object NavigateBack : NewSendEvent()
/**
* Show Toast.
*/
data class ShowToast(val message: String) : NewSendEvent()
}
/**
* Models actions for the new send screen.
*/
sealed class NewSendAction {
/**
* User clicked the close button.
*/
data object CloseClick : NewSendAction()
/**
* User clicked the save button.
*/
data object SaveClick : NewSendAction()
/**
* Value of the name field was updated.
*/
data class NameChange(val input: String) : NewSendAction()
/**
* User clicked the file type segmented button.
*/
data object FileTypeClick : NewSendAction()
/**
* User clicked the text type segmented button.
*/
data object TextTypeClick : NewSendAction()
/**
* Value of the send text field updated.
*/
data class TextChange(val input: String) : NewSendAction()
/**
* Value of the password field updated.
*/
data class PasswordChange(val input: String) : NewSendAction()
/**
* Value of the note text field updated.
*/
data class NoteChange(val input: String) : NewSendAction()
/**
* User clicked the choose file button.
*/
data object ChooseFileClick : NewSendAction()
/**
* User toggled the "hide text by default" toggle.
*/
data class HideByDefaultToggle(val isChecked: Boolean) : NewSendAction()
/**
* User incremented or decremented the max access count.
*/
data class MaxAccessCountChange(val newValue: Int) : NewSendAction()
/**
* User toggled the "hide my email" toggle.
*/
data class HideMyEmailToggle(val isChecked: Boolean) : NewSendAction()
/**
* User toggled the "deactivate this send" toggle.
*/
data class DeactivateThisSendToggle(val isChecked: Boolean) : NewSendAction()
}

View file

@ -0,0 +1,59 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton
/**
* Displays UX for choosing expiration date of a send.
*
* TODO: Implement custom date choosing and send choices to the VM: BIT-1090.
*/
@Composable
fun SendExpirationDateChooser(
modifier: Modifier = Modifier,
) {
val options = listOf(
stringResource(id = R.string.one_day),
stringResource(id = R.string.two_days),
stringResource(id = R.string.three_days),
stringResource(id = R.string.seven_days),
stringResource(id = R.string.thirty_days),
stringResource(id = R.string.custom),
)
val defaultOption = stringResource(id = R.string.seven_days)
var selectedOption: String by rememberSaveable { mutableStateOf(defaultOption) }
Column(
modifier = modifier,
) {
BitwardenMultiSelectButton(
label = stringResource(id = R.string.deletion_date),
options = options,
selectedOption = selectedOption,
onOptionSelected = { selectedOption = it },
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(id = R.string.deletion_date_info),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}

View file

@ -10,12 +10,14 @@ const val SEND_GRAPH_ROUTE: String = "send_graph"
/**
* Add send destination to the nav graph.
*/
fun NavGraphBuilder.sendGraph() {
fun NavGraphBuilder.sendGraph(
onNavigateToNewSend: () -> Unit,
) {
navigation(
startDestination = SEND_ROUTE,
route = SEND_GRAPH_ROUTE,
) {
sendDestination()
sendDestination(onNavigateToNewSend = onNavigateToNewSend)
}
}

View file

@ -11,7 +11,9 @@ const val SEND_ROUTE: String = "send"
/**
* Add send destination to the nav graph.
*/
fun NavGraphBuilder.sendDestination() {
fun NavGraphBuilder.sendDestination(
onNavigateToNewSend: () -> Unit,
) {
composable(
route = SEND_ROUTE,
enterTransition = TransitionProviders.Enter.stay,
@ -19,7 +21,9 @@ fun NavGraphBuilder.sendDestination() {
popEnterTransition = TransitionProviders.Enter.pushRight,
popExitTransition = TransitionProviders.Exit.fadeOut,
) {
SendScreen()
SendScreen(
onNavigateNewSend = onNavigateToNewSend,
)
}
}

View file

@ -34,6 +34,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SendScreen(
onNavigateNewSend: () -> Unit,
viewModel: SendViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@ -42,6 +43,7 @@ fun SendScreen(
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is SendEvent.NavigateNewSend -> onNavigateNewSend()
is SendEvent.ShowToast -> Toast
.makeText(context, event.messsage(context.resources), Toast.LENGTH_SHORT)
.show()

View file

@ -40,10 +40,7 @@ class SendViewModel @Inject constructor(
sendEvent(SendEvent.ShowToast("Search Not Implemented".asText()))
}
private fun handleSendClick() {
// TODO: navigate to new send UI BIT-479
sendEvent(SendEvent.ShowToast("New Send Not Implemented".asText()))
}
private fun handleSendClick() = sendEvent(SendEvent.NavigateNewSend)
}
/**
@ -76,6 +73,11 @@ sealed class SendAction {
* Models events for the send screen.
*/
sealed class SendEvent {
/**
* Navigate to the new send screen.
*/
data object NavigateNewSend : SendEvent()
/**
* Show a toast to the user.
*/

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<group>
<clip-path
android:pathData="M0.5,0.5h15v15h-15z"/>
<path
android:pathData="M7.677,10.585C7.961,10.874 8.084,10.823 8.361,10.538C8.361,10.538 14.286,4.843 14.704,4.456C15.122,4.068 15.892,4.635 15.262,5.215C14.632,5.794 8.835,11.382 8.835,11.382C8.335,11.788 7.655,11.788 7.155,11.382L0.808,5.295C0.105,4.656 0.74,3.945 1.356,4.533C3.725,6.798 7.677,10.585 7.677,10.585Z"
android:fillColor="#175DDC"/>
</group>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<group>
<clip-path
android:pathData="M0.5,0.5h15v15h-15z"/>
<path
android:pathData="M8.323,5.415C8.039,5.126 7.916,5.177 7.639,5.462C7.639,5.462 1.714,11.157 1.296,11.545C0.878,11.932 0.108,11.365 0.738,10.785C1.367,10.206 7.165,4.618 7.165,4.618C7.665,4.212 8.345,4.212 8.845,4.618L15.192,10.705C15.895,11.344 15.26,12.055 14.644,11.467C12.274,9.202 8.323,5.415 8.323,5.415Z"
android:fillColor="#175DDC"/>
</group>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="19dp"
android:height="18dp"
android:viewportWidth="19"
android:viewportHeight="18">
<path
android:pathData="M15.219,3.458C15.432,3.604 15.486,3.896 15.339,4.109L9.016,13.303C8.707,13.752 8.082,13.844 7.657,13.503L4.253,10.772C4.052,10.61 4.019,10.315 4.181,10.113C4.343,9.911 4.638,9.879 4.84,10.041L8.244,12.772L14.567,3.578C14.714,3.365 15.005,3.311 15.219,3.458Z"
android:fillColor="#151B2C"
android:fillType="evenOdd"/>
</vector>

View file

@ -33,6 +33,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToNewSend = {},
)
}
onNodeWithText("My vault").performClick()
@ -54,6 +55,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToNewSend = {},
)
}
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
@ -76,6 +78,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToNewSend = {},
)
}
onNodeWithText("Send").performClick()
@ -97,6 +100,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToNewSend = {},
)
}
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
@ -119,6 +123,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToNewSend = {},
)
}
onNodeWithText("Generator").performClick()
@ -140,6 +145,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToNewSend = {},
)
}
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
@ -162,6 +168,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToNewSend = {},
)
}
onNodeWithText("Settings").performClick()
@ -183,6 +190,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
viewModel = viewModel,
navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
onNavigateToNewSend = {},
)
}
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }

View file

@ -0,0 +1,422 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.onAllNodesWithText
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 androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
class NewSendScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = MutableSharedFlow<NewSendEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<NewSendViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
composeTestRule.setContent {
NewSendScreen(
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
)
}
}
@Test
fun `on NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(NewSendEvent.NavigateBack)
assert(onNavigateBackCalled)
}
@Test
fun `on close icon click should send CloseClick`() {
composeTestRule
.onNodeWithContentDescription("Close")
.performClick()
verify { viewModel.trySendAction(NewSendAction.CloseClick) }
}
@Test
fun `on save click should send SaveClick`() {
composeTestRule
.onNodeWithText("Save")
.performClick()
verify { viewModel.trySendAction(NewSendAction.SaveClick) }
}
@Test
fun `on name input change should send NameChange`() {
composeTestRule
.onNodeWithText("Name")
.performTextInput("input")
verify { viewModel.trySendAction(NewSendAction.NameChange("input")) }
}
@Test
fun `name input should change according to the state`() {
composeTestRule
.onNodeWithText("Name")
.assertTextEquals("Name", "")
mutableStateFlow.update {
it.copy(name = "input")
}
composeTestRule
.onNodeWithText("Name")
.assertTextEquals("Name", "input")
}
@Test
fun `File segmented button click should send FileTypeClick`() {
composeTestRule
.onNodeWithText("File")
.performClick()
verify { viewModel.trySendAction(NewSendAction.FileTypeClick) }
}
@Test
fun `Text segmented button click should send TextTypeClick`() {
composeTestRule
.onAllNodesWithText("Text")[0]
.performClick()
verify { viewModel.trySendAction(NewSendAction.TextTypeClick) }
}
@Test
fun `Choose file button click should send ChooseFileClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
selectedType = NewSendState.SendType.File,
)
composeTestRule
.onNodeWithText("Choose file")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(NewSendAction.ChooseFileClick) }
}
@Test
fun `text input change should send TextChange`() {
composeTestRule
.onAllNodesWithText("Text")[1]
.performTextInput("input")
viewModel.trySendAction(NewSendAction.TextChange("input"))
}
@Test
fun `text input should change according to the state`() {
composeTestRule
.onAllNodesWithText("Text")
.filterToOne(hasSetTextAction())
.assertTextEquals("Text", "")
mutableStateFlow.update {
it.copy(
selectedType = NewSendState.SendType.Text(
input = "input",
isHideByDefaultChecked = false,
),
)
}
composeTestRule
.onAllNodesWithText("Text")
.filterToOne(hasSetTextAction())
.assertTextEquals("Text", "input")
}
@Test
fun `hide by default toggle should send HideByDefaultToggle`() {
composeTestRule
.onNodeWithText(text = "When accessing the Send", substring = true)
.performClick()
viewModel.trySendAction(NewSendAction.HideByDefaultToggle(true))
}
@Test
fun `hide text toggle should change according to the state`() {
composeTestRule
.onNodeWithText("When accessing the Send,", substring = true)
.assertIsOff()
mutableStateFlow.update {
it.copy(
selectedType = NewSendState.SendType.Text(
input = "",
isHideByDefaultChecked = true,
),
)
}
composeTestRule
.onNodeWithText("When accessing the Send,", substring = true)
.assertIsOn()
}
@Test
fun `options sections should start hidden and show after options clicked`() {
composeTestRule
.onNodeWithText("Deletion date")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("Maximum access count")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("New password")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("Notes")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("Hide my email address from recipients")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("Deactivate this Send", substring = true)
.assertDoesNotExist()
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Deletion date", useUnmergedTree = true)
.assertExists()
composeTestRule
.onNodeWithText("Maximum access count")
.assertExists()
composeTestRule
.onNodeWithText("New password")
.assertExists()
composeTestRule
.onNodeWithText("Notes")
.assertExists()
composeTestRule
.onNodeWithText("Hide my email address from recipients")
.assertExists()
composeTestRule
.onNodeWithText("Deactivate this Send", substring = true)
.assertExists()
}
@Test
fun `max access count decrement should be disabled when max access count is null`() = runTest {
// Expand options section:
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithContentDescription("\u2212")
.performScrollTo()
.performClick()
}
@Test
fun `max access count decrement should send MaxAccessCountChange`() = runTest {
mutableStateFlow.update {
it.copy(maxAccessCount = 3)
}
// Expand options section:
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithContentDescription("\u2212")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(NewSendAction.MaxAccessCountChange(2)) }
}
@Test
fun `on max access count increment should send MaxAccessCountChange`() = runTest {
// Expand options section:
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithContentDescription("+")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(NewSendAction.MaxAccessCountChange(1)) }
}
@Test
fun `on password input change should send PasswordChange`() = runTest {
// Expand options section:
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("New password")
.performTextInput("input")
verify { viewModel.trySendAction(NewSendAction.PasswordChange("input")) }
}
@Test
fun `password input should change according to the state`() {
// Expand options section:
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("New password")
.assertTextEquals("New password", "")
mutableStateFlow.update {
it.copy(
passwordInput = "input",
)
}
composeTestRule
.onNodeWithText("New password")
.assertTextEquals("New password", "•••••")
}
@Test
fun `on notes input change should send NoteChange`() = runTest {
// Expand options section:
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Notes")
.performTextInput("input")
verify { viewModel.trySendAction(NewSendAction.NoteChange("input")) }
}
@Test
fun `note input should change according to the state`() {
// Expand options section:
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Notes")
.assertTextEquals("Notes", "")
mutableStateFlow.update {
it.copy(
noteInput = "input",
)
}
composeTestRule
.onNodeWithText("Notes")
.assertTextEquals("Notes", "input")
}
@Test
fun `on hide email toggle should send HideMyEmailToggle`() = runTest {
// Expand options section:
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Hide my email address", substring = true)
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(NewSendAction.HideMyEmailToggle(true)) }
}
@Test
fun `hide email toggle should change according to the state`() {
// Expand options section:
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Hide my email", substring = true)
.assertIsOff()
mutableStateFlow.update {
it.copy(
isHideEmailChecked = true,
)
}
composeTestRule
.onNodeWithText("Hide my email", substring = true)
.assertIsOn()
}
@Test
fun `on deactivate this send toggle should send DeactivateThisSendToggle`() = runTest {
// Expand options section:
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Deactivate this Send", substring = true)
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(NewSendAction.DeactivateThisSendToggle(true)) }
}
@Test
fun `deactivate send toggle should change according to the state`() {
// Expand options section:
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Deactivate this Send", substring = true)
.assertIsOff()
mutableStateFlow.update {
it.copy(
isDeactivateChecked = true,
)
}
composeTestRule
.onNodeWithText("Deactivate this Send", substring = true)
.assertIsOn()
}
companion object {
private val DEFAULT_STATE = NewSendState(
name = "",
maxAccessCount = null,
passwordInput = "",
noteInput = "",
isHideEmailChecked = false,
isDeactivateChecked = false,
selectedType = NewSendState.SendType.Text(
input = "",
isHideByDefaultChecked = false,
),
)
}
}

View file

@ -0,0 +1,177 @@
package com.x8bit.bitwarden.ui.tools.feature.send
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class NewSendViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should read from saved state when present`() {
val savedState = mockk<NewSendState>()
val viewModel = createViewModel(
savedStateHandle = SavedStateHandle(mapOf("state" to savedState)),
)
assertEquals(savedState, viewModel.stateFlow.value)
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(NewSendAction.CloseClick)
assertEquals(NewSendEvent.NavigateBack, awaitItem())
}
}
@Test
fun `SaveClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(NewSendAction.SaveClick)
assertEquals(NewSendEvent.ShowToast("Save Not Implemented"), awaitItem())
}
}
@Test
fun `ChooseFileClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(NewSendAction.ChooseFileClick)
assertEquals(NewSendEvent.ShowToast("Not Implemented: File Upload"), awaitItem())
}
}
@Test
fun `FileTypeClick and TextTypeClick should toggle sendType`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(NewSendAction.FileTypeClick)
assertEquals(
DEFAULT_STATE.copy(selectedType = NewSendState.SendType.File),
awaitItem(),
)
viewModel.trySendAction(NewSendAction.TextTypeClick)
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Test
fun `NameChange should update name input`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(NewSendAction.NameChange("input"))
assertEquals(DEFAULT_STATE.copy(name = "input"), awaitItem())
}
}
@Test
fun `MaxAccessCountChange should update maxAccessCount`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(NewSendAction.MaxAccessCountChange(5))
assertEquals(DEFAULT_STATE.copy(maxAccessCount = 5), awaitItem())
}
}
@Test
fun `MaxAccessCountChang below 1 should keep maxAccessCount at 1`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(NewSendAction.MaxAccessCountChange(0))
assertEquals(DEFAULT_STATE.copy(maxAccessCount = 1), awaitItem())
}
}
@Test
fun `TextChange should update text input`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(NewSendAction.TextChange("input"))
assertEquals(
DEFAULT_STATE.copy(
selectedType = NewSendState.SendType.Text(
input = "input",
isHideByDefaultChecked = false,
),
), awaitItem(),
)
}
}
@Test
fun `NoteChange should update note input`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(NewSendAction.NoteChange("input"))
assertEquals(DEFAULT_STATE.copy(noteInput = "input"), awaitItem())
}
}
@Test
fun `PasswordChange should update note input`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(NewSendAction.PasswordChange("input"))
assertEquals(DEFAULT_STATE.copy(passwordInput = "input"), awaitItem())
}
}
@Test
fun `DeactivateThisSendToggle should update isDeactivateChecked`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(NewSendAction.DeactivateThisSendToggle(true))
assertEquals(DEFAULT_STATE.copy(isDeactivateChecked = true), awaitItem())
}
}
@Test
fun `HideMyEmailToggle should update isHideEmailChecked`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(NewSendAction.HideMyEmailToggle(true))
assertEquals(DEFAULT_STATE.copy(isHideEmailChecked = true), awaitItem())
}
}
private fun createViewModel(
savedStateHandle: SavedStateHandle = SavedStateHandle(),
): NewSendViewModel = NewSendViewModel(
savedStateHandle = savedStateHandle,
)
companion object {
private val DEFAULT_STATE = NewSendState(
name = "",
maxAccessCount = null,
passwordInput = "",
noteInput = "",
isHideEmailChecked = false,
isDeactivateChecked = false,
selectedType = NewSendState.SendType.Text(
input = "",
isHideByDefaultChecked = false,
),
)
}
}

View file

@ -14,6 +14,7 @@ import org.junit.Test
class SendScreenTest : BaseComposeTest() {
private var onNavigateToNewSendCalled = false
private val mutableEventFlow = MutableSharedFlow<SendEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
@ -28,6 +29,7 @@ class SendScreenTest : BaseComposeTest() {
composeTestRule.setContent {
SendScreen(
viewModel = viewModel,
onNavigateNewSend = { onNavigateToNewSendCalled = true },
)
}
}
@ -55,6 +57,12 @@ class SendScreenTest : BaseComposeTest() {
.performClick()
verify { viewModel.trySendAction(SendAction.SearchClick) }
}
@Test
fun `on NavigateToNewSend should call onNavgiateToNewSend`() {
mutableEventFlow.tryEmit(SendEvent.NavigateNewSend)
assert(onNavigateToNewSendCalled)
}
}
private val DEFAULT_STATE = SendState.Empty

View file

@ -26,11 +26,11 @@ class SendViewModelTest : BaseViewModelTest() {
}
@Test
fun `AddSendClick should emit ShowToast`() = runTest {
fun `AddSendClick should emit NavigateNewSend`() = runTest {
val viewModel = SendViewModel(SavedStateHandle())
viewModel.eventFlow.test {
viewModel.trySendAction(SendAction.AddSendClick)
assertEquals(SendEvent.ShowToast("New Send Not Implemented".asText()), awaitItem())
assertEquals(SendEvent.NavigateNewSend, awaitItem())
}
}