BIT-484: Add deletion date and time pickers (#548)

This commit is contained in:
David Perez 2024-01-09 11:46:15 -06:00 committed by Álison Fernandes
parent b8d397f71f
commit 8c2e2f8af6
9 changed files with 183 additions and 69 deletions

View file

@ -27,6 +27,8 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
/**
@ -46,7 +48,7 @@ import java.time.ZonedDateTime
fun BitwardenDateSelectButton(
currentZonedDateTime: ZonedDateTime,
formatPattern: String,
onDateSelect: (millis: Long) -> Unit,
onDateSelect: (ZonedDateTime) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowDialog: Boolean by rememberSaveable { mutableStateOf(false) }
@ -100,7 +102,16 @@ fun BitwardenDateSelectButton(
confirmButton = {
TextButton(
onClick = {
onDateSelect(requireNotNull(datePickerState.selectedDateMillis))
onDateSelect(
ZonedDateTime
.ofInstant(
Instant.ofEpochMilli(
requireNotNull(datePickerState.selectedDateMillis),
),
ZoneOffset.UTC,
)
.withZoneSameLocal(currentZonedDateTime.zone),
)
shouldShowDialog = false
},
modifier = modifier,

View file

@ -1,15 +1,20 @@
package com.x8bit.bitwarden.ui.tools.feature.send
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
@ -17,17 +22,27 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
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 kotlinx.collections.immutable.toImmutableList
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/**
* Displays UX for choosing deletion date of a send.
*
* TODO: Implement custom date choosing and send choices to the VM: BIT-1090.
*/
@Suppress("LongMethod")
@Composable
fun SendDeletionDateChooser(
currentZonedDateTime: ZonedDateTime,
dateFormatPattern: String,
timeFormatPattern: String,
onDateSelect: (ZonedDateTime) -> Unit,
modifier: Modifier = Modifier,
) {
val customOption = stringResource(id = R.string.custom)
val options = listOf(
stringResource(id = R.string.one_hour),
stringResource(id = R.string.one_day),
@ -35,7 +50,7 @@ fun SendDeletionDateChooser(
stringResource(id = R.string.three_days),
stringResource(id = R.string.seven_days),
stringResource(id = R.string.thirty_days),
stringResource(id = R.string.custom),
customOption,
)
val defaultOption = stringResource(id = R.string.seven_days)
var selectedOption: String by rememberSaveable { mutableStateOf(defaultOption) }
@ -48,6 +63,52 @@ fun SendDeletionDateChooser(
selectedOption = selectedOption,
onOptionSelected = { selectedOption = it },
)
AnimatedVisibility(visible = selectedOption == customOption) {
// 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.hour.hours.inWholeMilliseconds +
currentZonedDateTime.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.deletion_date_info),

View file

@ -42,6 +42,7 @@ 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
import java.time.ZonedDateTime
/**
* Content view for the [AddSendScreen].
@ -154,6 +155,7 @@ fun AddSendContent(
onNoteChange = addSendHandlers.onNoteChange,
onHideEmailChecked = addSendHandlers.onHideEmailToggle,
onDeactivateSendChecked = addSendHandlers.onDeactivateSendToggle,
onDeletionDateChange = addSendHandlers.onDeletionDateChange,
)
Spacer(modifier = Modifier.height(24.dp))
@ -180,6 +182,7 @@ private fun AddSendOptions(
onNoteChange: (String) -> Unit,
onHideEmailChecked: (Boolean) -> Unit,
onDeactivateSendChecked: (Boolean) -> Unit,
onDeletionDateChange: (ZonedDateTime) -> Unit,
) {
var isExpanded by rememberSaveable { mutableStateOf(false) }
Row(
@ -223,6 +226,10 @@ private fun AddSendOptions(
Column {
SendDeletionDateChooser(
modifier = Modifier.padding(horizontal = 16.dp),
dateFormatPattern = state.common.dateFormatPattern,
timeFormatPattern = state.common.timeFormatPattern,
currentZonedDateTime = state.common.deletionDate,
onDateSelect = onDeletionDateChange,
)
Spacer(modifier = Modifier.height(8.dp))
SendExpirationDateChooser(

View file

@ -23,16 +23,12 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.Instant
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* The default amount of time in the future the deletion date should be set to.
*/
private const val DELETION_DATE_OFFSET_SECONDS = 604_800L
/**
* View model for the new send screen.
*/
@ -54,7 +50,11 @@ class AddSendViewModel @Inject constructor(
noteInput = "",
isHideEmailChecked = false,
isDeactivateChecked = false,
deletionDate = clock.instant().plusSeconds(DELETION_DATE_OFFSET_SECONDS),
deletionDate = ZonedDateTime
.now(clock)
// We want the default time to be midnight, so we remove all values beyond days
.truncatedTo(ChronoUnit.DAYS)
.plusWeeks(1),
expirationDate = null,
),
selectedType = AddSendState.ViewState.Content.SendType.Text(
@ -82,6 +82,7 @@ class AddSendViewModel @Inject constructor(
override fun handleAction(action: AddSendAction): Unit = when (action) {
is AddSendAction.CloseClick -> handleCloseClick()
is AddSendAction.DeletionDateChange -> handleDeletionDateChange(action)
AddSendAction.DismissDialogClick -> handleDismissDialogClick()
is AddSendAction.SaveClick -> handleSaveClick()
is AddSendAction.FileTypeClick -> handleFileTypeClick()
@ -162,6 +163,12 @@ class AddSendViewModel @Inject constructor(
private fun handleCloseClick() = sendEvent(AddSendEvent.NavigateBack)
private fun handleDeletionDateChange(action: AddSendAction.DeletionDateChange) {
updateCommonContent {
it.copy(deletionDate = action.deletionDate)
}
}
private fun handleSaveClick() {
onContent { content ->
if (content.common.name.isBlank()) {
@ -185,7 +192,7 @@ class AddSendViewModel @Inject constructor(
)
}
viewModelScope.launch {
val result = vaultRepo.createSend(content.toSendView())
val result = vaultRepo.createSend(content.toSendView(clock))
sendAction(AddSendAction.Internal.CreateSendResultReceive(result))
}
}
@ -347,9 +354,13 @@ data class AddSendState(
val noteInput: String,
val isHideEmailChecked: Boolean,
val isDeactivateChecked: Boolean,
val deletionDate: Instant,
val expirationDate: Instant?,
) : Parcelable
val deletionDate: ZonedDateTime,
val expirationDate: ZonedDateTime?,
) : Parcelable {
val dateFormatPattern: String get() = "M/d/yyyy"
val timeFormatPattern: String get() = "hh:mm a"
}
/**
* Models what type the user is trying to send.
@ -492,6 +503,11 @@ sealed class AddSendAction {
*/
data class DeactivateThisSendToggle(val isChecked: Boolean) : AddSendAction()
/**
* User toggled the "deactivate this send" toggle.
*/
data class DeletionDateChange(val deletionDate: ZonedDateTime) : AddSendAction()
/**
* Models actions that the [AddSendViewModel] itself might send.
*/

View file

@ -2,6 +2,7 @@ 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
import java.time.ZonedDateTime
/**
* A collection of handler functions for managing actions within the context of adding
@ -19,6 +20,7 @@ data class AddSendHandlers(
val onNoteChange: (String) -> Unit,
val onHideEmailToggle: (Boolean) -> Unit,
val onDeactivateSendToggle: (Boolean) -> Unit,
val onDeletionDateChange: (ZonedDateTime) -> Unit,
) {
companion object {
/**
@ -48,6 +50,9 @@ data class AddSendHandlers(
onDeactivateSendToggle = {
viewModel.trySendAction(AddSendAction.DeactivateThisSendToggle(it))
},
onDeletionDateChange = {
viewModel.trySendAction(AddSendAction.DeletionDateChange(it))
},
)
}
}

View file

@ -5,12 +5,14 @@ import com.bitwarden.core.SendTextView
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState
import java.time.Instant
import java.time.Clock
/**
* Transforms [AddSendState] into [SendView].
*/
fun AddSendState.ViewState.Content.toSendView(): SendView =
fun AddSendState.ViewState.Content.toSendView(
clock: Clock,
): SendView =
SendView(
id = null,
accessId = null,
@ -26,9 +28,9 @@ fun AddSendState.ViewState.Content.toSendView(): SendView =
accessCount = 0U,
disabled = common.isDeactivateChecked,
hideEmail = common.isHideEmailChecked,
revisionDate = Instant.now(),
deletionDate = common.deletionDate,
expirationDate = common.expirationDate,
revisionDate = clock.instant(),
deletionDate = common.deletionDate.toInstant(),
expirationDate = common.expirationDate?.toInstant(),
)
private fun AddSendState.ViewState.Content.SendType.toSendType(): SendType =

View file

@ -30,7 +30,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import java.time.Instant
import java.time.ZonedDateTime
class AddSendScreenTest : BaseComposeTest() {
@ -604,7 +604,7 @@ class AddSendScreenTest : BaseComposeTest() {
noteInput = "",
isHideEmailChecked = false,
isDeactivateChecked = false,
deletionDate = Instant.parse("2023-10-27T12:00:00Z"),
deletionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
expirationDate = null,
)

View file

@ -28,13 +28,14 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.util.TimeZone
import java.time.ZoneOffset
import java.time.ZonedDateTime
class AddSendViewModelTest : BaseViewModelTest() {
private val clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
TimeZone.getTimeZone("UTC").toZoneId(),
ZoneOffset.UTC,
)
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val authRepository: AuthRepository = mockk {
@ -89,7 +90,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
)
val initialState = DEFAULT_STATE.copy(viewState = viewState)
val mockSendView = mockk<SendView>()
every { viewState.toSendView() } returns mockSendView
every { viewState.toSendView(clock) } returns mockSendView
val sendUrl = "www.test.com/send/test"
val resultSendView = mockk<SendView> {
every { toSendUrl(DEFAULT_ENVIRONMENT_URL) } returns sendUrl
@ -117,7 +118,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
)
val initialState = DEFAULT_STATE.copy(viewState = viewState)
val mockSendView = mockk<SendView>()
every { viewState.toSendView() } returns mockSendView
every { viewState.toSendView(clock) } returns mockSendView
coEvery { vaultRepository.createSend(mockSendView) } returns CreateSendResult.Error
val viewModel = createViewModel(initialState)
@ -180,6 +181,27 @@ class AddSendViewModelTest : BaseViewModelTest() {
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `DeletionDateChange should store the new deletion date`() {
val viewModel = createViewModel()
val newDeletionDate = ZonedDateTime.parse("2024-09-13T00:00Z")
// DEFAULT deletion date is "2023-11-03T00:00Z"
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
viewModel.trySendAction(AddSendAction.DeletionDateChange(newDeletionDate))
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
deletionDate = newDeletionDate,
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `ChooseFileClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
@ -353,7 +375,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
noteInput = "",
isHideEmailChecked = false,
isDeactivateChecked = false,
deletionDate = Instant.parse("2023-11-03T12:00:00Z"),
deletionDate = ZonedDateTime.parse("2023-11-03T00:00Z"),
expirationDate = null,
)

View file

@ -4,24 +4,15 @@ import com.bitwarden.core.SendFileView
import com.bitwarden.core.SendType
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
class AddSendStateExtensionsTest {
private val fixedInstant: Instant = Instant.parse("2023-10-27T12:00:00Z")
@AfterEach
fun tearDown() {
// Some individual tests call mockkStatic so we will make sure this is always undone.
unmockkStatic(Instant::class)
}
@Test
fun `toSendView should create an appropriate SendView with file type`() {
val sendView = createMockSendView(number = 1, type = SendType.FILE).copy(
@ -37,12 +28,10 @@ class AddSendStateExtensionsTest {
sizeName = "",
),
)
mockkStatic(Instant::class)
every { Instant.now() } returns fixedInstant
val result = DEFAULT_VIEW_STATE
.copy(selectedType = AddSendState.ViewState.Content.SendType.File)
.toSendView()
.toSendView(FIXED_CLOCK)
assertEquals(sendView, result)
}
@ -56,34 +45,35 @@ class AddSendStateExtensionsTest {
accessCount = 0U,
file = null,
)
mockkStatic(Instant::class)
every { Instant.now() } returns fixedInstant
val result = DEFAULT_VIEW_STATE.toSendView()
val result = DEFAULT_VIEW_STATE.toSendView(FIXED_CLOCK)
assertEquals(sendView, result)
}
companion object {
private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common(
name = "mockName-1",
maxAccessCount = 1,
passwordInput = "mockPassword-1",
noteInput = "mockNotes-1",
isHideEmailChecked = false,
isDeactivateChecked = false,
deletionDate = Instant.parse("2023-10-27T12:00:00Z"),
expirationDate = Instant.parse("2023-10-27T12:00:00Z"),
)
private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text(
input = "mockText-1",
isHideByDefaultChecked = false,
)
private val DEFAULT_VIEW_STATE = AddSendState.ViewState.Content(
common = DEFAULT_COMMON_STATE,
selectedType = DEFAULT_SELECTED_TYPE_STATE,
)
}
}
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common(
name = "mockName-1",
maxAccessCount = 1,
passwordInput = "mockPassword-1",
noteInput = "mockNotes-1",
isHideEmailChecked = false,
isDeactivateChecked = false,
deletionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
expirationDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
)
private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text(
input = "mockText-1",
isHideByDefaultChecked = false,
)
private val DEFAULT_VIEW_STATE = AddSendState.ViewState.Content(
common = DEFAULT_COMMON_STATE,
selectedType = DEFAULT_SELECTED_TYPE_STATE,
)