From aba5de32fbef8d14c7e4859d27862f5f7080fe5e Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 9 Jan 2024 15:43:27 -0600 Subject: [PATCH] Add support for the Send expiration date UI (#556) --- .../dialog/BitwardenDateSelectButton.kt | 13 +- .../dialog/BitwardenTimeSelectButton.kt | 13 +- .../platform/util/ZonedDateTImeExtensions.kt | 8 ++ .../feature/send/addsend/AddSendContent.kt | 4 + .../addsend/AddSendExpirationDateChooser.kt | 136 +++++++++++++++--- .../feature/send/addsend/AddSendViewModel.kt | 14 +- .../send/addsend/handlers/AddSendHandlers.kt | 4 + .../util/ZonedDateTimeExtensionsTest.kt | 33 +++++ .../send/addsend/AddSendViewModelTest.kt | 21 +++ 9 files changed, 220 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/util/ZonedDateTImeExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ZonedDateTimeExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenDateSelectButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenDateSelectButton.kt index a4da8010e..9a6f7783a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenDateSelectButton.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenDateSelectButton.kt @@ -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 }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenTimeSelectButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenTimeSelectButton.kt index 88d9b4f23..bfb261791 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenTimeSelectButton.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenTimeSelectButton.kt @@ -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) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/ZonedDateTImeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/ZonedDateTImeExtensions.kt new file mode 100644 index 000000000..277d2ed21 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/ZonedDateTImeExtensions.kt @@ -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() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendContent.kt index ce5a3ff7f..85c3fb2c8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendContent.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendExpirationDateChooser.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendExpirationDateChooser.kt index af77ce957..660e8b38e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendExpirationDateChooser.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendExpirationDateChooser.kt @@ -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, + ), +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt index d7951eb45..d8d9bd29c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt @@ -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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/handlers/AddSendHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/handlers/AddSendHandlers.kt index 56329a038..33a4aa037 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/handlers/AddSendHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/handlers/AddSendHandlers.kt @@ -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)) + }, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ZonedDateTimeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ZonedDateTimeExtensionsTest.kt new file mode 100644 index 000000000..a056b60c6 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ZonedDateTimeExtensionsTest.kt @@ -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() + + val result = zonedDateTime.orNow() + + assertEquals(zonedDateTime, result) + } + + @Test + fun `orNow should return current ZonedDateTime when original is null`() { + val zonedDateTime = mockk() + mockkStatic(ZonedDateTime::class) + every { ZonedDateTime.now() } returns zonedDateTime + + val result = null.orNow() + + assertEquals(zonedDateTime, result) + unmockkStatic(ZonedDateTime::class) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt index 7628dc327..3112372a7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt @@ -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()