Modify Add Sends UI to allow for editing existing sends (#575)

This commit is contained in:
David Perez
2024-01-11 15:44:47 -06:00
committed by Álison Fernandes
parent 7e0a14d3a0
commit 9e6c49fb7c
10 changed files with 677 additions and 55 deletions

View File

@@ -13,6 +13,8 @@ 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.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
@@ -36,9 +38,11 @@ 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.BitwardenTextButton
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.platform.theme.LocalNonMaterialTypography
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandlers
/**
@@ -48,6 +52,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandler
@Composable
fun AddSendContent(
state: AddSendState.ViewState.Content,
isAddMode: Boolean,
addSendHandlers: AddSendHandlers,
modifier: Modifier = Modifier,
) {
@@ -65,34 +70,36 @@ fun AddSendContent(
onValueChange = addSendHandlers.onNamChange,
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.type),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
if (isAddMode) {
Spacer(modifier = Modifier.height(16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.type),
modifier = Modifier
.fillMaxWidth()
.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,
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.isFileType,
),
SegmentedButtonState(
text = stringResource(id = R.string.text),
onClick = addSendHandlers.onTextTypeSelect,
isChecked = state.isTextType,
),
),
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))
Spacer(modifier = Modifier.height(8.dp))
when (val type = state.selectedType) {
is AddSendState.ViewState.Content.SendType.File -> {
BitwardenListHeaderText(
@@ -161,6 +168,7 @@ fun AddSendContent(
Spacer(modifier = Modifier.height(16.dp))
AddSendOptions(
state = state,
isAddMode = isAddMode,
addSendHandlers = addSendHandlers,
)
@@ -173,12 +181,15 @@ fun AddSendContent(
* Displays a collapsable set of new send options.
*
* @param state The content state.
* @param isAddMode When `true`, indicates that we are creating a new send and `false` when editing
* an existing send.
* @param addSendHandlers THe handlers various events.
*/
@Suppress("LongMethod")
@Composable
private fun AddSendOptions(
state: AddSendState.ViewState.Content,
isAddMode: Boolean,
addSendHandlers: AddSendHandlers,
) {
var isExpanded by rememberSaveable { mutableStateOf(false) }
@@ -221,25 +232,92 @@ private fun AddSendOptions(
modifier = Modifier.clipToBounds(),
) {
Column {
SendDeletionDateChooser(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
dateFormatPattern = state.common.dateFormatPattern,
timeFormatPattern = state.common.timeFormatPattern,
currentZonedDateTime = state.common.deletionDate,
onDateSelect = addSendHandlers.onDeletionDateChange,
)
Spacer(modifier = Modifier.height(8.dp))
SendExpirationDateChooser(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
dateFormatPattern = state.common.dateFormatPattern,
timeFormatPattern = state.common.timeFormatPattern,
currentZonedDateTime = state.common.expirationDate,
onDateSelect = addSendHandlers.onExpirationDateChange,
)
if (isAddMode) {
SendDeletionDateChooser(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
dateFormatPattern = state.common.dateFormatPattern,
timeFormatPattern = state.common.timeFormatPattern,
currentZonedDateTime = state.common.deletionDate,
onDateSelect = addSendHandlers.onDeletionDateChange,
)
Spacer(modifier = Modifier.height(8.dp))
SendExpirationDateChooser(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
dateFormatPattern = state.common.dateFormatPattern,
timeFormatPattern = state.common.timeFormatPattern,
currentZonedDateTime = state.common.expirationDate,
onDateSelect = addSendHandlers.onExpirationDateChange,
)
} else {
BitwardenListHeaderText(
label = stringResource(id = R.string.deletion_date),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
AddSendCustomDateChooser(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
dateFormatPattern = state.common.dateFormatPattern,
timeFormatPattern = state.common.timeFormatPattern,
currentZonedDateTime = state.common.deletionDate,
onDateSelect = { addSendHandlers.onDeletionDateChange(requireNotNull(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),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.expiration_date),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
AddSendCustomDateChooser(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
dateFormatPattern = state.common.dateFormatPattern,
timeFormatPattern = state.common.timeFormatPattern,
currentZonedDateTime = state.common.expirationDate,
onDateSelect = addSendHandlers.onExpirationDateChange,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(id = R.string.expiration_date_info),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(4.dp))
BitwardenTextButton(
label = stringResource(id = R.string.clear),
onClick = addSendHandlers.onClearExpirationDateClick,
isEnabled = state.common.expirationDate != null,
modifier = Modifier.wrapContentWidth(),
)
}
}
Spacer(modifier = Modifier.height(8.dp))
BitwardenStepper(
label = stringResource(id = R.string.maximum_access_count),
@@ -260,6 +338,30 @@ private fun AddSendOptions(
.fillMaxWidth()
.padding(horizontal = 32.dp),
)
if (!isAddMode) {
state.common.currentAccessCount?.let {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
) {
Text(
text = stringResource(id = R.string.current_access_count) + ":",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = it.toString(),
style = LocalNonMaterialTypography.current.bodySmallProminent,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
Spacer(modifier = Modifier.height(8.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.new_password),

View File

@@ -135,6 +135,7 @@ fun AddSendScreen(
when (val viewState = state.viewState) {
is AddSendState.ViewState.Content -> AddSendContent(
state = viewState,
isAddMode = state.isAddMode,
addSendHandlers = remember(viewModel) { AddSendHandlers.create(viewModel) },
modifier = modifier,
)

View File

@@ -3,18 +3,24 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addsend
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.SendView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toSendView
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toViewState
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
@@ -50,6 +56,7 @@ class AddSendViewModel @Inject constructor(
AddSendType.AddItem -> AddSendState.ViewState.Content(
common = AddSendState.ViewState.Content.Common(
name = "",
currentAccessCount = null,
maxAccessCount = null,
passwordInput = "",
noteInput = "",
@@ -62,6 +69,7 @@ class AddSendViewModel @Inject constructor(
.truncatedTo(ChronoUnit.DAYS)
.plusWeeks(1),
expirationDate = null,
sendUrl = null,
),
selectedType = AddSendState.ViewState.Content.SendType.Text(
input = "",
@@ -69,9 +77,7 @@ class AddSendViewModel @Inject constructor(
),
)
is AddSendType.EditItem -> AddSendState.ViewState.Error(
"Not yet implemented".asText(),
)
is AddSendType.EditItem -> AddSendState.ViewState.Loading
},
dialogState = null,
isPremiumUser = authRepo.userStateFlow.value?.activeAccount?.isPremium == true,
@@ -85,6 +91,19 @@ class AddSendViewModel @Inject constructor(
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
when (val addSendType = state.addSendType) {
AddSendType.AddItem -> Unit
is AddSendType.EditItem -> {
vaultRepo
.getSendStateFlow(addSendType.sendItemId)
// We'll stop getting updates as soon as we get some loaded data.
.takeUntilLoaded()
.map { AddSendAction.Internal.SendDataReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
}
authRepo
.userStateFlow
.map { AddSendAction.Internal.UserStateReceive(it) }
@@ -100,6 +119,7 @@ class AddSendViewModel @Inject constructor(
is AddSendAction.CloseClick -> handleCloseClick()
is AddSendAction.DeletionDateChange -> handleDeletionDateChange(action)
is AddSendAction.ExpirationDateChange -> handleExpirationDateChange(action)
AddSendAction.ClearExpirationDate -> handleClearExpirationDate()
AddSendAction.DismissDialogClick -> handleDismissDialogClick()
is AddSendAction.SaveClick -> handleSaveClick()
is AddSendAction.FileTypeClick -> handleFileTypeClick()
@@ -118,7 +138,9 @@ class AddSendViewModel @Inject constructor(
private fun handleInternalAction(action: AddSendAction.Internal): Unit = when (action) {
is AddSendAction.Internal.CreateSendResultReceive -> handleCreateSendResultReceive(action)
is AddSendAction.Internal.UpdateSendResultReceive -> handleUpdateSendResultReceive(action)
is AddSendAction.Internal.UserStateReceive -> handleUserStateReceive(action)
is AddSendAction.Internal.SendDataReceive -> handleSendDataReceive(action)
}
private fun handleCreateSendResultReceive(
@@ -148,12 +170,113 @@ class AddSendViewModel @Inject constructor(
}
}
private fun handleUpdateSendResultReceive(
action: AddSendAction.Internal.UpdateSendResultReceive,
) {
when (val result = action.result) {
is UpdateSendResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = AddSendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = result
.errorMessage
?.asText()
?: R.string.generic_error_message.asText(),
),
)
}
}
is UpdateSendResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(AddSendEvent.NavigateBack)
sendEvent(
AddSendEvent.ShowShareSheet(
message = result.sendView.toSendUrl(state.baseWebSendUrl),
),
)
}
}
}
private fun handleUserStateReceive(action: AddSendAction.Internal.UserStateReceive) {
mutableStateFlow.update {
it.copy(isPremiumUser = action.userState?.activeAccount?.isPremium == true)
}
}
@Suppress("LongMethod")
private fun handleSendDataReceive(action: AddSendAction.Internal.SendDataReceive) {
when (val sendDataState = action.sendDataState) {
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
viewState = AddSendState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
viewState = sendDataState
.data
?.toViewState(
clock = clock,
baseWebSendUrl = environmentRepo
.environment
.environmentUrlData
.baseWebSendUrl,
)
?: AddSendState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
DataState.Loading -> {
mutableStateFlow.update {
it.copy(viewState = AddSendState.ViewState.Loading)
}
}
is DataState.NoNetwork -> {
mutableStateFlow.update {
it.copy(
viewState = AddSendState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
)
}
}
is DataState.Pending -> {
mutableStateFlow.update {
it.copy(
viewState = sendDataState
.data
?.toViewState(
clock = clock,
baseWebSendUrl = environmentRepo
.environment
.environmentUrlData
.baseWebSendUrl,
)
?: AddSendState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
}
}
private fun handleCopyLinkClick() {
// TODO Add copy link support (BIT-1435)
sendEvent(AddSendEvent.ShowToast("Not yet implemented"))
@@ -212,6 +335,10 @@ class AddSendViewModel @Inject constructor(
}
}
private fun handleClearExpirationDate() {
updateCommonContent { it.copy(expirationDate = null) }
}
private fun handleSaveClick() {
onContent { content ->
if (content.common.name.isBlank()) {
@@ -235,8 +362,20 @@ class AddSendViewModel @Inject constructor(
)
}
viewModelScope.launch {
val result = vaultRepo.createSend(content.toSendView(clock))
sendAction(AddSendAction.Internal.CreateSendResultReceive(result))
when (val addSendType = state.addSendType) {
AddSendType.AddItem -> {
val result = vaultRepo.createSend(content.toSendView(clock))
sendAction(AddSendAction.Internal.CreateSendResultReceive(result))
}
is AddSendType.EditItem -> {
val result = vaultRepo.updateSend(
sendId = addSendType.sendItemId,
sendView = content.toSendView(clock),
)
sendAction(AddSendAction.Internal.UpdateSendResultReceive(result))
}
}
}
}
}
@@ -402,12 +541,23 @@ data class AddSendState(
val selectedType: SendType,
) : ViewState() {
/**
* Helper method to indicate if the selected type is [SendType.File].
*/
val isFileType: Boolean get() = selectedType is SendType.File
/**
* Helper method to indicate if the selected type is [SendType.Text].
*/
val isTextType: Boolean get() = selectedType is SendType.Text
/**
* Content data that is common for all item types.
*/
@Parcelize
data class Common(
val name: String,
val currentAccessCount: Int?,
val maxAccessCount: Int?,
val passwordInput: String,
val noteInput: String,
@@ -415,6 +565,7 @@ data class AddSendState(
val isDeactivateChecked: Boolean,
val deletionDate: ZonedDateTime,
val expirationDate: ZonedDateTime?,
val sendUrl: String?,
) : Parcelable {
val dateFormatPattern: String get() = "M/d/yyyy"
@@ -592,6 +743,11 @@ sealed class AddSendAction {
*/
data class ExpirationDateChange(val expirationDate: ZonedDateTime?) : AddSendAction()
/**
* The user has cleared the expiration date.
*/
data object ClearExpirationDate : AddSendAction()
/**
* Models actions that the [AddSendViewModel] itself might send.
*/
@@ -605,5 +761,15 @@ sealed class AddSendAction {
* Indicates a result for creating a send has been received.
*/
data class CreateSendResultReceive(val result: CreateSendResult) : Internal()
/**
* Indicates a result for updating a send has been received.
*/
data class UpdateSendResultReceive(val result: UpdateSendResult) : Internal()
/**
* Indicates that the send item data has been received.
*/
data class SendDataReceive(val sendDataState: DataState<SendView?>) : Internal()
}
}

View File

@@ -22,6 +22,7 @@ data class AddSendHandlers(
val onDeactivateSendToggle: (Boolean) -> Unit,
val onDeletionDateChange: (ZonedDateTime) -> Unit,
val onExpirationDateChange: (ZonedDateTime?) -> Unit,
val onClearExpirationDateClick: () -> Unit,
) {
companion object {
/**
@@ -57,6 +58,9 @@ data class AddSendHandlers(
onExpirationDateChange = {
viewModel.trySendAction(AddSendAction.ExpirationDateChange(it))
},
onClearExpirationDateClick = {
viewModel.trySendAction(AddSendAction.ClearExpirationDate)
},
)
}
}

View File

@@ -0,0 +1,42 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addsend.util
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
import java.time.Clock
import java.time.ZonedDateTime
/**
* Transforms [SendView] into [AddSendState.ViewState.Content].
*/
fun SendView.toViewState(
clock: Clock,
baseWebSendUrl: String,
): AddSendState.ViewState.Content =
AddSendState.ViewState.Content(
common = AddSendState.ViewState.Content.Common(
name = this.name,
currentAccessCount = this.accessCount.toInt(),
maxAccessCount = this.maxAccessCount?.toInt(),
// We do not set the password here
// We only allow them to create new passwords, not view old ones
passwordInput = "",
noteInput = this.notes.orEmpty(),
isHideEmailChecked = this.hideEmail,
isDeactivateChecked = this.disabled,
deletionDate = ZonedDateTime.ofInstant(this.deletionDate, clock.zone),
expirationDate = this.expirationDate?.let { ZonedDateTime.ofInstant(it, clock.zone) },
sendUrl = this.toSendUrl(baseWebSendUrl),
),
selectedType = when (type) {
SendType.TEXT -> {
AddSendState.ViewState.Content.SendType.Text(
input = this.text?.text.orEmpty(),
isHideByDefaultChecked = this.text?.hidden == true,
)
}
SendType.FILE -> AddSendState.ViewState.Content.SendType.File
},
)