Add support for the Send expiration date UI (#556)

This commit is contained in:
David Perez 2024-01-09 15:43:27 -06:00 committed by Álison Fernandes
parent 273b18118a
commit aba5de32fb
9 changed files with 220 additions and 26 deletions

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.util.orNow
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import java.time.Instant
import java.time.ZoneOffset
@ -46,14 +47,18 @@ import java.time.ZonedDateTime
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitwardenDateSelectButton(
currentZonedDateTime: ZonedDateTime,
currentZonedDateTime: ZonedDateTime?,
formatPattern: String,
onDateSelect: (ZonedDateTime) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowDialog: Boolean by rememberSaveable { mutableStateOf(false) }
val formattedDate by remember(currentZonedDateTime) {
mutableStateOf(currentZonedDateTime.toFormattedPattern(formatPattern))
mutableStateOf(
currentZonedDateTime
?.toFormattedPattern(formatPattern)
?: "mm/dd/yyyy",
)
}
// TODO: This should be "Date" but we don't have that string (BIT-1405)
val label = stringResource(id = R.string.deletion_date)
@ -95,7 +100,7 @@ fun BitwardenDateSelectButton(
if (shouldShowDialog) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = currentZonedDateTime.toInstant().toEpochMilli(),
initialSelectedDateMillis = currentZonedDateTime.orNow().toInstant().toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = { shouldShowDialog = false },
@ -110,7 +115,7 @@ fun BitwardenDateSelectButton(
),
ZoneOffset.UTC,
)
.withZoneSameLocal(currentZonedDateTime.zone),
.withZoneSameLocal(currentZonedDateTime.orNow().zone),
)
shouldShowDialog = false
},

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.util.orNow
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import java.time.ZonedDateTime
@ -39,7 +40,7 @@ import java.time.ZonedDateTime
*/
@Composable
fun BitwardenTimeSelectButton(
currentZonedDateTime: ZonedDateTime,
currentZonedDateTime: ZonedDateTime?,
formatPattern: String,
onTimeSelect: (hour: Int, minute: Int) -> Unit,
modifier: Modifier = Modifier,
@ -47,7 +48,11 @@ fun BitwardenTimeSelectButton(
) {
var shouldShowDialog: Boolean by rememberSaveable { mutableStateOf(false) }
val formattedTime by remember(currentZonedDateTime) {
mutableStateOf(currentZonedDateTime.toFormattedPattern(formatPattern))
mutableStateOf(
currentZonedDateTime
?.toFormattedPattern(formatPattern)
?: "--:-- --",
)
}
val label = stringResource(id = R.string.time)
OutlinedTextField(
@ -87,8 +92,8 @@ fun BitwardenTimeSelectButton(
if (shouldShowDialog) {
BitwardenTimePickerDialog(
initialHour = currentZonedDateTime.hour,
initialMinute = currentZonedDateTime.minute,
initialHour = currentZonedDateTime.orNow().hour,
initialMinute = currentZonedDateTime.orNow().minute,
onTimeSelect = { hour, minute ->
shouldShowDialog = false
onTimeSelect(hour, minute)

View file

@ -0,0 +1,8 @@
package com.x8bit.bitwarden.ui.platform.util
import java.time.ZonedDateTime
/**
* Returns the current [ZonedDateTime] or [ZonedDateTime.now] if the current one is null.
*/
fun ZonedDateTime?.orNow(): ZonedDateTime = this ?: ZonedDateTime.now()

View file

@ -217,6 +217,10 @@ private fun AddSendOptions(
Spacer(modifier = Modifier.height(8.dp))
SendExpirationDateChooser(
modifier = Modifier.padding(horizontal = 16.dp),
dateFormatPattern = state.common.dateFormatPattern,
timeFormatPattern = state.common.timeFormatPattern,
currentZonedDateTime = state.common.expirationDate,
onDateSelect = addSendHandlers.onExpirationDateChange,
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenStepper(

View file

@ -1,54 +1,118 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addsend
import androidx.compose.animation.AnimatedVisibility
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenDateSelectButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTimeSelectButton
import com.x8bit.bitwarden.ui.platform.util.orNow
import kotlinx.collections.immutable.toImmutableList
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/**
* Displays UX for choosing expiration date of a send.
*
* TODO: Implement custom date choosing and send choices to the VM: BIT-1090.
*/
@Suppress("LongMethod")
@Composable
fun SendExpirationDateChooser(
currentZonedDateTime: ZonedDateTime?,
dateFormatPattern: String,
timeFormatPattern: String,
onDateSelect: (ZonedDateTime?) -> Unit,
modifier: Modifier = Modifier,
) {
val options = listOf(
stringResource(id = R.string.never),
stringResource(id = R.string.one_hour),
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.never)
var selectedOption: String by rememberSaveable { mutableStateOf(defaultOption) }
val defaultOption = ExpirationOptions.NEVER
val options = ExpirationOptions.entries.associateWith { it.text() }
var selectedOption: ExpirationOptions by rememberSaveable { mutableStateOf(defaultOption) }
Column(
modifier = modifier,
) {
BitwardenMultiSelectButton(
label = stringResource(id = R.string.expiration_date),
options = options.toImmutableList(),
selectedOption = selectedOption,
onOptionSelected = { selectedOption = it },
options = options.values.toImmutableList(),
selectedOption = selectedOption.text(),
onOptionSelected = { selected ->
selectedOption = options.entries.first { it.value == selected }.key
if (selectedOption != ExpirationOptions.NEVER) {
onDateSelect(null)
} else if (selectedOption != ExpirationOptions.CUSTOM) {
onDateSelect(
// Add the appropriate milliseconds offset based on the selected option
ZonedDateTime.now().plus(selectedOption.offsetMillis, ChronoUnit.MILLIS),
)
}
},
)
AnimatedVisibility(visible = selectedOption == ExpirationOptions.CUSTOM) {
// This tracks the date component (year, month, and day) and ignores lower level
// components.
var date: ZonedDateTime? by remember { mutableStateOf(currentZonedDateTime) }
// This tracks just the time component (hours and minutes) and ignores the higher level
// components. 0 representing midnight and counting up from there.
var timeMillis: Long by remember {
mutableStateOf(
currentZonedDateTime.orNow().let {
it.hour.hours.inWholeMilliseconds + it.minute.minutes.inWholeMilliseconds
},
)
}
val derivedDateTimeMillis: ZonedDateTime? by remember {
derivedStateOf { date?.plus(timeMillis, ChronoUnit.MILLIS) }
}
Column {
Spacer(modifier = Modifier.height(8.dp))
Row {
BitwardenDateSelectButton(
modifier = Modifier.weight(1f),
formatPattern = dateFormatPattern,
currentZonedDateTime = currentZonedDateTime,
onDateSelect = {
date = it
onDateSelect(derivedDateTimeMillis)
},
)
Spacer(modifier = Modifier.width(16.dp))
BitwardenTimeSelectButton(
modifier = Modifier.weight(1f),
formatPattern = timeFormatPattern,
currentZonedDateTime = currentZonedDateTime,
onTimeSelect = { hour, minute ->
timeMillis = hour.hours.inWholeMilliseconds +
minute.minutes.inWholeMilliseconds
onDateSelect(derivedDateTimeMillis)
},
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(id = R.string.expiration_date_info),
@ -60,3 +124,41 @@ fun SendExpirationDateChooser(
)
}
}
private enum class ExpirationOptions(
val text: Text,
val offsetMillis: Long,
) {
NEVER(
text = R.string.never.asText(),
offsetMillis = -1L,
),
ONE_HOUR(
text = R.string.one_hour.asText(),
offsetMillis = 1.hours.inWholeMilliseconds,
),
ONE_DAY(
text = R.string.one_day.asText(),
offsetMillis = 1.days.inWholeMilliseconds,
),
TWO_DAYS(
text = R.string.two_days.asText(),
offsetMillis = 2.days.inWholeMilliseconds,
),
THREE_DAYS(
text = R.string.three_days.asText(),
offsetMillis = 3.days.inWholeMilliseconds,
),
SEVEN_DAYS(
text = R.string.seven_days.asText(),
offsetMillis = 7.days.inWholeMilliseconds,
),
THIRTY_DAYS(
text = R.string.thirty_days.asText(),
offsetMillis = 30.days.inWholeMilliseconds,
),
CUSTOM(
text = R.string.custom.asText(),
offsetMillis = -1L,
),
}

View file

@ -83,6 +83,7 @@ class AddSendViewModel @Inject constructor(
override fun handleAction(action: AddSendAction): Unit = when (action) {
is AddSendAction.CloseClick -> handleCloseClick()
is AddSendAction.DeletionDateChange -> handleDeletionDateChange(action)
is AddSendAction.ExpirationDateChange -> handleExpirationDateChange(action)
AddSendAction.DismissDialogClick -> handleDismissDialogClick()
is AddSendAction.SaveClick -> handleSaveClick()
is AddSendAction.FileTypeClick -> handleFileTypeClick()
@ -169,6 +170,12 @@ class AddSendViewModel @Inject constructor(
}
}
private fun handleExpirationDateChange(action: AddSendAction.ExpirationDateChange) {
updateCommonContent {
it.copy(expirationDate = action.expirationDate)
}
}
private fun handleSaveClick() {
onContent { content ->
if (content.common.name.isBlank()) {
@ -504,10 +511,15 @@ sealed class AddSendAction {
data class DeactivateThisSendToggle(val isChecked: Boolean) : AddSendAction()
/**
* User toggled the "deactivate this send" toggle.
* The user changed the deletion date.
*/
data class DeletionDateChange(val deletionDate: ZonedDateTime) : AddSendAction()
/**
* The user changed the expiration date.
*/
data class ExpirationDateChange(val expirationDate: ZonedDateTime?) : AddSendAction()
/**
* Models actions that the [AddSendViewModel] itself might send.
*/

View file

@ -21,6 +21,7 @@ data class AddSendHandlers(
val onHideEmailToggle: (Boolean) -> Unit,
val onDeactivateSendToggle: (Boolean) -> Unit,
val onDeletionDateChange: (ZonedDateTime) -> Unit,
val onExpirationDateChange: (ZonedDateTime?) -> Unit,
) {
companion object {
/**
@ -53,6 +54,9 @@ data class AddSendHandlers(
onDeletionDateChange = {
viewModel.trySendAction(AddSendAction.DeletionDateChange(it))
},
onExpirationDateChange = {
viewModel.trySendAction(AddSendAction.ExpirationDateChange(it))
},
)
}
}

View file

@ -0,0 +1,33 @@
package com.x8bit.bitwarden.ui.platform.util
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.ZonedDateTime
class ZonedDateTimeExtensionsTest {
@Test
fun `orNow should return original ZonedDateTime when original is nonnull`() {
val zonedDateTime = mockk<ZonedDateTime>()
val result = zonedDateTime.orNow()
assertEquals(zonedDateTime, result)
}
@Test
fun `orNow should return current ZonedDateTime when original is null`() {
val zonedDateTime = mockk<ZonedDateTime>()
mockkStatic(ZonedDateTime::class)
every { ZonedDateTime.now() } returns zonedDateTime
val result = null.orNow()
assertEquals(zonedDateTime, result)
unmockkStatic(ZonedDateTime::class)
}
}

View file

@ -202,6 +202,27 @@ class AddSendViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `ExpirationDateChange should store the new expiration date`() {
val viewModel = createViewModel()
val newDeletionDate = ZonedDateTime.parse("2024-09-13T00:00Z")
// DEFAULT expiration date is null
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
viewModel.trySendAction(AddSendAction.ExpirationDateChange(newDeletionDate))
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
expirationDate = newDeletionDate,
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `ChooseFileClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()