mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
Add support for the Send expiration date UI (#556)
This commit is contained in:
parent
273b18118a
commit
aba5de32fb
9 changed files with 220 additions and 26 deletions
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
Loading…
Reference in a new issue