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