diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenIconButtonWithResource.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenIconButtonWithResource.kt index 2fb0bacac4..efbd6a4dac 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenIconButtonWithResource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenIconButtonWithResource.kt @@ -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, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSegmentedButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSegmentedButton.kt new file mode 100644 index 0000000000..ea72f9553b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSegmentedButton.kt @@ -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, +) { + 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, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index fe4f093e75..3a86a19f8a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -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() }) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt index bb0d564c96..05f7f8cb51 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt @@ -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, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index bb75ebd166..441eb6d4e3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -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) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendNavigation.kt new file mode 100644 index 0000000000..e558cd79e3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendNavigation.kt @@ -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) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendScreen.kt new file mode 100644 index 0000000000..d21513aac6 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendScreen.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendViewModel.kt new file mode 100644 index 0000000000..e49c3fc7c9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendViewModel.kt @@ -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( + 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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendExpirationDateChooser.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendExpirationDateChooser.kt new file mode 100644 index 0000000000..6fcd251e96 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendExpirationDateChooser.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendGraphNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendGraphNavigation.kt index 6c469a7140..7ead8b3793 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendGraphNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendGraphNavigation.kt @@ -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) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendNavigation.kt index 9ee679db3a..bbeb31481a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendNavigation.kt @@ -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, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt index d749f32391..6f145c941e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt @@ -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() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt index f1de2725c4..4d62293a3d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt @@ -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. */ diff --git a/app/src/main/res/drawable/ic_expand_down.xml b/app/src/main/res/drawable/ic_expand_down.xml new file mode 100644 index 0000000000..cfce80df0b --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_down.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_expand_up.xml b/app/src/main/res/drawable/ic_expand_up.xml new file mode 100644 index 0000000000..66a95e02fd --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_up.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_selected.xml b/app/src/main/res/drawable/ic_selected.xml new file mode 100644 index 0000000000..d08dbfd4f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_selected.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt index c5af1ae95a..6bc12b775c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt @@ -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") } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendScreenTest.kt new file mode 100644 index 0000000000..84f5fbb254 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendScreenTest.kt @@ -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( + extraBufferCapacity = Int.MAX_VALUE, + ) + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(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, + ), + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendViewModelTest.kt new file mode 100644 index 0000000000..2d7963aa6f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/NewSendViewModelTest.kt @@ -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() + 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, + ), + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt index 1f8dd59f54..3a5b540216 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt @@ -14,6 +14,7 @@ import org.junit.Test class SendScreenTest : BaseComposeTest() { + private var onNavigateToNewSendCalled = false private val mutableEventFlow = MutableSharedFlow( 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 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt index ad73e25409..04c68d453a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt @@ -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()) } }