diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 6cb11dceb2..ea620f5852 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -16,6 +16,8 @@ import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestination import com.x8bit.bitwarden.ui.auth.feature.login.loginDestination import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin +import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.loginWithDeviceDestination +import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice const val AUTH_GRAPH_ROUTE: String = "auth_graph" @@ -57,6 +59,10 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { loginDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToEnterpriseSignOn = { navController.navigateToEnterpriseSignOn() }, + onNavigateToLoginWithDevice = { navController.navigateToLoginWithDevice() }, + ) + loginWithDeviceDestination( + onNavigateBack = { navController.popBackStack() }, ) environmentDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt index f192dd16a4..c982a67344 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt @@ -45,6 +45,7 @@ fun NavController.navigateToLogin( fun NavGraphBuilder.loginDestination( onNavigateBack: () -> Unit, onNavigateToEnterpriseSignOn: () -> Unit, + onNavigateToLoginWithDevice: () -> Unit, ) { composable( route = LOGIN_ROUTE, @@ -63,6 +64,7 @@ fun NavGraphBuilder.loginDestination( LoginScreen( onNavigateBack = onNavigateBack, onNavigateToEnterpriseSignOn = onNavigateToEnterpriseSignOn, + onNavigateToLoginWithDevice = onNavigateToLoginWithDevice, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt index a79684f9ce..e489911b04 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt @@ -42,6 +42,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog +import com.x8bit.bitwarden.ui.platform.components.BitwardenClickableText import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButtonWithIcon @@ -63,6 +64,7 @@ import kotlinx.collections.immutable.toImmutableList fun LoginScreen( onNavigateBack: () -> Unit, onNavigateToEnterpriseSignOn: () -> Unit, + onNavigateToLoginWithDevice: () -> Unit, viewModel: LoginViewModel = hiltViewModel(), intentHandler: IntentHandler = IntentHandler(context = LocalContext.current), ) { @@ -76,6 +78,7 @@ fun LoginScreen( } LoginEvent.NavigateToEnterpriseSignOn -> onNavigateToEnterpriseSignOn() + LoginEvent.NavigateToLoginWithDevice -> onNavigateToLoginWithDevice() is LoginEvent.ShowToast -> { Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() } @@ -132,6 +135,9 @@ fun LoginScreen( onLoginButtonClick = remember(viewModel) { { viewModel.trySendAction(LoginAction.LoginButtonClick) } }, + onLoginWithDeviceClick = remember(viewModel) { + { viewModel.trySendAction(LoginAction.LoginWithDeviceButtonClick) } + }, onSingleSignOnClick = remember(viewModel) { { viewModel.trySendAction(LoginAction.SingleSignOnClick) } }, @@ -176,6 +182,7 @@ private fun LoginScreenContent( onPasswordInputChanged: (String) -> Unit, onMasterPasswordClick: () -> Unit, onLoginButtonClick: () -> Unit, + onLoginWithDeviceClick: () -> Unit, onSingleSignOnClick: () -> Unit, onNotYouButtonClick: () -> Unit, modifier: Modifier = Modifier, @@ -236,6 +243,18 @@ private fun LoginScreenContent( Spacer(modifier = Modifier.height(12.dp)) + // TODO BIT-808: Hide button for first-time users + BitwardenOutlinedButtonWithIcon( + label = stringResource(id = R.string.log_in_with_device), + icon = painterResource(id = R.drawable.ic_device), + onClick = onLoginWithDeviceClick, + modifier = Modifier + .semantics { testTag = "LogInWithAnotherDeviceButton" } + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + BitwardenOutlinedButtonWithIcon( label = stringResource(id = R.string.log_in_sso), icon = painterResource(id = R.drawable.ic_briefcase), @@ -263,15 +282,11 @@ private fun LoginScreenContent( Spacer(modifier = Modifier.height(8.dp)) - // TODO: Need to figure out better handling for very small clickable text (BIT-724) - Text( + BitwardenClickableText( modifier = Modifier - .semantics { testTag = "NotYouLabel" } - .clickable { onNotYouButtonClick() }, - text = stringResource(id = R.string.not_you), - textAlign = TextAlign.Start, - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.labelLarge, + .semantics { testTag = "NotYouLabel" }, + onClick = onNotYouButtonClick, + label = stringResource(id = R.string.not_you), ) Spacer(modifier = Modifier.navigationBarsPadding()) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index 1b8a3d5901..db29aafc82 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -76,6 +76,7 @@ class LoginViewModel @Inject constructor( is LoginAction.SwitchAccountClick -> handleSwitchAccountClicked(action) is LoginAction.CloseButtonClick -> handleCloseButtonClicked() LoginAction.LoginButtonClick -> handleLoginButtonClicked() + LoginAction.LoginWithDeviceButtonClick -> handleLoginWithDeviceButtonClicked() LoginAction.MasterPasswordHintClick -> handleMasterPasswordHintClicked() LoginAction.NotYouButtonClick -> handleNotYouButtonClicked() LoginAction.SingleSignOnClick -> handleSingleSignOnClicked() @@ -172,6 +173,10 @@ class LoginViewModel @Inject constructor( attemptLogin() } + private fun handleLoginWithDeviceButtonClicked() { + sendEvent(LoginEvent.NavigateToLoginWithDevice) + } + private fun attemptLogin() { mutableStateFlow.update { it.copy( @@ -251,6 +256,11 @@ sealed class LoginEvent { */ data object NavigateToEnterpriseSignOn : LoginEvent() + /** + * Navigates to the login with device screen. + */ + data object NavigateToLoginWithDevice : LoginEvent() + /** * Shows a toast with the given [message]. */ @@ -301,6 +311,11 @@ sealed class LoginAction { */ data object LoginButtonClick : LoginAction() + /** + * Indicates that the Login With Device button has been clicked. + */ + data object LoginWithDeviceButtonClick : LoginAction() + /** * Indicates that the "Not you?" text was clicked. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt new file mode 100644 index 0000000000..bc1d9cf487 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt @@ -0,0 +1,35 @@ +package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.x8bit.bitwarden.ui.platform.theme.TransitionProviders + +private const val LOGIN_WITH_DEVICE_ROUTE = "login_with_device" + +/** + * Navigate to the Login with Device screen. + */ +fun NavController.navigateToLoginWithDevice(navOptions: NavOptions? = null) { + this.navigate(LOGIN_WITH_DEVICE_ROUTE, navOptions) +} + +/** + * Add the Login with Device screen to the nav graph. + */ +fun NavGraphBuilder.loginWithDeviceDestination( + onNavigateBack: () -> Unit, +) { + composable( + route = LOGIN_WITH_DEVICE_ROUTE, + enterTransition = TransitionProviders.Enter.slideUp, + exitTransition = TransitionProviders.Exit.stay, + popEnterTransition = TransitionProviders.Enter.stay, + popExitTransition = TransitionProviders.Exit.slideDown, + ) { + LoginWithDeviceScreen( + onNavigateBack = onNavigateBack, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt new file mode 100644 index 0000000000..7bb60ba816 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt @@ -0,0 +1,235 @@ +package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.components.BitwardenClickableText +import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent +import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors +import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography + +/** + * The top level composable for the Login with Device screen. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginWithDeviceScreen( + onNavigateBack: () -> Unit, + viewModel: LoginWithDeviceViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + EventsEffect(viewModel = viewModel) { event -> + when (event) { + LoginWithDeviceEvent.NavigateBack -> onNavigateBack() + is LoginWithDeviceEvent.ShowToast -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + } + } + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.log_in_with_device), + scrollBehavior = scrollBehavior, + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(LoginWithDeviceAction.CloseButtonClick) } + }, + ) + }, + ) { paddingValues -> + val modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + when (val viewState = state.viewState) { + is LoginWithDeviceState.ViewState.Content -> { + LoginWithDeviceScreenContent( + state = viewState, + onResendNotificationClick = remember(viewModel) { + { viewModel.trySendAction(LoginWithDeviceAction.ResendNotificationClick) } + }, + onViewAllLogInOptionsClick = remember(viewModel) { + { viewModel.trySendAction(LoginWithDeviceAction.ViewAllLogInOptionsClick) } + }, + modifier = modifier, + ) + } + + is LoginWithDeviceState.ViewState.Error -> { + BitwardenErrorContent( + message = viewState.message(), + modifier = modifier, + ) + } + + LoginWithDeviceState.ViewState.Loading -> { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } + } + } +} + +@Suppress("LongMethod") +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun LoginWithDeviceScreenContent( + state: LoginWithDeviceState.ViewState.Content, + onResendNotificationClick: () -> Unit, + onViewAllLogInOptionsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .semantics { testTagsAsResourceId = true } + .imePadding() + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(id = R.string.log_in_initiated), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(id = R.string.a_notification_has_been_sent_to_your_device), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + @Suppress("MaxLineLength") + Text( + text = stringResource(id = R.string.please_make_sure_your_vault_is_unlocked_and_the_fingerprint_phrase_matches_on_the_other_device), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(id = R.string.fingerprint_phrase), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = state.fingerprintPhrase, + textAlign = TextAlign.Start, + color = LocalNonMaterialColors.current.fingerprint, + style = LocalNonMaterialTypography.current.sensitiveInfoSmall, + modifier = Modifier + .semantics { testTag = "FingerprintValueLabel" } + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + BitwardenClickableText( + modifier = Modifier + .padding(horizontal = 16.dp) + .semantics { testTag = "ResendNotificationButton" } + .fillMaxWidth(), + label = stringResource(id = R.string.resend_notification), + onClick = onResendNotificationClick, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(id = R.string.need_another_option), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BitwardenClickableText( + modifier = Modifier + .padding(horizontal = 16.dp) + .semantics { testTag = "ViewAllLoginOptionsButton" } + .fillMaxWidth(), + label = stringResource(id = R.string.view_all_login_options), + onClick = onViewAllLogInOptionsClick, + ) + + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt new file mode 100644 index 0000000000..2d44e273ba --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt @@ -0,0 +1,136 @@ +package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * Manages application state for the Login with Device screen. + */ +@HiltViewModel +class LoginWithDeviceViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: LoginWithDeviceState( + viewState = LoginWithDeviceState.ViewState.Loading, + ), +) { + init { + mutableStateFlow.update { + // TODO BIT-809: Pull phrase from SDK + it.copy( + viewState = LoginWithDeviceState.ViewState.Content( + fingerprintPhrase = "alabster-drinkable-mystified-rapping-irrigate", + ), + ) + } + } + + override fun handleAction(action: LoginWithDeviceAction) { + when (action) { + LoginWithDeviceAction.CloseButtonClick -> handleCloseButtonClicked() + LoginWithDeviceAction.ResendNotificationClick -> handleResendNotificationClicked() + LoginWithDeviceAction.ViewAllLogInOptionsClick -> handleViewAllLogInOptionsClicked() + } + } + + private fun handleCloseButtonClicked() { + sendEvent(LoginWithDeviceEvent.NavigateBack) + } + + private fun handleResendNotificationClicked() { + // TODO BIT-810: implement Resend Notification button + sendEvent(LoginWithDeviceEvent.ShowToast("Not yet implemented.")) + } + + private fun handleViewAllLogInOptionsClicked() { + sendEvent(LoginWithDeviceEvent.NavigateBack) + } +} + +/** + * Models state of the Login with Device screen. + */ +@Parcelize +data class LoginWithDeviceState( + val viewState: ViewState, +) : Parcelable { + /** + * Represents the specific view states for the [LoginWithDeviceScreen]. + */ + @Parcelize + sealed class ViewState : Parcelable { + /** + * Loading state for the [LoginWithDeviceScreen], signifying that the content is being + * processed. + */ + @Parcelize + data object Loading : ViewState() + + /** + * Represents a state where the [LoginWithDeviceScreen] is unable to display data due to an + * error retrieving it. + * + * @property message The message to display on the error screen. + */ + @Parcelize + data class Error( + val message: Text, + ) : ViewState() + + /** + * Content state for the [LoginWithDeviceScreen] showing the actual content or items. + * + * @property fingerprintPhrase The fingerprint phrase to present to the user. + */ + @Parcelize + data class Content( + val fingerprintPhrase: String, + ) : ViewState() + } +} + +/** + * Models events for the Login with Device screen. + */ +sealed class LoginWithDeviceEvent { + /** + * Navigates back to the previous screen. + */ + data object NavigateBack : LoginWithDeviceEvent() + + /** + * Shows a toast with the given [message]. + */ + data class ShowToast( + val message: String, + ) : LoginWithDeviceEvent() +} + +/** + * Models actions for the Login with Device screen. + */ +sealed class LoginWithDeviceAction { + /** + * Indicates that the top-bar close button was clicked. + */ + data object CloseButtonClick : LoginWithDeviceAction() + + /** + * Indicates that the "Resend notification" text has been clicked. + */ + data object ResendNotificationClick : LoginWithDeviceAction() + + /** + * Indicates that the "View all log in options" text has been clicked. + */ + data object ViewAllLogInOptionsClick : LoginWithDeviceAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenClickableText.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenClickableText.kt new file mode 100644 index 0000000000..6521cb1756 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenClickableText.kt @@ -0,0 +1,42 @@ +package com.x8bit.bitwarden.ui.platform.components + +import androidx.compose.foundation.clickable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview + +/** + * Represents a Bitwarden-styled clickable text. + * + * @param label The label for the button. + * @param onClick The callback when the button is clicked. + * @param modifier The [Modifier] to be applied to the button. + */ +@Composable +fun BitwardenClickableText( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Text( + modifier = modifier + // TODO: Need to figure out better handling for very small clickable text (BIT-724) + .clickable { onClick() }, + text = label, + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + ) +} + +@Preview +@Composable +private fun BitwardenTextButton_preview() { + BitwardenTextButton( + label = "Label", + onClick = {}, + ) +} diff --git a/app/src/main/res/drawable/ic_device.xml b/app/src/main/res/drawable/ic_device.xml new file mode 100644 index 0000000000..27df2361cb --- /dev/null +++ b/app/src/main/res/drawable/ic_device.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt index f2c5b9b503..f27d467ee0 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt @@ -46,6 +46,7 @@ class LoginScreenTest : BaseComposeTest() { } private var onNavigateBackCalled = false private var onNavigateToEnterpriseSignOnCalled = false + private var onNavigateToLoginWithDeviceCalled = false private val mutableEventFlow = MutableSharedFlow( extraBufferCapacity = Int.MAX_VALUE, ) @@ -61,6 +62,7 @@ class LoginScreenTest : BaseComposeTest() { LoginScreen( onNavigateBack = { onNavigateBackCalled = true }, onNavigateToEnterpriseSignOn = { onNavigateToEnterpriseSignOnCalled = true }, + onNavigateToLoginWithDevice = { onNavigateToLoginWithDeviceCalled = true }, viewModel = viewModel, intentHandler = intentHandler, ) @@ -273,6 +275,12 @@ class LoginScreenTest : BaseComposeTest() { mutableEventFlow.tryEmit(LoginEvent.NavigateToEnterpriseSignOn) assertTrue(onNavigateToEnterpriseSignOnCalled) } + + @Test + fun `NavigateToLoginWithDevice should call onNavigateToLoginWithDevice`() { + mutableEventFlow.tryEmit(LoginEvent.NavigateToLoginWithDevice) + assertTrue(onNavigateToLoginWithDeviceCalled) + } } private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt index 5ae120cee0..bce939f619 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt @@ -317,6 +317,19 @@ class LoginViewModelTest : BaseViewModelTest() { } } + @Test + fun `LoginWithDeviceButtonClick should emit NavigateToLoginWithDevice`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(LoginAction.LoginWithDeviceButtonClick) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + assertEquals( + LoginEvent.NavigateToLoginWithDevice, + awaitItem(), + ) + } + } + @Test fun `SingleSignOnClick should emit NavigateToEnterpriseSignOn`() = runTest { val viewModel = createViewModel() diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt new file mode 100644 index 0000000000..d0c350557f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt @@ -0,0 +1,116 @@ +package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.util.isProgressBar +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import junit.framework.TestCase +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test + +class LoginWithDeviceScreenTest : BaseComposeTest() { + private var onNavigateBackCalled = false + private val mutableEventFlow = MutableSharedFlow( + extraBufferCapacity = Int.MAX_VALUE, + ) + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setup() { + composeTestRule.setContent { + LoginWithDeviceScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `close button click should send CloseButtonClick action`() { + composeTestRule.onNodeWithContentDescription("Close").performClick() + verify { + viewModel.trySendAction(LoginWithDeviceAction.CloseButtonClick) + } + } + + @Test + fun `resend notification click should send ResendNotificationClick action`() { + composeTestRule.onNodeWithText("Resend notification").performClick() + verify { + viewModel.trySendAction(LoginWithDeviceAction.ResendNotificationClick) + } + } + + @Test + fun `view all log in options click should send ViewAllLogInOptionsClick action`() { + composeTestRule.onNodeWithText("View all log in options").performScrollTo().performClick() + verify { + viewModel.trySendAction(LoginWithDeviceAction.ViewAllLogInOptionsClick) + } + } + + @Test + fun `NavigateBack should call onNavigateBack`() { + mutableEventFlow.tryEmit(LoginWithDeviceEvent.NavigateBack) + TestCase.assertTrue(onNavigateBackCalled) + } + + @Test + fun `progress bar should be displayed according to state`() { + mutableStateFlow.update { + it.copy(viewState = LoginWithDeviceState.ViewState.Loading) + } + composeTestRule.onNode(isProgressBar).assertIsDisplayed() + + mutableStateFlow.update { + it.copy(viewState = LoginWithDeviceState.ViewState.Error("Failure".asText())) + } + composeTestRule.onNode(isProgressBar).assertDoesNotExist() + + mutableStateFlow.update { + it.copy(viewState = DEFAULT_STATE.viewState) + } + composeTestRule.onNode(isProgressBar).assertDoesNotExist() + } + + @Test + fun `error should be displayed according to state`() { + val errorMessage = "error" + mutableStateFlow.update { + it.copy(viewState = LoginWithDeviceState.ViewState.Loading) + } + composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist() + + mutableStateFlow.update { + it.copy(viewState = LoginWithDeviceState.ViewState.Error(errorMessage.asText())) + } + composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed() + + mutableStateFlow.update { + it.copy(viewState = DEFAULT_STATE.viewState) + } + composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist() + } + + companion object { + private val DEFAULT_STATE = LoginWithDeviceState( + viewState = LoginWithDeviceState.ViewState.Content( + fingerprintPhrase = "alabster-drinkable-mystified-rapping-irrigate", + ), + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt new file mode 100644 index 0000000000..2d2236ed17 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt @@ -0,0 +1,72 @@ +package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class LoginWithDeviceViewModelTest : BaseViewModelTest() { + + private val savedStateHandle = SavedStateHandle() + + @Test + fun `initial state should be correct`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + } + } + + @Test + fun `CloseButtonClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(LoginWithDeviceAction.CloseButtonClick) + assertEquals( + LoginWithDeviceEvent.NavigateBack, + awaitItem(), + ) + } + } + + @Test + fun `ResendNotificationClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(LoginWithDeviceAction.ResendNotificationClick) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + assertEquals( + LoginWithDeviceEvent.ShowToast("Not yet implemented."), + awaitItem(), + ) + } + } + + @Test + fun `ViewAllLogInOptionsClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(LoginWithDeviceAction.ViewAllLogInOptionsClick) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + assertEquals( + LoginWithDeviceEvent.NavigateBack, + awaitItem(), + ) + } + } + + private fun createViewModel(): LoginWithDeviceViewModel = + LoginWithDeviceViewModel( + savedStateHandle = savedStateHandle, + ) + + companion object { + private val DEFAULT_STATE = LoginWithDeviceState( + viewState = LoginWithDeviceState.ViewState.Content( + fingerprintPhrase = "alabster-drinkable-mystified-rapping-irrigate", + ), + ) + } +}