PM-42839, PM-42840, PM-42841, PM-42842: bug: Minor DatePicker bug fixes (#7343)

This commit is contained in:
David Perez authored and GitHub committed 2026-09-02 15:36:58 +00:00
1 parent 301f1069e3
commit 8a3307c042
8 files changed
+200 -89

No files matched your search

@@ -96,6 +96,7 @@ fun LazyListScope.vaultAddEditLicenseItems(
currentDate = licenseState.dateOfBirth,
onDateSelect = licenseHandlers.onDateOfBirthChange,
textFieldTestTag = "LicenseDateOfBirthEntry",
allowFutureDates = false,
cardStyle = CardStyle.Middle(),
modifier = Modifier
.fillMaxWidth()
@@ -148,6 +149,7 @@ fun LazyListScope.vaultAddEditLicenseItems(
currentDate = licenseState.issueDate,
onDateSelect = licenseHandlers.onIssueDateChange,
textFieldTestTag = "LicenseIssueDateEntry",
allowFutureDates = false,
cardStyle = CardStyle.Middle(),
modifier = Modifier
.fillMaxWidth()
@@ -69,6 +69,7 @@ fun LazyListScope.vaultAddEditPassportItems(
currentDate = passportState.dateOfBirth,
onDateSelect = passportHandlers.onDateOfBirthChange,
textFieldTestTag = "PassportDateOfBirthEntry",
allowFutureDates = false,
cardStyle = CardStyle.Middle(),
modifier = Modifier
.fillMaxWidth()
@@ -188,6 +189,7 @@ fun LazyListScope.vaultAddEditPassportItems(
currentDate = passportState.issueDate,
onDateSelect = passportHandlers.onIssueDateChange,
textFieldTestTag = "PassportIssueDateEntry",
allowFutureDates = false,
cardStyle = CardStyle.Middle(),
modifier = Modifier
.fillMaxWidth()
@@ -1010,7 +1010,7 @@ class GeneratorScreenTest : BitwardenComposeTest() {
// Opens the menu
composeTestRule
.onNodeWithContentDescription(label = "null. Service")
.onNodeWithContentDescription(label = "Service")
.performScrollTo()
.performClick()
@@ -2922,7 +2922,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
fun `in ItemType_License the date of birth should display the selected date from the state`() {
mutableStateFlow.value = DEFAULT_STATE_LICENSE
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "null. Date of birth")
.onNodeWithContentDescriptionAfterScroll(label = "Date of birth")
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
@@ -2939,13 +2939,21 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_License clicking the date of birth clear button should trigger DateOfBirthChange with null`() {
mutableStateFlow.value = DEFAULT_STATE_LICENSE
mutableStateFlow.value = DEFAULT_STATE_LICENSE.copy(
viewState = VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(),
type = VaultAddEditState.ViewState.Content.ItemType.License(
dateOfBirth = LocalDate.of(2026, 9, 10),
),
isIndividualVaultDisabled = false,
),
)
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "null. Date of birth")
.performClick()
composeTestRule
.onNodeWithText(text = "Clear")
.onNodeWithContentDescriptionAfterScroll(label = "September 10, 2026. Date of birth")
.onChildren()
.filterToOne(matcher = hasText(text = "Date of birth"))
.onChildren()
.filterToOne(matcher = hasContentDescription(value = "Clear"))
.performClick()
verify {
@@ -2959,7 +2967,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
fun `in ItemType_License the issue date should display the selected date from the state`() {
mutableStateFlow.value = DEFAULT_STATE_LICENSE
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "null. Issue date")
.onNodeWithContentDescriptionAfterScroll(label = "Issue date")
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
@@ -2976,13 +2984,21 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_License clicking the issue date clear button should trigger IssueDateChange with null`() {
mutableStateFlow.value = DEFAULT_STATE_LICENSE
mutableStateFlow.value = DEFAULT_STATE_LICENSE.copy(
viewState = VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(),
type = VaultAddEditState.ViewState.Content.ItemType.License(
issueDate = LocalDate.of(2026, 9, 10),
),
isIndividualVaultDisabled = false,
),
)
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "null. Issue date")
.performClick()
composeTestRule
.onNodeWithText(text = "Clear")
.onNodeWithContentDescriptionAfterScroll(label = "September 10, 2026. Issue date")
.onChildren()
.filterToOne(matcher = hasText(text = "Issue date"))
.onChildren()
.filterToOne(matcher = hasContentDescription(value = "Clear"))
.performClick()
verify {
@@ -2997,7 +3013,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
fun `in ItemType_License the expiration date should display the selected date from the state`() {
mutableStateFlow.value = DEFAULT_STATE_LICENSE
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "null. Expiration date")
.onNodeWithContentDescriptionAfterScroll(label = "Expiration date")
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
@@ -3014,13 +3030,21 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_License clicking the expiration date clear button should trigger ExpirationDateChange with null`() {
mutableStateFlow.value = DEFAULT_STATE_LICENSE
mutableStateFlow.value = DEFAULT_STATE_LICENSE.copy(
viewState = VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(),
type = VaultAddEditState.ViewState.Content.ItemType.License(
expirationDate = LocalDate.of(2026, 9, 10),
),
isIndividualVaultDisabled = false,
),
)
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "null. Expiration date")
.performClick()
composeTestRule
.onNodeWithText(text = "Clear")
.onNodeWithContentDescriptionAfterScroll(label = "September 10, 2026. Expiration date")
.onChildren()
.filterToOne(matcher = hasText(text = "Expiration date"))
.onChildren()
.filterToOne(matcher = hasContentDescription(value = "Clear"))
.performClick()
verify {
@@ -3201,7 +3225,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
fun `in ItemType_Passport the date of birth should display the selected date from the state`() {
mutableStateFlow.value = DEFAULT_STATE_PASSPORT
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "null. Date of birth")
.onNodeWithContentDescriptionAfterScroll(label = "Date of birth")
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
@@ -3218,13 +3242,21 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Passport clicking the date of birth clear button should trigger DateOfBirthChange with null`() {
mutableStateFlow.value = DEFAULT_STATE_PASSPORT
mutableStateFlow.value = DEFAULT_STATE_PASSPORT.copy(
viewState = VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(),
type = VaultAddEditState.ViewState.Content.ItemType.Passport(
dateOfBirth = LocalDate.of(2026, 9, 10),
),
isIndividualVaultDisabled = false,
),
)
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "null. Date of birth")
.performClick()
composeTestRule
.onNodeWithText(text = "Clear")
.onNodeWithContentDescriptionAfterScroll(label = "September 10, 2026. Date of birth")
.onChildren()
.filterToOne(matcher = hasText(text = "Date of birth"))
.onChildren()
.filterToOne(matcher = hasContentDescription(value = "Clear"))
.performClick()
verify {
@@ -3234,12 +3266,11 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Passport the issue date should display the selected date from the state`() {
mutableStateFlow.value = DEFAULT_STATE_PASSPORT
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "null. Issue date")
.onNodeWithContentDescriptionAfterScroll(label = "Issue date")
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
@@ -3256,13 +3287,21 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Passport clicking the issue date clear button should trigger IssueDateChange with null`() {
mutableStateFlow.value = DEFAULT_STATE_PASSPORT
mutableStateFlow.value = DEFAULT_STATE_PASSPORT.copy(
viewState = VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(),
type = VaultAddEditState.ViewState.Content.ItemType.Passport(
issueDate = LocalDate.of(2026, 9, 10),
),
isIndividualVaultDisabled = false,
),
)
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "null. Issue date")
.performClick()
composeTestRule
.onNodeWithText(text = "Clear")
.onNodeWithContentDescriptionAfterScroll(label = "September 10, 2026. Issue date")
.onChildren()
.filterToOne(matcher = hasText(text = "Issue date"))
.onChildren()
.filterToOne(matcher = hasContentDescription(value = "Clear"))
.performClick()
verify {
@@ -3277,7 +3316,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
fun `in ItemType_Passport the expiration date should display the selected date from the state`() {
mutableStateFlow.value = DEFAULT_STATE_PASSPORT
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "null. Expiration date")
.onNodeWithContentDescriptionAfterScroll(label = "Expiration date")
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
@@ -3294,13 +3333,21 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Passport clicking the expiration date clear button should trigger ExpirationDateChange with null`() {
mutableStateFlow.value = DEFAULT_STATE_PASSPORT
mutableStateFlow.value = DEFAULT_STATE_PASSPORT.copy(
viewState = VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(),
type = VaultAddEditState.ViewState.Content.ItemType.Passport(
expirationDate = LocalDate.of(2026, 9, 10),
),
isIndividualVaultDisabled = false,
),
)
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "null. Expiration date")
.performClick()
composeTestRule
.onNodeWithText(text = "Clear")
.onNodeWithContentDescriptionAfterScroll(label = "September 10, 2026. Expiration date")
.onChildren()
.filterToOne(matcher = hasText(text = "Expiration date"))
.onChildren()
.filterToOne(matcher = hasContentDescription(value = "Clear"))
.performClick()
verify {
@@ -23,6 +23,7 @@ import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
@@ -113,7 +114,7 @@ fun BitwardenTextSelectionButton(
.defaultMinSize(minHeight = 60.dp)
.semantics {
role = semanticRole
contentDescription = "$selectedOption. $label"
contentDescription = "${selectedOption?.let { "$it. " }.orEmpty()}$label"
customActions = persistentListOfNotNull(
helpData?.let {
CustomAccessibilityAction(
@@ -180,6 +181,7 @@ fun BitwardenTextSelectionButton(
bitwardenTextFieldColors()
},
modifier = Modifier
.semantics { this.hideFromAccessibility() }
.nullableTestTag(tag = textFieldTestTag)
.fillMaxWidth(),
)
@@ -4,8 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
@@ -16,6 +15,7 @@ import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Surface
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
@@ -29,8 +29,10 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.field.color.bitwardenTextFieldColors
import com.bitwarden.ui.platform.composition.LocalClock
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import java.time.Clock
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
@@ -41,6 +43,10 @@ import java.time.ZoneOffset
* @param initialDate The initial [LocalDate] to display.
* @param onDateSelect The callback invoked with the selected [LocalDate] when the user confirms.
* @param onDismissRequest The callback invoked when the dialog is dismissed.
* @param allowPastDates Indicates that this date picker allows past dates.
* @param allowFutureDates Indicates that this date picker allows future dates.
* @param allowToday Indicates that this date picker allows this date to be selected.
* @param clock The system clock.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@@ -49,12 +55,40 @@ fun BitwardenDatePickerDialog(
initialDate: LocalDate?,
onDateSelect: (LocalDate?) -> Unit,
onDismissRequest: () -> Unit,
allowPastDates: Boolean = true,
allowFutureDates: Boolean = true,
allowToday: Boolean = true,
clock: Clock = LocalClock.current,
) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = initialDate
?.atStartOfDay(ZoneOffset.UTC)
?.toInstant()
?.toEpochMilli(),
selectableDates = object : SelectableDates {
override fun isSelectableDate(
utcTimeMillis: Long,
): Boolean {
val today = LocalDate.now(clock)
val date = Instant.ofEpochMilli(utcTimeMillis).atZone(ZoneOffset.UTC).toLocalDate()
return when {
date.isBefore(today) -> allowPastDates
date.isAfter(today) -> allowFutureDates
else -> allowToday
}
}
override fun isSelectableYear(
year: Int,
): Boolean {
val clockYear = clock.instant().atZone(clock.zone).year
return when {
clockYear > year -> allowPastDates
clockYear < year -> allowFutureDates
else -> true
}
}
},
)
BasicAlertDialog(
onDismissRequest = onDismissRequest,
@@ -86,38 +120,32 @@ fun BitwardenDatePickerDialog(
colors = colors,
)
}
Row(modifier = Modifier.padding(bottom = 8.dp, start = 12.dp)) {
FlowRow(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp, start = 16.dp, end = 16.dp),
) {
BitwardenTextButton(
label = stringResource(id = BitwardenString.clear),
onClick = { onDateSelect(null) },
modifier = Modifier.testTag(tag = "ClearAlertButton"),
label = stringResource(id = BitwardenString.cancel),
onClick = onDismissRequest,
modifier = Modifier.testTag(tag = "DismissAlertButton"),
)
BitwardenTextButton(
label = stringResource(id = BitwardenString.okay),
onClick = {
datePickerState
.selectedDateMillis
?.let { millis ->
Instant
.ofEpochMilli(millis)
.atZone(ZoneOffset.UTC)
.toLocalDate()
}
.let(onDateSelect)
},
modifier = Modifier.testTag(tag = "AcceptAlertButton"),
)
Spacer(modifier = Modifier.weight(weight = 1f))
FlowRow(
horizontalArrangement = Arrangement.End,
modifier = Modifier.padding(horizontal = 8.dp),
) {
BitwardenTextButton(
label = stringResource(id = BitwardenString.cancel),
onClick = onDismissRequest,
modifier = Modifier.testTag(tag = "DismissAlertButton"),
)
BitwardenTextButton(
label = stringResource(id = BitwardenString.okay),
onClick = {
datePickerState
.selectedDateMillis
?.let { millis ->
Instant
.ofEpochMilli(millis)
.atZone(ZoneOffset.UTC)
.toLocalDate()
}
.let(onDateSelect)
},
modifier = Modifier.testTag(tag = "AcceptAlertButton"),
)
}
}
}
}
@@ -2,22 +2,30 @@ package com.bitwarden.ui.platform.components.dropdown
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.core.data.util.toFormattedDateStyle
import com.bitwarden.ui.platform.components.animation.AnimateNullableContentVisibility
import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.bitwarden.ui.platform.components.button.BitwardenTextSelectionButton
import com.bitwarden.ui.platform.components.button.model.BitwardenHelpButtonData
import com.bitwarden.ui.platform.components.dialog.BitwardenDatePickerDialog
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalClock
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import java.time.Clock
import java.time.LocalDate
@@ -36,10 +44,10 @@ import java.time.format.FormatStyle
* @param helpData An optional [BitwardenHelpButtonData], representing the help button.
* @param insets Inner padding to be applied within the card.
* @param textFieldTestTag The optional test tag associated with the inner text field.
* @param actionsPadding Padding to be applied to the [actions] block.
* @param actions A lambda containing the set of actions (usually icons or similar) to display
* in the trailing side. This lambda extends [RowScope], allowing flexibility in defining the
* layout of the actions.
* @param allowPastDates Indicates that the date picker allows past dates.
* @param allowFutureDates Indicates that the date picker allows future dates.
* @param allowToday Indicates that this date picker allows this date to be selected.
* @param clock The system clock.
*/
@Composable
fun BitwardenDatePickerButton(
@@ -53,11 +61,13 @@ fun BitwardenDatePickerButton(
helpData: BitwardenHelpButtonData? = null,
insets: PaddingValues = PaddingValues(),
textFieldTestTag: String? = null,
actionsPadding: PaddingValues = PaddingValues(end = 4.dp),
allowPastDates: Boolean = true,
allowFutureDates: Boolean = true,
allowToday: Boolean = true,
clock: Clock = LocalClock.current,
actions: @Composable RowScope.() -> Unit = {},
) {
var shouldShowDialog by rememberSaveable { mutableStateOf(value = false) }
val openCalendarString = stringResource(id = BitwardenString.open_calendar)
BitwardenTextSelectionButton(
label = label,
selectedOption = currentDate?.toFormattedDateStyle(
@@ -67,14 +77,29 @@ fun BitwardenDatePickerButton(
onClick = { shouldShowDialog = true },
cardStyle = cardStyle,
enabled = isEnabled,
showChevron = true,
showChevron = false,
supportingContent = supportingContent,
helpData = helpData,
insets = insets,
textFieldTestTag = textFieldTestTag,
actionsPadding = actionsPadding,
actions = actions,
modifier = modifier,
actions = {
AnimateNullableContentVisibility(targetState = currentDate) {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_clear,
contentDescription = stringResource(id = BitwardenString.clear),
onClick = { onDateSelect(null) },
isEnabled = isEnabled,
)
}
Icon(
painter = rememberVectorPainter(id = BitwardenDrawable.ic_chevron_down),
contentDescription = null,
modifier = Modifier.minimumInteractiveComponentSize(),
)
},
modifier = modifier.semantics(mergeDescendants = true) {
this.onClick(label = openCalendarString, action = null)
},
)
if (shouldShowDialog) {
BitwardenDatePickerDialog(
@@ -84,6 +109,10 @@ fun BitwardenDatePickerButton(
shouldShowDialog = false
},
onDismissRequest = { shouldShowDialog = false },
allowPastDates = allowPastDates,
allowFutureDates = allowFutureDates,
allowToday = allowToday,
clock = clock,
)
}
}
+1
View File
@@ -363,6 +363,7 @@ Scanning will happen automatically.</string>
<string name="passphrase">Passphrase</string>
<string name="word_separator">Word separator</string>
<string name="clear">Clear</string>
<string name="open_calendar">open calendar</string>
<string name="generator">Generator</string>
<string name="no_folders_to_list">There are no folders to list.</string>
<string name="fingerprint_phrase">Fingerprint phrase</string>