PM-28522: Update the Login With Device Screen (#6184)

This commit is contained in:
David Perez 2025-12-01 10:25:30 -06:00 committed by GitHub
parent f02b374e98
commit ca7a65fc95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 92 additions and 115 deletions

View File

@ -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())
}
}

View File

@ -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()
}
}
/**

View File

@ -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,

View File

@ -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,
)

View File

@ -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 {
{

View File

@ -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,

View File

@ -653,7 +653,8 @@ Do you want to switch to this account?</string>
<string name="invalid_uri">Invalid URI</string>
<string name="the_urix_is_already_blocked">The URI %1$s is already blocked</string>
<string name="login_approved">Login approved</string>
<string name="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?</string>
<string name="log_in_with_device_must_be_set_up_in_the_settings_of_the_bitwarden_app">Log in with device must be set up in the settings of the Bitwarden app.</string>
<string name="need_another_option_view_all_login_options">Need another option? <annotation link="viewAll">View all login options</annotation></string>
<string name="log_in_with_device">Log in with device</string>
<string name="logging_in_on">Logging in on</string>
<string name="logging_in_on_with_colon">Logging in on:</string>
@ -1156,4 +1157,5 @@ Do you want to switch to this account?</string>
<string name="lock_app">Lock app</string>
<string name="use_your_devices_lock_method_to_unlock_the_app">Use your devices lock method to unlock the app</string>
<string name="loading_vault_data">Loading vault data…</string>
<string name="resending">Resending</string>
</resources>