mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-479 Implement new send UI (#215)
This commit is contained in:
parent
a9295ff981
commit
aeb5ff3734
21 changed files with 1447 additions and 14 deletions
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
13
app/src/main/res/drawable/ic_expand_down.xml
Normal file
13
app/src/main/res/drawable/ic_expand_down.xml
Normal 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>
|
13
app/src/main/res/drawable/ic_expand_up.xml
Normal file
13
app/src/main/res/drawable/ic_expand_up.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_selected.xml
Normal file
10
app/src/main/res/drawable/ic_selected.xml
Normal 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>
|
|
@ -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") }
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue