Refactor AddSendViewModel to support loading and error states. (#524)

This commit is contained in:
David Perez 2024-01-07 20:05:33 -06:00 committed by Álison Fernandes
parent 1e8d603b61
commit 978e72899b
6 changed files with 635 additions and 367 deletions

View file

@ -0,0 +1,281 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addsend
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
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 com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenSegmentedButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenStepper
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.components.SegmentedButtonState
import com.x8bit.bitwarden.ui.tools.feature.send.SendDeletionDateChooser
import com.x8bit.bitwarden.ui.tools.feature.send.SendExpirationDateChooser
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandlers
/**
* Content view for the [AddSendScreen].
*/
@Suppress("LongMethod")
@Composable
fun AddSendContent(
state: AddSendState.ViewState.Content,
addSendHandlers: AddSendHandlers,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState()),
) {
BitwardenTextField(
modifier = Modifier.padding(horizontal = 16.dp),
label = stringResource(id = R.string.name),
hint = stringResource(id = R.string.name_info),
value = state.common.name,
onValueChange = addSendHandlers.onNamChange,
)
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 = addSendHandlers.onFileTypeSelect,
isChecked = state.selectedType is AddSendState.ViewState.Content.SendType.File,
),
SegmentedButtonState(
text = stringResource(id = R.string.text),
onClick = addSendHandlers.onTextTypeSelect,
isChecked = state.selectedType is AddSendState.ViewState.Content.SendType.Text,
),
),
)
Spacer(modifier = Modifier.height(16.dp))
when (val type = state.selectedType) {
is AddSendState.ViewState.Content.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 = addSendHandlers.onChooseFileCLick,
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 AddSendState.ViewState.Content.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 = addSendHandlers.onTextChange,
)
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 = addSendHandlers.onIsHideByDefaultToggle,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
AddSendOptions(
state = state,
onMaxAccessCountChange = addSendHandlers.onMaxAccessCountChange,
onPasswordChange = addSendHandlers.onPasswordChange,
onNoteChange = addSendHandlers.onNoteChange,
onHideEmailChecked = addSendHandlers.onHideEmailToggle,
onDeactivateSendChecked = addSendHandlers.onDeactivateSendToggle,
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
/**
* Displays a collapsable set of new send options.
*
* @param state The content state.
* @param onMaxAccessCountChange called when max access count changes.
* @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 AddSendOptions(
state: AddSendState.ViewState.Content,
onMaxAccessCountChange: (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:
AnimatedVisibility(
visible = isExpanded,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically(),
modifier = Modifier.clipToBounds(),
) {
Column {
SendDeletionDateChooser(
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
SendExpirationDateChooser(
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenStepper(
label = stringResource(id = R.string.maximum_access_count),
value = state.common.maxAccessCount,
onValueChange = onMaxAccessCountChange,
isDecrementEnabled = state.common.maxAccessCount != null,
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(8.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.new_password),
hint = stringResource(id = R.string.password_info),
value = state.common.passwordInput,
onValueChange = onPasswordChange,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(id = R.string.notes),
hint = stringResource(id = R.string.notes_info),
value = state.common.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.common.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.common.isDeactivateChecked,
onCheckedChange = onDeactivateSendChecked,
)
}
}
}

View file

@ -1,61 +1,30 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addsend
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
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.navigationBarsPadding
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.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.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.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenSegmentedButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenStepper
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.tools.feature.send.SendDeletionDateChooser
import com.x8bit.bitwarden.ui.tools.feature.send.SendExpirationDateChooser
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandlers
/**
* Displays new send UX.
@ -104,249 +73,25 @@ fun AddSendScreen(
)
},
) { innerPadding ->
Column(
modifier = Modifier
.imePadding()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(paddingValues = innerPadding),
) {
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(AddSendAction.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(AddSendAction.FileTypeClick) }
},
isChecked = state.selectedType is AddSendState.SendType.File,
),
SegmentedButtonState(
text = stringResource(id = R.string.text),
onClick = remember(viewModel) {
{ viewModel.trySendAction(AddSendAction.TextTypeClick) }
},
isChecked = state.selectedType is AddSendState.SendType.Text,
),
),
)
Spacer(modifier = Modifier.height(16.dp))
when (val type = state.selectedType) {
is AddSendState.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(AddSendAction.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),
)
}
val modifier = Modifier
.imePadding()
.fillMaxSize()
.padding(paddingValues = innerPadding)
is AddSendState.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(AddSendAction.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(AddSendAction.HideByDefaultToggle(it)) }
},
)
}
}
Spacer(modifier = Modifier.height(16.dp))
NewSendOptions(
state = state,
onMaxAccessCountChange = remember(viewModel) {
{ viewModel.trySendAction(AddSendAction.MaxAccessCountChange(it)) }
},
onPasswordChange = remember(viewModel) {
{ viewModel.trySendAction(AddSendAction.PasswordChange(it)) }
},
onNoteChange = remember(viewModel) {
{ viewModel.trySendAction(AddSendAction.NoteChange(it)) }
},
onHideEmailChecked = remember(viewModel) {
{ viewModel.trySendAction(AddSendAction.HideMyEmailToggle(it)) }
},
onDeactivateSendChecked = remember(viewModel) {
{ viewModel.trySendAction(AddSendAction.DeactivateThisSendToggle(it)) }
},
when (val viewState = state.viewState) {
is AddSendState.ViewState.Content -> AddSendContent(
state = viewState,
addSendHandlers = remember(viewModel) { AddSendHandlers.create(viewModel) },
modifier = modifier,
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
/**
* Displays a collapsable set of new send options.
*
* @param state state.
* @param onMaxAccessCountChange called when max access count changes.
* @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: AddSendState,
onMaxAccessCountChange: (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 },
is AddSendState.ViewState.Error -> BitwardenErrorContent(
message = viewState.message(),
modifier = modifier,
)
.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:
AnimatedVisibility(
visible = isExpanded,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically(),
modifier = Modifier.clipToBounds(),
) {
Column {
SendDeletionDateChooser(
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
SendExpirationDateChooser(
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenStepper(
label = stringResource(id = R.string.maximum_access_count),
value = state.maxAccessCount,
onValueChange = onMaxAccessCountChange,
isDecrementEnabled = state.maxAccessCount != null,
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(8.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(8.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,
AddSendState.ViewState.Loading -> BitwardenLoadingContent(
modifier = modifier,
)
}
}

View file

@ -4,6 +4,7 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -22,15 +23,19 @@ class AddSendViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<AddSendState, AddSendEvent, AddSendAction>(
initialState = savedStateHandle[KEY_STATE] ?: AddSendState(
name = "",
maxAccessCount = null,
passwordInput = "",
noteInput = "",
isHideEmailChecked = false,
isDeactivateChecked = false,
selectedType = AddSendState.SendType.Text(
input = "",
isHideByDefaultChecked = false,
viewState = AddSendState.ViewState.Content(
common = AddSendState.ViewState.Content.Common(
name = "",
maxAccessCount = null,
passwordInput = "",
noteInput = "",
isHideEmailChecked = false,
isDeactivateChecked = false,
),
selectedType = AddSendState.ViewState.Content.SendType.Text(
input = "",
isHideByDefaultChecked = false,
),
),
),
) {
@ -58,25 +63,25 @@ class AddSendViewModel @Inject constructor(
}
private fun handlePasswordChange(action: AddSendAction.PasswordChange) {
mutableStateFlow.update {
updateCommonContent {
it.copy(passwordInput = action.input)
}
}
private fun handleNoteChange(action: AddSendAction.NoteChange) {
mutableStateFlow.update {
updateCommonContent {
it.copy(noteInput = action.input)
}
}
private fun handleHideMyEmailToggle(action: AddSendAction.HideMyEmailToggle) {
mutableStateFlow.update {
updateCommonContent {
it.copy(isHideEmailChecked = action.isChecked)
}
}
private fun handleDeactivateThisSendToggle(action: AddSendAction.DeactivateThisSendToggle) {
mutableStateFlow.update {
updateCommonContent {
it.copy(isDeactivateChecked = action.isChecked)
}
}
@ -86,36 +91,37 @@ class AddSendViewModel @Inject constructor(
private fun handleSaveClick() = sendEvent(AddSendEvent.ShowToast("Save Not Implemented"))
private fun handleNameChange(action: AddSendAction.NameChange) {
mutableStateFlow.update {
updateCommonContent {
it.copy(name = action.input)
}
}
private fun handleFileTypeClick() {
mutableStateFlow.update {
it.copy(selectedType = AddSendState.SendType.File)
updateContent {
it.copy(selectedType = AddSendState.ViewState.Content.SendType.File)
}
}
private fun handleTextTypeClick() {
mutableStateFlow.update {
it.copy(selectedType = AddSendState.SendType.Text("", isHideByDefaultChecked = false))
updateContent {
it.copy(
selectedType = AddSendState.ViewState.Content.SendType.Text(
input = "",
isHideByDefaultChecked = false,
),
)
}
}
private fun handleTextChange(action: AddSendAction.TextChange) {
val currentSendInput =
mutableStateFlow.value.selectedType as? AddSendState.SendType.Text ?: return
mutableStateFlow.update {
it.copy(selectedType = currentSendInput.copy(input = action.input))
updateTextContent {
it.copy(input = action.input)
}
}
private fun handleHideByDefaultToggle(action: AddSendAction.HideByDefaultToggle) {
val currentSendInput =
mutableStateFlow.value.selectedType as? AddSendState.SendType.Text ?: return
mutableStateFlow.update {
it.copy(selectedType = currentSendInput.copy(isHideByDefaultChecked = action.isChecked))
updateTextContent {
it.copy(isHideByDefaultChecked = action.isChecked)
}
}
@ -125,8 +131,54 @@ class AddSendViewModel @Inject constructor(
}
private fun handleMaxAccessCountChange(action: AddSendAction.MaxAccessCountChange) {
mutableStateFlow.update {
it.copy(maxAccessCount = action.value)
updateCommonContent { it.copy(maxAccessCount = action.value) }
}
private inline fun onContent(
crossinline block: (AddSendState.ViewState.Content) -> Unit,
) {
(state.viewState as? AddSendState.ViewState.Content)?.let(block)
}
private inline fun updateContent(
crossinline block: (
AddSendState.ViewState.Content,
) -> AddSendState.ViewState.Content?,
) {
val currentViewState = state.viewState
val updatedContent = (currentViewState as? AddSendState.ViewState.Content)
?.let(block)
?: return
mutableStateFlow.update { it.copy(viewState = updatedContent) }
}
private inline fun updateCommonContent(
crossinline block: (
AddSendState.ViewState.Content.Common,
) -> AddSendState.ViewState.Content.Common,
) {
updateContent { it.copy(common = block(it.common)) }
}
private inline fun updateFileContent(
crossinline block: (
AddSendState.ViewState.Content.SendType.File,
) -> AddSendState.ViewState.Content.SendType.File,
) {
updateContent { currentContent ->
(currentContent.selectedType as? AddSendState.ViewState.Content.SendType.File)
?.let { currentContent.copy(selectedType = block(it)) }
}
}
private inline fun updateTextContent(
crossinline block: (
AddSendState.ViewState.Content.SendType.Text,
) -> AddSendState.ViewState.Content.SendType.Text,
) {
updateContent { currentContent ->
(currentContent.selectedType as? AddSendState.ViewState.Content.SendType.Text)
?.let { currentContent.copy(selectedType = block(it)) }
}
}
}
@ -136,34 +188,68 @@ class AddSendViewModel @Inject constructor(
*/
@Parcelize
data class AddSendState(
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,
val viewState: ViewState,
) : Parcelable {
/**
* Models what type the user is trying to send.
* Represents the specific view states for the [AddSendScreen].
*/
sealed class SendType : Parcelable {
sealed class ViewState : Parcelable {
/**
* Sending a file.
* Represents an error state for the [AddSendScreen].
*/
@Parcelize
data object File : SendType()
data class Error(val message: Text) : ViewState()
/**
* Sending text.
* Loading state for the [AddSendScreen], signifying that the content is being processed.
*/
@Parcelize
data class Text(
val input: String,
val isHideByDefaultChecked: Boolean,
) : SendType()
data object Loading : ViewState()
/**
* Represents a loaded content state for the [AddSendScreen].
*/
@Parcelize
data class Content(
val common: Common,
val selectedType: SendType,
) : ViewState() {
/**
* Content data that is common for all item types.
*/
@Parcelize
data class Common(
val name: String,
// 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()
}
}
}
}

View file

@ -0,0 +1,53 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendAction
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendViewModel
/**
* A collection of handler functions for managing actions within the context of adding
* send items.
*/
data class AddSendHandlers(
val onNamChange: (String) -> Unit,
val onFileTypeSelect: () -> Unit,
val onTextTypeSelect: () -> Unit,
val onChooseFileCLick: () -> Unit,
val onTextChange: (String) -> Unit,
val onIsHideByDefaultToggle: (Boolean) -> Unit,
val onMaxAccessCountChange: (Int) -> Unit,
val onPasswordChange: (String) -> Unit,
val onNoteChange: (String) -> Unit,
val onHideEmailToggle: (Boolean) -> Unit,
val onDeactivateSendToggle: (Boolean) -> Unit,
) {
companion object {
/**
* Creates an instance of [AddSendHandlers] by binding actions to the provided
* [AddSendViewModel].
*/
fun create(
viewModel: AddSendViewModel,
): AddSendHandlers =
AddSendHandlers(
onNamChange = { viewModel.trySendAction(AddSendAction.NameChange(it)) },
onFileTypeSelect = { viewModel.trySendAction(AddSendAction.FileTypeClick) },
onTextTypeSelect = { viewModel.trySendAction(AddSendAction.TextTypeClick) },
onChooseFileCLick = { viewModel.trySendAction(AddSendAction.ChooseFileClick) },
onTextChange = { viewModel.trySendAction(AddSendAction.TextChange(it)) },
onIsHideByDefaultToggle = {
viewModel.trySendAction(AddSendAction.HideByDefaultToggle(it))
},
onMaxAccessCountChange = {
viewModel.trySendAction(AddSendAction.MaxAccessCountChange(it))
},
onPasswordChange = { viewModel.trySendAction(AddSendAction.PasswordChange(it)) },
onNoteChange = { viewModel.trySendAction(AddSendAction.NoteChange(it)) },
onHideEmailToggle = {
viewModel.trySendAction(AddSendAction.HideMyEmailToggle(it))
},
onDeactivateSendToggle = {
viewModel.trySendAction(AddSendAction.DeactivateThisSendToggle(it))
},
)
}
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addsend
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
@ -13,6 +14,8 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.util.isProgressBar
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -83,7 +86,11 @@ class AddSendScreenTest : BaseComposeTest() {
)
mutableStateFlow.update {
it.copy(name = "input")
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(name = "input"),
),
)
}
composeTestRule
.onNodeWithText("Name")
@ -113,7 +120,9 @@ class AddSendScreenTest : BaseComposeTest() {
@Test
fun `Choose file button click should send ChooseFileClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
selectedType = AddSendState.SendType.File,
viewState = DEFAULT_VIEW_STATE.copy(
selectedType = AddSendState.ViewState.Content.SendType.File,
),
)
composeTestRule
.onNodeWithText("Choose file")
@ -143,9 +152,11 @@ class AddSendScreenTest : BaseComposeTest() {
mutableStateFlow.update {
it.copy(
selectedType = AddSendState.SendType.Text(
input = "input",
isHideByDefaultChecked = false,
viewState = DEFAULT_VIEW_STATE.copy(
selectedType = AddSendState.ViewState.Content.SendType.Text(
input = "input",
isHideByDefaultChecked = false,
),
),
)
}
@ -175,9 +186,11 @@ class AddSendScreenTest : BaseComposeTest() {
mutableStateFlow.update {
it.copy(
selectedType = AddSendState.SendType.Text(
input = "",
isHideByDefaultChecked = true,
viewState = DEFAULT_VIEW_STATE.copy(
selectedType = AddSendState.ViewState.Content.SendType.Text(
input = "",
isHideByDefaultChecked = true,
),
),
)
}
@ -253,7 +266,11 @@ class AddSendScreenTest : BaseComposeTest() {
@Test
fun `max access count decrement should send MaxAccessCountChange`() = runTest {
mutableStateFlow.update {
it.copy(maxAccessCount = 3)
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(maxAccessCount = 3),
),
)
}
// Expand options section:
composeTestRule
@ -272,7 +289,11 @@ class AddSendScreenTest : BaseComposeTest() {
fun `max access count decrement when set to 1 should do nothing`() =
runTest {
mutableStateFlow.update {
it.copy(maxAccessCount = 1)
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(maxAccessCount = 1),
),
)
}
// Expand options section:
composeTestRule
@ -333,7 +354,9 @@ class AddSendScreenTest : BaseComposeTest() {
mutableStateFlow.update {
it.copy(
passwordInput = "input",
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(passwordInput = "input"),
),
)
}
composeTestRule
@ -376,7 +399,9 @@ class AddSendScreenTest : BaseComposeTest() {
mutableStateFlow.update {
it.copy(
noteInput = "input",
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(noteInput = "input"),
),
)
}
composeTestRule
@ -416,7 +441,9 @@ class AddSendScreenTest : BaseComposeTest() {
mutableStateFlow.update {
it.copy(
isHideEmailChecked = true,
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(isHideEmailChecked = true),
),
)
}
composeTestRule
@ -452,7 +479,9 @@ class AddSendScreenTest : BaseComposeTest() {
mutableStateFlow.update {
it.copy(
isDeactivateChecked = true,
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(isDeactivateChecked = true),
),
)
}
composeTestRule
@ -460,18 +489,60 @@ class AddSendScreenTest : BaseComposeTest() {
.assertIsOn()
}
@Test
fun `progressbar should be displayed according to state`() {
mutableStateFlow.update {
it.copy(viewState = AddSendState.ViewState.Loading)
}
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = AddSendState.ViewState.Error("Fail".asText()))
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = DEFAULT_VIEW_STATE)
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
}
@Test
fun `error should be displayed according to state`() {
val errorMessage = "Fail"
mutableStateFlow.update {
it.copy(viewState = AddSendState.ViewState.Error(errorMessage.asText()))
}
composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = AddSendState.ViewState.Loading)
}
composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist()
}
companion object {
private val DEFAULT_STATE = AddSendState(
private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common(
name = "",
maxAccessCount = null,
passwordInput = "",
noteInput = "",
isHideEmailChecked = false,
isDeactivateChecked = false,
selectedType = AddSendState.SendType.Text(
input = "",
isHideByDefaultChecked = false,
),
)
private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text(
input = "",
isHideByDefaultChecked = false,
)
private val DEFAULT_VIEW_STATE = AddSendState.ViewState.Content(
common = DEFAULT_COMMON_STATE,
selectedType = DEFAULT_SELECTED_TYPE_STATE,
)
private val DEFAULT_STATE = AddSendState(
viewState = DEFAULT_VIEW_STATE,
)
}
}

View file

@ -19,9 +19,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should read from saved state when present`() {
val savedState = mockk<AddSendState>()
val viewModel = createViewModel(
savedStateHandle = SavedStateHandle(mapOf("state" to savedState)),
)
val viewModel = createViewModel(savedState)
assertEquals(savedState, viewModel.stateFlow.value)
}
@ -55,13 +53,14 @@ class AddSendViewModelTest : BaseViewModelTest() {
@Test
fun `FileTypeClick and TextTypeClick should toggle sendType`() = runTest {
val viewModel = createViewModel()
val expectedViewState = DEFAULT_VIEW_STATE.copy(
selectedType = AddSendState.ViewState.Content.SendType.File,
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(AddSendAction.FileTypeClick)
assertEquals(
DEFAULT_STATE.copy(selectedType = AddSendState.SendType.File),
awaitItem(),
)
assertEquals(DEFAULT_STATE.copy(viewState = expectedViewState), awaitItem())
viewModel.trySendAction(AddSendAction.TextTypeClick)
assertEquals(DEFAULT_STATE, awaitItem())
}
@ -70,99 +69,132 @@ class AddSendViewModelTest : BaseViewModelTest() {
@Test
fun `NameChange should update name input`() = runTest {
val viewModel = createViewModel()
val expectedViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(name = "input"),
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(AddSendAction.NameChange("input"))
assertEquals(DEFAULT_STATE.copy(name = "input"), awaitItem())
assertEquals(DEFAULT_STATE.copy(viewState = expectedViewState), awaitItem())
}
}
@Test
fun `MaxAccessCountChange should update maxAccessCount`() = runTest {
val viewModel = createViewModel()
val expectedViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(maxAccessCount = 5),
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(AddSendAction.MaxAccessCountChange(5))
assertEquals(DEFAULT_STATE.copy(maxAccessCount = 5), awaitItem())
assertEquals(DEFAULT_STATE.copy(viewState = expectedViewState), awaitItem())
}
}
@Test
fun `TextChange should update text input`() = runTest {
val viewModel = createViewModel()
val expectedViewState = DEFAULT_VIEW_STATE.copy(
selectedType = AddSendState.ViewState.Content.SendType.Text(
input = "input",
isHideByDefaultChecked = false,
),
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(AddSendAction.TextChange("input"))
assertEquals(
DEFAULT_STATE.copy(
selectedType = AddSendState.SendType.Text(
input = "input",
isHideByDefaultChecked = false,
),
),
awaitItem(),
)
assertEquals(DEFAULT_STATE.copy(viewState = expectedViewState), awaitItem())
}
}
@Test
fun `NoteChange should update note input`() = runTest {
val viewModel = createViewModel()
val expectedViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(noteInput = "input"),
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(AddSendAction.NoteChange("input"))
assertEquals(DEFAULT_STATE.copy(noteInput = "input"), awaitItem())
assertEquals(DEFAULT_STATE.copy(viewState = expectedViewState), awaitItem())
}
}
@Test
fun `PasswordChange should update note input`() = runTest {
val viewModel = createViewModel()
val expectedViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(passwordInput = "input"),
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(AddSendAction.PasswordChange("input"))
assertEquals(DEFAULT_STATE.copy(passwordInput = "input"), awaitItem())
assertEquals(DEFAULT_STATE.copy(viewState = expectedViewState), awaitItem())
}
}
@Test
fun `DeactivateThisSendToggle should update isDeactivateChecked`() = runTest {
val viewModel = createViewModel()
val expectedViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(isDeactivateChecked = true),
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(AddSendAction.DeactivateThisSendToggle(true))
assertEquals(DEFAULT_STATE.copy(isDeactivateChecked = true), awaitItem())
assertEquals(DEFAULT_STATE.copy(viewState = expectedViewState), awaitItem())
}
}
@Test
fun `HideMyEmailToggle should update isHideEmailChecked`() = runTest {
val viewModel = createViewModel()
val expectedViewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(isHideEmailChecked = true),
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(AddSendAction.HideMyEmailToggle(true))
assertEquals(DEFAULT_STATE.copy(isHideEmailChecked = true), awaitItem())
viewModel.trySendAction(AddSendAction.HideMyEmailToggle(isChecked = true))
assertEquals(DEFAULT_STATE.copy(viewState = expectedViewState), awaitItem())
}
}
private fun createViewModel(
savedStateHandle: SavedStateHandle = SavedStateHandle(),
state: AddSendState? = null,
): AddSendViewModel = AddSendViewModel(
savedStateHandle = savedStateHandle,
savedStateHandle = SavedStateHandle().apply { set("state", state) },
)
companion object {
private val DEFAULT_STATE = AddSendState(
private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common(
name = "",
maxAccessCount = null,
passwordInput = "",
noteInput = "",
isHideEmailChecked = false,
isDeactivateChecked = false,
selectedType = AddSendState.SendType.Text(
input = "",
isHideByDefaultChecked = false,
),
)
private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text(
input = "",
isHideByDefaultChecked = false,
)
private val DEFAULT_VIEW_STATE = AddSendState.ViewState.Content(
common = DEFAULT_COMMON_STATE,
selectedType = DEFAULT_SELECTED_TYPE_STATE,
)
private val DEFAULT_STATE = AddSendState(
viewState = DEFAULT_VIEW_STATE,
)
}
}