BIT-479 Implement new send UI (#215)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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