From ca7a65fc9589e98fe3728e276f5c5f23ce8a84b0 Mon Sep 17 00:00:00 2001 From: David Perez Date: Mon, 1 Dec 2025 10:25:30 -0600 Subject: [PATCH] PM-28522: Update the Login With Device Screen (#6184) --- .../loginwithdevice/LoginWithDeviceScreen.kt | 122 ++++++++---------- .../LoginWithDeviceViewModel.kt | 54 +++----- .../LoginWithDeviceScreenTest.kt | 7 +- .../LoginWithDeviceViewModelTest.kt | 7 +- .../components/field/BitwardenTextField.kt | 8 +- .../field/color/BitwardenTextFieldColors.kt | 5 +- ui/src/main/res/values/strings.xml | 4 +- 7 files changed, 92 insertions(+), 115 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt index 1b2c30e0fb..368126de3d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt @@ -1,16 +1,11 @@ package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api @@ -20,7 +15,6 @@ import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag @@ -30,13 +24,17 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitwarden.ui.platform.base.util.EventsEffect +import com.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog -import com.bitwarden.ui.platform.components.indicator.BitwardenCircularProgressIndicator +import com.bitwarden.ui.platform.components.field.BitwardenTextField +import com.bitwarden.ui.platform.components.field.model.TextToolbarType +import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold -import com.bitwarden.ui.platform.components.text.BitwardenClickableText +import com.bitwarden.ui.platform.components.text.BitwardenHyperTextLink import com.bitwarden.ui.platform.components.util.rememberVectorPainter import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString @@ -120,111 +118,99 @@ private fun LoginWithDeviceScreenContent( modifier = modifier .verticalScroll(rememberScrollState()), ) { + Spacer(modifier = Modifier.height(height = 24.dp)) + Text( text = state.title(), - textAlign = TextAlign.Start, - style = BitwardenTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + style = BitwardenTheme.typography.titleMedium, color = BitwardenTheme.colorScheme.text.primary, modifier = Modifier - .padding(horizontal = 16.dp) + .standardHorizontalMargin() .fillMaxWidth(), ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(height = 12.dp)) Text( text = state.subtitle(), - textAlign = TextAlign.Start, + textAlign = TextAlign.Center, style = BitwardenTheme.typography.bodyMedium, color = BitwardenTheme.colorScheme.text.primary, modifier = Modifier - .padding(horizontal = 16.dp) + .standardHorizontalMargin() .fillMaxWidth(), ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(height = 12.dp)) Text( text = state.description(), - textAlign = TextAlign.Start, + textAlign = TextAlign.Center, style = BitwardenTheme.typography.bodyMedium, color = BitwardenTheme.colorScheme.text.primary, modifier = Modifier - .padding(horizontal = 16.dp) + .standardHorizontalMargin() .fillMaxWidth(), ) Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(id = BitwardenString.fingerprint_phrase), - textAlign = TextAlign.Start, - style = BitwardenTheme.typography.titleLarge, - color = BitwardenTheme.colorScheme.text.primary, + BitwardenTextField( + label = stringResource(id = BitwardenString.fingerprint_phrase), + value = state.fingerprintPhrase, + textFieldTestTag = "FingerprintPhraseValue", + onValueChange = { }, + readOnly = true, + singleLine = false, + textToolbarType = TextToolbarType.NONE, + textStyle = BitwardenTheme.typography.sensitiveInfoSmall, + textColor = BitwardenTheme.colorScheme.text.codePink, + cardStyle = CardStyle.Full, modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - ) - - Spacer(modifier = Modifier.height(12.dp)) - - Text( - text = state.fingerprintPhrase, - textAlign = TextAlign.Start, - color = BitwardenTheme.colorScheme.text.codePink, - style = BitwardenTheme.typography.sensitiveInfoSmall, - minLines = 2, - modifier = Modifier - .testTag("FingerprintPhraseValue") - .padding(horizontal = 16.dp) + .standardHorizontalMargin() .fillMaxWidth(), ) if (state.allowsResend) { - Column( - verticalArrangement = Arrangement.Center, + Spacer(modifier = Modifier.height(height = 24.dp)) + BitwardenOutlinedButton( + label = stringResource(id = BitwardenString.resend_notification), + onClick = onResendNotificationClick, modifier = Modifier - .defaultMinSize(minHeight = 40.dp) - .align(Alignment.Start), - ) { - if (state.isResendNotificationLoading) { - BitwardenCircularProgressIndicator( - modifier = Modifier - .padding(horizontal = 64.dp) - .size(size = 16.dp), - ) - } else { - BitwardenClickableText( - modifier = Modifier.testTag("ResendNotificationButton"), - label = stringResource(id = BitwardenString.resend_notification), - style = BitwardenTheme.typography.labelLarge, - innerPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp), - onClick = onResendNotificationClick, - ) - } - } + .testTag(tag = "ResendNotificationButton") + .standardHorizontalMargin() + .fillMaxWidth(), + ) } - Spacer(modifier = Modifier.height(28.dp)) + Spacer(modifier = Modifier.height(height = 24.dp)) Text( text = state.otherOptions(), - textAlign = TextAlign.Start, - style = BitwardenTheme.typography.bodyMedium, - color = BitwardenTheme.colorScheme.text.primary, + textAlign = TextAlign.Center, + style = BitwardenTheme.typography.bodySmall, + color = BitwardenTheme.colorScheme.text.secondary, modifier = Modifier - .padding(horizontal = 16.dp) + .standardHorizontalMargin() .fillMaxWidth(), ) - BitwardenClickableText( - modifier = Modifier.testTag("ViewAllLoginOptionsButton"), - label = stringResource(id = BitwardenString.view_all_login_options), - innerPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp), - style = BitwardenTheme.typography.labelLarge, + Spacer(modifier = Modifier.height(height = 12.dp)) + + BitwardenHyperTextLink( + annotatedResId = BitwardenString.need_another_option_view_all_login_options, + annotationKey = "viewAll", + accessibilityString = stringResource(id = BitwardenString.view_all_login_options), onClick = onViewAllLogInOptionsClick, + style = BitwardenTheme.typography.bodySmall, + modifier = Modifier + .testTag(tag = "ViewAllLoginOptionsButton") + .standardHorizontalMargin() + .fillMaxWidth(), ) + Spacer(modifier = Modifier.height(height = 12.dp)) Spacer(modifier = Modifier.navigationBarsPadding()) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt index 5bca77ce3b..4ed386b42b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt @@ -52,7 +52,7 @@ class LoginWithDeviceViewModel @Inject constructor( private var authJob: Job = Job().apply { complete() } init { - sendNewAuthRequest(isResend = false) + sendNewAuthRequest() } override fun handleAction(action: LoginWithDeviceAction) { @@ -74,7 +74,14 @@ class LoginWithDeviceViewModel @Inject constructor( } private fun handleResendNotificationClicked() { - sendNewAuthRequest(isResend = true) + mutableStateFlow.update { + it.copy( + dialogState = LoginWithDeviceState.DialogState.Loading( + message = BitwardenString.resending.asText(), + ), + ) + } + sendNewAuthRequest() } private fun handleViewAllLogInOptionsClicked() { @@ -99,9 +106,6 @@ class LoginWithDeviceViewModel @Inject constructor( ) { when (val result = action.result) { is CreateAuthRequestResult.Success -> { - updateContent { content -> - content.copy(isResendNotificationLoading = false) - } mutableStateFlow.update { it.copy( dialogState = null, @@ -123,7 +127,6 @@ class LoginWithDeviceViewModel @Inject constructor( viewState = LoginWithDeviceState.ViewState.Content( loginWithDeviceType = it.loginWithDeviceType, fingerprintPhrase = result.authRequest.fingerprint, - isResendNotificationLoading = false, ), dialogState = null, ) @@ -131,9 +134,6 @@ class LoginWithDeviceViewModel @Inject constructor( } is CreateAuthRequestResult.Error -> { - updateContent { content -> - content.copy(isResendNotificationLoading = false) - } mutableStateFlow.update { it.copy( dialogState = LoginWithDeviceState.DialogState.Error( @@ -149,9 +149,6 @@ class LoginWithDeviceViewModel @Inject constructor( CreateAuthRequestResult.Declined -> Unit CreateAuthRequestResult.Expired -> { - updateContent { content -> - content.copy(isResendNotificationLoading = false) - } mutableStateFlow.update { it.copy( dialogState = LoginWithDeviceState.DialogState.Error( @@ -279,8 +276,7 @@ class LoginWithDeviceViewModel @Inject constructor( } } - private fun sendNewAuthRequest(isResend: Boolean) { - setIsResendNotificationLoading(isResend) + private fun sendNewAuthRequest() { authJob.cancel() authJob = authRepository .createAuthRequestWithUpdates( @@ -291,22 +287,6 @@ class LoginWithDeviceViewModel @Inject constructor( .onEach(::sendAction) .launchIn(viewModelScope) } - - private fun setIsResendNotificationLoading(isResend: Boolean) { - updateContent { it.copy(isResendNotificationLoading = isResend) } - } - - private inline fun updateContent( - crossinline block: ( - LoginWithDeviceState.ViewState.Content, - ) -> LoginWithDeviceState.ViewState.Content?, - ) { - val currentViewState = state.viewState - val updatedContent = (currentViewState as? LoginWithDeviceState.ViewState.Content) - ?.let(block) - ?: return - mutableStateFlow.update { it.copy(viewState = updatedContent) } - } } /** @@ -349,13 +329,10 @@ data class LoginWithDeviceState( * Content state for the [LoginWithDeviceScreen] showing the actual content or items. * * @property fingerprintPhrase The fingerprint phrase to present to the user. - * @property isResendNotificationLoading Indicates if the resend loading spinner should be - * displayed. */ @Parcelize data class Content( val fingerprintPhrase: String, - val isResendNotificationLoading: Boolean, private val loginWithDeviceType: LoginWithDeviceType, ) : ViewState() { /** @@ -401,14 +378,19 @@ data class LoginWithDeviceState( /** * The text to display indicating that there are other option for logging in. */ - @Suppress("MaxLineLength") val otherOptions: Text get() = when (loginWithDeviceType) { LoginWithDeviceType.OTHER_DEVICE, LoginWithDeviceType.SSO_OTHER_DEVICE, - -> BitwardenString.log_in_with_device_must_be_set_up_in_the_settings_of_the_bitwarden_app_need_another_option.asText() + -> { + BitwardenString + .log_in_with_device_must_be_set_up_in_the_settings_of_the_bitwarden_app + .asText() + } - LoginWithDeviceType.SSO_ADMIN_APPROVAL -> BitwardenString.trouble_logging_in.asText() + LoginWithDeviceType.SSO_ADMIN_APPROVAL -> { + BitwardenString.trouble_logging_in.asText() + } } /** diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt index d775790033..14f4c01895 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performFirstLinkClick import androidx.compose.ui.test.performScrollTo import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.ui.platform.manager.IntentManager @@ -92,7 +93,10 @@ class LoginWithDeviceScreenTest : BitwardenComposeTest() { @Test fun `view all log in options click should send ViewAllLogInOptionsClick action`() { - composeTestRule.onNodeWithText("View all log in options").performScrollTo().performClick() + composeTestRule + .onNodeWithText(text = "Need another option? View all login options") + .performScrollTo() + .performFirstLinkClick() verify { viewModel.trySendAction(LoginWithDeviceAction.ViewAllLogInOptionsClick) } @@ -168,7 +172,6 @@ private val DEFAULT_STATE = LoginWithDeviceState( emailAddress = EMAIL, viewState = LoginWithDeviceState.ViewState.Content( fingerprintPhrase = "alabster-drinkable-mystified-rapping-irrigate", - isResendNotificationLoading = false, loginWithDeviceType = LoginWithDeviceType.OTHER_DEVICE, ), dialogState = null, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt index b42aee2eeb..f51b3dd711 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt @@ -121,8 +121,8 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { viewModel.trySendAction(LoginWithDeviceAction.ResendNotificationClick) assertEquals( DEFAULT_STATE.copy( - viewState = DEFAULT_CONTENT_VIEW_STATE.copy( - isResendNotificationLoading = true, + dialogState = LoginWithDeviceState.DialogState.Loading( + message = BitwardenString.resending.asText(), ), ), viewModel.stateFlow.value, @@ -610,7 +610,6 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { DEFAULT_STATE.copy( viewState = DEFAULT_CONTENT_VIEW_STATE.copy( fingerprintPhrase = FINGERPRINT, - isResendNotificationLoading = false, ), dialogState = LoginWithDeviceState.DialogState.Error( title = BitwardenString.an_error_has_occurred.asText(), @@ -661,7 +660,6 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { DEFAULT_STATE.copy( viewState = DEFAULT_CONTENT_VIEW_STATE.copy( fingerprintPhrase = FINGERPRINT, - isResendNotificationLoading = false, ), dialogState = LoginWithDeviceState.DialogState.Error( title = null, @@ -693,7 +691,6 @@ private const val FINGERPRINT = "fingerprint" private val DEFAULT_CONTENT_VIEW_STATE = LoginWithDeviceState.ViewState.Content( fingerprintPhrase = FINGERPRINT, - isResendNotificationLoading = false, loginWithDeviceType = LoginWithDeviceType.OTHER_DEVICE, ) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenTextField.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenTextField.kt index 7abeab85a5..1f353bee02 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenTextField.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenTextField.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalFocusManager @@ -94,6 +95,7 @@ import kotlinx.collections.immutable.toImmutableList * @param readOnly `true` if the input should be read-only and not accept user interactions. * @param enabled Whether or not the text field is enabled. * @param textStyle An optional style that may be used to override the default used. + * @param textColor An optional color that may be used to override the text color. * @param shouldAddCustomLineBreaks If `true`, line breaks will be inserted to allow for filling * an entire line before breaking. `false` by default. * @param visualTransformation Transforms the visual representation of the input [value]. @@ -123,6 +125,7 @@ fun BitwardenTextField( readOnly: Boolean = false, enabled: Boolean = true, textStyle: TextStyle = BitwardenTheme.typography.bodyLarge, + textColor: Color = BitwardenTheme.colorScheme.text.primary, shouldAddCustomLineBreaks: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, keyboardActions: KeyboardActions = KeyboardActions.Default, @@ -158,6 +161,7 @@ fun BitwardenTextField( readOnly = readOnly, enabled = enabled, textStyle = textStyle, + textColor = textColor, shouldAddCustomLineBreaks = shouldAddCustomLineBreaks, keyboardType = keyboardType, keyboardActions = keyboardActions, @@ -194,6 +198,7 @@ fun BitwardenTextField( * @param readOnly `true` if the input should be read-only and not accept user interactions. * @param enabled Whether or not the text field is enabled. * @param textStyle An optional style that may be used to override the default used. + * @param textColor An optional color that may be used to override the text color. * @param shouldAddCustomLineBreaks If `true`, line breaks will be inserted to allow for filling * an entire line before breaking. `false` by default. * @param visualTransformation Transforms the visual representation of the input [value]. @@ -226,6 +231,7 @@ fun BitwardenTextField( readOnly: Boolean = false, enabled: Boolean = true, textStyle: TextStyle = BitwardenTheme.typography.bodyLarge, + textColor: Color = BitwardenTheme.colorScheme.text.primary, shouldAddCustomLineBreaks: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, keyboardActions: KeyboardActions = KeyboardActions.Default, @@ -312,7 +318,7 @@ fun BitwardenTextField( var focused by remember { mutableStateOf(false) } TextField( - colors = bitwardenTextFieldColors(), + colors = bitwardenTextFieldColors(textColor = textColor), enabled = enabled, label = label?.let { { diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/color/BitwardenTextFieldColors.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/color/BitwardenTextFieldColors.kt index 8e1066aabc..856827b3e9 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/color/BitwardenTextFieldColors.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/color/BitwardenTextFieldColors.kt @@ -24,6 +24,7 @@ fun bitwardenTextFieldButtonColors(): TextFieldColors = bitwardenTextFieldColors */ @Composable fun bitwardenTextFieldColors( + textColor: Color = BitwardenTheme.colorScheme.text.primary, disabledTextColor: Color = BitwardenTheme.colorScheme.filledButton.foregroundDisabled, disabledLeadingIconColor: Color = BitwardenTheme.colorScheme.filledButton.foregroundDisabled, disabledTrailingIconColor: Color = BitwardenTheme.colorScheme.filledButton.foregroundDisabled, @@ -31,8 +32,8 @@ fun bitwardenTextFieldColors( disabledPlaceholderColor: Color = BitwardenTheme.colorScheme.text.secondary, disabledSupportingTextColor: Color = BitwardenTheme.colorScheme.filledButton.foregroundDisabled, ): TextFieldColors = TextFieldColors( - focusedTextColor = BitwardenTheme.colorScheme.text.primary, - unfocusedTextColor = BitwardenTheme.colorScheme.text.primary, + focusedTextColor = textColor, + unfocusedTextColor = textColor, disabledTextColor = disabledTextColor, errorTextColor = BitwardenTheme.colorScheme.text.primary, focusedContainerColor = Color.Transparent, diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 8a371f3323..8f61d161cd 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -653,7 +653,8 @@ Do you want to switch to this account? Invalid URI The URI %1$s is already blocked Login approved - Log in with device must be set up in the settings of the Bitwarden app. Need another option? + Log in with device must be set up in the settings of the Bitwarden app. + Need another option? View all login options Log in with device Logging in on Logging in on: @@ -1156,4 +1157,5 @@ Do you want to switch to this account? Lock app Use your device’s lock method to unlock the app Loading vault data… + Resending