From c16d31fb33b155670fdce269763bccd1e9371a9b Mon Sep 17 00:00:00 2001 From: David Perez Date: Mon, 27 Oct 2025 16:13:36 -0500 Subject: [PATCH] PM-27494: Update custom vault timeout UI (#6085) --- .../accountsecurity/AccountSecurityScreen.kt | 64 ++----- .../AccountSecurityScreenTest.kt | 14 +- .../button/BitwardenTextSelectionButton.kt | 15 +- .../dropdown/BitwardenTimePickerButton.kt | 174 ++++++++++++++++++ ui/src/main/res/values/strings.xml | 2 + 5 files changed, 208 insertions(+), 61 deletions(-) create mode 100644 ui/src/main/kotlin/com/bitwarden/ui/platform/components/dropdown/BitwardenTimePickerButton.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt index 647e687e36..9f30eb48b8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bitwarden.core.data.util.toFormattedPattern import com.bitwarden.ui.platform.base.util.EventsEffect import com.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.bitwarden.ui.platform.components.account.dialog.BitwardenLogoutConfirmationDialog @@ -43,9 +42,9 @@ import com.bitwarden.ui.platform.components.card.BitwardenActionCard import com.bitwarden.ui.platform.components.card.actionCardExitAnimation import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog -import com.bitwarden.ui.platform.components.dialog.BitwardenTimePickerDialog import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton +import com.bitwarden.ui.platform.components.dropdown.BitwardenTimePickerButton import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.components.row.BitwardenExternalLinkRow @@ -73,11 +72,8 @@ import com.x8bit.bitwarden.ui.platform.util.displayLabel import com.x8bit.bitwarden.ui.platform.util.minutes import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import java.time.LocalTime import javax.crypto.Cipher -private const val MINUTES_PER_HOUR = 60 - /** * Displays the account security screen. */ @@ -532,48 +528,22 @@ private fun SessionCustomTimeoutRow( onCustomVaultTimeoutSelect: (VaultTimeout.Custom) -> Unit, modifier: Modifier = Modifier, ) { - var shouldShowTimePickerDialog by rememberSaveable { mutableStateOf(false) } var shouldShowViolatesPoliciesDialog by remember { mutableStateOf(false) } - val vaultTimeoutInMinutes = customVaultTimeout.vaultTimeoutInMinutes - BitwardenTextRow( - text = stringResource(id = BitwardenString.custom), - onClick = { shouldShowTimePickerDialog = true }, + BitwardenTimePickerButton( + label = stringResource(id = BitwardenString.custom_timeout), + totalMinutes = customVaultTimeout.vaultTimeoutInMinutes, + onTimeSelect = { minutes -> + if (vaultTimeoutPolicy?.minutes != null && minutes > vaultTimeoutPolicy.minutes) { + shouldShowViolatesPoliciesDialog = true + } else { + onCustomVaultTimeoutSelect(VaultTimeout.Custom(minutes)) + } + }, + is24Hour = true, + supportingContent = null, cardStyle = CardStyle.Middle(), modifier = modifier, - ) { - Text( - text = LocalTime - .ofSecondOfDay(vaultTimeoutInMinutes * MINUTES_PER_HOUR.toLong()) - .toFormattedPattern(pattern = "HH:mm"), - style = BitwardenTheme.typography.labelSmall, - color = BitwardenTheme.colorScheme.text.primary, - ) - } - - if (shouldShowTimePickerDialog) { - BitwardenTimePickerDialog( - initialHour = vaultTimeoutInMinutes / MINUTES_PER_HOUR, - initialMinute = vaultTimeoutInMinutes.mod(MINUTES_PER_HOUR), - onTimeSelect = { hour, minute -> - shouldShowTimePickerDialog = false - - val totalMinutes = (hour * MINUTES_PER_HOUR) + minute - if (vaultTimeoutPolicy?.minutes != null && - totalMinutes > vaultTimeoutPolicy.minutes - ) { - shouldShowViolatesPoliciesDialog = true - } else { - onCustomVaultTimeoutSelect( - VaultTimeout.Custom( - vaultTimeoutInMinutes = totalMinutes, - ), - ) - } - }, - onDismissRequest = { shouldShowTimePickerDialog = false }, - is24Hour = true, - ) - } + ) if (shouldShowViolatesPoliciesDialog) { BitwardenBasicDialog( @@ -582,11 +552,7 @@ private fun SessionCustomTimeoutRow( onDismissRequest = { shouldShowViolatesPoliciesDialog = false vaultTimeoutPolicy?.minutes?.let { - onCustomVaultTimeoutSelect( - VaultTimeout.Custom( - vaultTimeoutInMinutes = it, - ), - ) + onCustomVaultTimeoutSelect(VaultTimeout.Custom(it)) } }, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt index 32db47aafa..b2865e910f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt @@ -1047,7 +1047,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() { composeTestRule // Check for exact text to differentiate from the Custom label on the Vault Timeout // item above. - .onNode(hasTextExactly("Custom", "00:00")) + .onNode(hasTextExactly("Custom timeout", "0 minutes")) .performScrollTo() .assertIsDisplayed() @@ -1056,7 +1056,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() { } composeTestRule - .onNode(hasTextExactly("Custom", "02:03")) + .onNode(hasTextExactly("Custom timeout", "2 hours, 3 minutes")) .assertIsDisplayed() mutableStateFlow.update { @@ -1064,7 +1064,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() { } composeTestRule - .onNode(hasTextExactly("Custom", "20:34")) + .onNode(hasTextExactly("Custom timeout", "20 hours, 34 minutes")) .assertIsDisplayed() } @@ -1076,7 +1076,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() { it.copy(vaultTimeout = VaultTimeout.Custom(vaultTimeoutInMinutes = 123)) } composeTestRule - .onNode(hasTextExactly("Custom", "02:03")) + .onNode(hasTextExactly("Custom timeout", "2 hours, 3 minutes")) .performScrollTo() .performClick() @@ -1102,7 +1102,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() { it.copy(vaultTimeout = VaultTimeout.Custom(vaultTimeoutInMinutes = 123)) } composeTestRule - .onNode(hasTextExactly("Custom", "02:03")) + .onNode(hasTextExactly("Custom timeout", "2 hours, 3 minutes")) .performScrollTo() .performClick() @@ -1123,7 +1123,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() { it.copy(vaultTimeout = VaultTimeout.Custom(vaultTimeoutInMinutes = 123)) } composeTestRule - .onNode(hasTextExactly("Custom", "02:03")) + .onNode(hasTextExactly("Custom timeout", "2 hours, 3 minutes")) .performScrollTo() .performClick() @@ -1158,7 +1158,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() { ) } composeTestRule - .onNode(hasTextExactly("Custom", "02:03")) + .onNode(hasTextExactly("Custom timeout", "2 hours, 3 minutes")) .performScrollTo() .performClick() diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/button/BitwardenTextSelectionButton.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/button/BitwardenTextSelectionButton.kt index 346a083ae8..169bb2a45a 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/button/BitwardenTextSelectionButton.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/button/BitwardenTextSelectionButton.kt @@ -99,6 +99,7 @@ fun BitwardenTextSelectionButton( cardStyle: CardStyle?, modifier: Modifier = Modifier, enabled: Boolean = true, + showChevron: Boolean = true, tooltip: TooltipData? = null, insets: PaddingValues = PaddingValues(), textFieldTestTag: String? = null, @@ -161,11 +162,15 @@ fun BitwardenTextSelectionButton( BitwardenRowOfActions( modifier = Modifier.padding(paddingValues = actionsPadding), actions = { - Icon( - painter = rememberVectorPainter(id = BitwardenDrawable.ic_chevron_down), - contentDescription = null, - modifier = Modifier.minimumInteractiveComponentSize(), - ) + if (showChevron) { + Icon( + painter = rememberVectorPainter( + id = BitwardenDrawable.ic_chevron_down, + ), + contentDescription = null, + modifier = Modifier.minimumInteractiveComponentSize(), + ) + } actions() }, ) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/dropdown/BitwardenTimePickerButton.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/dropdown/BitwardenTimePickerButton.kt new file mode 100644 index 0000000000..cccc47e66b --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/dropdown/BitwardenTimePickerButton.kt @@ -0,0 +1,174 @@ +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.OutlinedTextField +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.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bitwarden.ui.platform.components.button.BitwardenTextSelectionButton +import com.bitwarden.ui.platform.components.dialog.BitwardenTimePickerDialog +import com.bitwarden.ui.platform.components.model.CardStyle +import com.bitwarden.ui.platform.components.model.TooltipData +import com.bitwarden.ui.platform.resource.BitwardenPlurals +import com.bitwarden.ui.platform.resource.BitwardenString + +private const val MINUTES_PER_HOUR: Int = 60 + +/** + * A button that displays a selected time duration and opens a time picker dialog when clicked. + * + * @param label The descriptive text label for the [OutlinedTextField]. + * @param totalMinutes The currently selected time value in minutes. + * @param onTimeSelect A lambda that is invoked when a time is selected from the menu. + * @param is24Hour Whether or not the time should be displayed in 24-hour format. + * @param cardStyle Indicates the type of card style to be applied. + * @param modifier A [Modifier] that you can use to apply custom modifications to the composable. + * @param isEnabled Whether or not the button is enabled. + * @param supportingContent An optional supporting content that will appear below the button. + * @param tooltip A nullable [TooltipData], representing the tooltip icon. + * @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 app bar's trailing side. This lambda extends [RowScope], allowing flexibility in + * defining the layout of the actions. + */ +@Composable +fun BitwardenTimePickerButton( + label: String, + totalMinutes: Int, + onTimeSelect: (minutes: Int) -> Unit, + is24Hour: Boolean, + cardStyle: CardStyle?, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + supportingContent: @Composable (ColumnScope.() -> Unit)?, + tooltip: TooltipData? = null, + insets: PaddingValues = PaddingValues(), + textFieldTestTag: String? = null, + actionsPadding: PaddingValues = PaddingValues(end = 4.dp), + actions: @Composable RowScope.() -> Unit = {}, +) { + BitwardenTimePickerButton( + label = label, + hours = totalMinutes / MINUTES_PER_HOUR, + minutes = totalMinutes.mod(MINUTES_PER_HOUR), + onTimeSelect = { hour, minute -> onTimeSelect((hour * MINUTES_PER_HOUR) + minute) }, + cardStyle = cardStyle, + is24Hour = is24Hour, + modifier = modifier, + isEnabled = isEnabled, + supportingContent = supportingContent, + tooltip = tooltip, + insets = insets, + textFieldTestTag = textFieldTestTag, + actionsPadding = actionsPadding, + actions = actions, + ) +} + +/** + * A button that displays a selected time duration and opens a time picker dialog when clicked. + * + * @param label The descriptive text label for the [OutlinedTextField]. + * @param hours The currently selected time value in hours. + * @param minutes The currently selected time value in minutes. + * @param onTimeSelect A lambda that is invoked when a time is selected from the menu. + * @param is24Hour Whether or not the time should be displayed in 24-hour format. + * @param cardStyle Indicates the type of card style to be applied. + * @param modifier A [Modifier] that you can use to apply custom modifications to the composable. + * @param isEnabled Whether or not the button is enabled. + * @param supportingContent An optional supporting content that will appear below the button. + * @param tooltip A nullable [TooltipData], representing the tooltip icon. + * @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 app bar's trailing side. This lambda extends [RowScope], allowing flexibility in + * defining the layout of the actions. + */ +@Composable +fun BitwardenTimePickerButton( + label: String, + hours: Int, + minutes: Int, + onTimeSelect: (hour: Int, minute: Int) -> Unit, + is24Hour: Boolean, + cardStyle: CardStyle?, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + supportingContent: @Composable (ColumnScope.() -> Unit)?, + tooltip: TooltipData? = null, + insets: PaddingValues = PaddingValues(), + textFieldTestTag: String? = null, + actionsPadding: PaddingValues = PaddingValues(end = 4.dp), + actions: @Composable RowScope.() -> Unit = {}, +) { + var shouldShowDialog by rememberSaveable { mutableStateOf(value = false) } + BitwardenTextSelectionButton( + label = label, + selectedOption = if (hours != 0 && minutes != 0) { + // Since both hours and minutes are non-zero, we display both of them. + stringResource( + id = BitwardenString.hours_minutes_format, + formatArgs = arrayOf( + pluralStringResource( + id = BitwardenPlurals.hours_format, + count = hours, + formatArgs = arrayOf(hours), + ), + pluralStringResource( + id = BitwardenPlurals.minutes_format, + count = minutes, + formatArgs = arrayOf(minutes), + ), + ), + ) + } else if (hours != 0) { + // Since only hours are non-zero, we only display hours. + pluralStringResource( + id = BitwardenPlurals.hours_format, + count = hours, + formatArgs = arrayOf(hours), + ) + } else { + // We display this if there are only minutes or if both hours and minutes are 0. + pluralStringResource( + id = BitwardenPlurals.minutes_format, + count = minutes, + formatArgs = arrayOf(minutes), + ) + }, + onClick = { shouldShowDialog = true }, + cardStyle = cardStyle, + enabled = isEnabled, + showChevron = false, + supportingContent = supportingContent, + tooltip = tooltip, + insets = insets, + textFieldTestTag = textFieldTestTag, + actionsPadding = actionsPadding, + actions = actions, + modifier = modifier, + ) + if (shouldShowDialog) { + BitwardenTimePickerDialog( + initialHour = hours, + initialMinute = minutes, + onTimeSelect = { hour, minute -> + onTimeSelect(hour, minute) + shouldShowDialog = false + }, + onDismissRequest = { shouldShowDialog = false }, + is24Hour = is24Hour, + ) + } +} diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 751b01285a..855a987f0f 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -479,6 +479,7 @@ Scanning will happen automatically. 7 days 30 days Custom + Custom timeout Add this authenticator key to an existing login, or create a new login. Due to an enterprise policy, you are only able to delete an existing Send. About Send @@ -497,6 +498,7 @@ Scanning will happen automatically. Authenticate WebAuthn Return to app This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password. + %1$s, %2$s %1$d hour %1$d hours