BIT-202: Adding region selector composable to Landing Screen (#91)

This commit is contained in:
joshua-livefront 2023-10-03 17:37:36 -04:00 committed by Álison Fernandes
parent bc319368ed
commit eedf0b6f91
12 changed files with 238 additions and 28 deletions

View file

@ -25,7 +25,9 @@ fun NavGraphBuilder.authDestinations(navController: NavHostController) {
createAccountDestinations()
landingDestinations(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
onNavigateToLogin = { emailAddress -> navController.navigateToLogin(emailAddress) },
onNavigateToLogin = { emailAddress, regionLabel ->
navController.navigateToLogin(emailAddress, regionLabel)
},
)
loginDestinations(
onNavigateBack = { navController.popBackStack() },

View file

@ -19,7 +19,7 @@ fun NavController.navigateToLanding(navOptions: NavOptions? = null) {
*/
fun NavGraphBuilder.landingDestinations(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (String) -> Unit,
onNavigateToLogin: (emailAddress: String, regionLabel: String) -> Unit,
) {
composable(route = LANDING_ROUTE) {
LandingScreen(

View file

@ -2,7 +2,9 @@ package com.x8bit.bitwarden.ui.auth.feature.landing
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -14,11 +16,16 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@ -36,6 +43,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* The top level composable for the Landing screen.
@ -44,14 +52,17 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
@Suppress("LongMethod")
fun LandingScreen(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (emailAddress: String) -> Unit,
onNavigateToLogin: (emailAddress: String, regionLabel: String) -> Unit,
viewModel: LandingViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
LandingEvent.NavigateToCreateAccount -> onNavigateToCreateAccount()
is LandingEvent.NavigateToLogin -> onNavigateToLogin(event.emailAddress)
is LandingEvent.NavigateToLogin -> onNavigateToLogin(
event.emailAddress,
event.regionLabel,
)
}
}
@ -103,6 +114,14 @@ fun LandingScreen(
label = stringResource(id = R.string.email_address),
)
RegionSelector(
selectedOption = state.selectedRegion,
options = LandingState.RegionOption.values().toList(),
onOptionSelected = remember(viewModel) {
{ viewModel.trySendAction(LandingAction.RegionOptionSelect(it)) }
},
)
BitwardenSwitch(
label = stringResource(id = R.string.remember_me),
isChecked = state.isRememberMeEnabled,
@ -152,12 +171,78 @@ fun LandingScreen(
}
}
/**
* A dropdown selector UI component specific to region url selection on the Landing screen.
*
* This composable displays a dropdown menu allowing users to select a region
* from a list of options. When an option is selected, it invokes the provided callback
* and displays the currently selected region on the UI.
*
* @param selectedOption The currently selected region option.
* @param options A list of region options available for selection.
* @param onOptionSelected A callback that gets invoked when a region option is selected
* and passes the selected option as an argument.
*
*/
@Composable
private fun RegionSelector(
selectedOption: LandingState.RegionOption,
options: List<LandingState.RegionOption>,
onOptionSelected: (LandingState.RegionOption) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.clickable { expanded = !expanded }
.fillMaxWidth()
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(id = R.string.logging_in_on),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(end = 12.dp),
)
Text(
text = selectedOption.label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp),
)
Icon(
painter = painterResource(id = R.drawable.ic_region_select_dropdown),
contentDescription = stringResource(id = R.string.region),
tint = MaterialTheme.colorScheme.primary,
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { optionString ->
DropdownMenuItem(
text = { Text(text = optionString.label) },
onClick = {
expanded = false
onOptionSelected(optionString)
},
)
}
}
}
}
@Preview
@Composable
private fun LandingScreen_preview() {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = {},
viewModel = LandingViewModel(SavedStateHandle()),
)
BitwardenTheme {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = { _, _ -> },
viewModel = LandingViewModel(SavedStateHandle()),
)
}
}

View file

@ -25,6 +25,7 @@ class LandingViewModel @Inject constructor(
emailInput = "",
isContinueButtonEnabled = false,
isRememberMeEnabled = false,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
),
) {
@ -41,6 +42,7 @@ class LandingViewModel @Inject constructor(
LandingAction.CreateAccountClick -> handleCreateAccountClicked()
is LandingAction.RememberMeToggle -> handleRememberMeToggled(action)
is LandingAction.EmailInputChanged -> handleEmailInputUpdated(action)
is LandingAction.RegionOptionSelect -> handleRegionSelect(action)
}
}
@ -59,7 +61,9 @@ class LandingViewModel @Inject constructor(
if (mutableStateFlow.value.emailInput.isBlank()) {
return
}
sendEvent(LandingEvent.NavigateToLogin(mutableStateFlow.value.emailInput))
val email = mutableStateFlow.value.emailInput
val selectedRegionLabel = mutableStateFlow.value.selectedRegion.label
sendEvent(LandingEvent.NavigateToLogin(email, selectedRegionLabel))
}
private fun handleCreateAccountClicked() {
@ -69,6 +73,14 @@ class LandingViewModel @Inject constructor(
private fun handleRememberMeToggled(action: LandingAction.RememberMeToggle) {
mutableStateFlow.update { it.copy(isRememberMeEnabled = action.isChecked) }
}
private fun handleRegionSelect(action: LandingAction.RegionOptionSelect) {
mutableStateFlow.update {
it.copy(
selectedRegion = action.regionOption,
)
}
}
}
/**
@ -79,7 +91,17 @@ data class LandingState(
val emailInput: String,
val isContinueButtonEnabled: Boolean,
val isRememberMeEnabled: Boolean,
) : Parcelable
val selectedRegion: RegionOption,
) : Parcelable {
/**
* Enumerates the possible region options with their corresponding labels.
*/
enum class RegionOption(val label: String) {
BITWARDEN_US("bitwarden.com"),
BITWARDEN_EU("bitwarden.eu"),
SELF_HOSTED("Self-hosted"),
}
}
/**
* Models events for the landing screen.
@ -91,10 +113,11 @@ sealed class LandingEvent {
data object NavigateToCreateAccount : LandingEvent()
/**
* Navigates to the Login screen with the given email address.
* Navigates to the Login screen with the given email address and region label.
*/
data class NavigateToLogin(
val emailAddress: String,
val regionLabel: String,
) : LandingEvent()
}
@ -125,4 +148,11 @@ sealed class LandingAction {
data class EmailInputChanged(
val input: String,
) : LandingAction()
/**
* Indicates that the selection from the region drop down has changed.
*/
data class RegionOptionSelect(
val regionOption: LandingState.RegionOption,
) : LandingAction()
}

View file

@ -9,25 +9,28 @@ import androidx.navigation.compose.composable
import androidx.navigation.navArgument
private const val EMAIL_ADDRESS: String = "email_address"
private const val LOGIN_ROUTE: String = "login/{$EMAIL_ADDRESS}"
private const val REGION_LABEL: String = "region_label"
private const val LOGIN_ROUTE: String = "login/{$EMAIL_ADDRESS}/{$REGION_LABEL}"
/**
* Class to retrieve login arguments from the [SavedStateHandle].
*/
class LoginArgs(val emailAddress: String) {
class LoginArgs(val emailAddress: String, val regionLabel: String) {
constructor(savedStateHandle: SavedStateHandle) : this(
checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String,
checkNotNull(savedStateHandle[REGION_LABEL]) as String,
)
}
/**
* Navigate to the login screen with the given email address.
* Navigate to the login screen with the given email address and region label.
*/
fun NavController.navigateToLogin(
emailAddress: String,
regionLabel: String,
navOptions: NavOptions? = null,
) {
this.navigate("login/$emailAddress", navOptions)
this.navigate("login/$emailAddress/$regionLabel", navOptions)
}
/**

View file

@ -145,7 +145,7 @@ fun LoginScreen(
text = stringResource(
id = R.string.log_in_attempt_by_x_on_y,
state.emailAddress,
"bitwarden.com",
state.region,
),
textAlign = TextAlign.Start,
style = MaterialTheme.typography.bodyMedium,

View file

@ -34,6 +34,7 @@ class LoginViewModel @Inject constructor(
emailAddress = LoginArgs(savedStateHandle).emailAddress,
isLoginButtonEnabled = true,
passwordInput = "",
region = LoginArgs(savedStateHandle).regionLabel,
),
) {
@ -135,6 +136,7 @@ class LoginViewModel @Inject constructor(
data class LoginState(
val passwordInput: String,
val emailAddress: String,
val region: String,
val isLoginButtonEnabled: Boolean,
) : Parcelable

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<group>
<clip-path
android:pathData="M0.5,0.5h15v15h-15z"/>
<path
android:pathData="M7.995,12.65C7.552,12.649 7.123,12.467 6.789,12.138L0.866,6.361C0.701,6.2 0.585,5.986 0.532,5.747C0.479,5.509 0.492,5.258 0.571,5.029C0.639,4.811 0.764,4.622 0.931,4.487C1.098,4.352 1.297,4.279 1.502,4.277L14.489,4.213C14.694,4.213 14.895,4.284 15.063,4.418C15.23,4.551 15.357,4.739 15.427,4.956C15.506,5.185 15.522,5.436 15.47,5.675C15.418,5.914 15.302,6.129 15.138,6.292L9.208,12.13C8.874,12.464 8.442,12.649 7.995,12.65Z"
android:fillColor="#175DDC"/>
</group>
</vector>

View file

@ -28,6 +28,7 @@ class LandingScreenTest : BaseComposeTest() {
emailInput = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
),
)
val viewModel = mockk<LandingViewModel>(relaxed = true) {
@ -37,7 +38,7 @@ class LandingScreenTest : BaseComposeTest() {
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
@ -57,13 +58,14 @@ class LandingScreenTest : BaseComposeTest() {
emailInput = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
),
)
}
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
@ -82,13 +84,14 @@ class LandingScreenTest : BaseComposeTest() {
emailInput = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
),
)
}
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
@ -111,13 +114,14 @@ class LandingScreenTest : BaseComposeTest() {
emailInput = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
),
)
}
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
@ -137,13 +141,14 @@ class LandingScreenTest : BaseComposeTest() {
emailInput = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
),
)
}
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
@ -163,13 +168,14 @@ class LandingScreenTest : BaseComposeTest() {
emailInput = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
),
)
}
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = { onNavigateToCreateAccountCalled = true },
onNavigateToLogin = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
@ -179,24 +185,69 @@ class LandingScreenTest : BaseComposeTest() {
@Test
fun `NavigateToLogin event should call onNavigateToLogin`() {
val testEmail = "test@test.com"
var onNavigateToLoginEmail = ""
val testRegion = "bitwarden.com"
var capturedEmail: String? = null
var capturedRegion: String? = null
val viewModel = mockk<LandingViewModel>(relaxed = true) {
every { eventFlow } returns flowOf(LandingEvent.NavigateToLogin(testEmail))
every { eventFlow } returns flowOf(LandingEvent.NavigateToLogin(testEmail, testRegion))
every { stateFlow } returns MutableStateFlow(
LandingState(
emailInput = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
),
)
}
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = { },
onNavigateToLogin = { onNavigateToLoginEmail = it },
onNavigateToLogin = { email, region ->
capturedEmail = email
capturedRegion = region
},
viewModel = viewModel,
)
}
assertEquals(testEmail, onNavigateToLoginEmail)
assertEquals(testEmail, capturedEmail)
assertEquals(testRegion, capturedRegion)
}
@Test
fun `selecting region should send RegionOptionSelect action`() {
val selectedRegion = LandingState.RegionOption.BITWARDEN_EU
val viewModel = mockk<LandingViewModel>(relaxed = true) {
every { eventFlow } returns emptyFlow()
every { stateFlow } returns MutableStateFlow(
LandingState(
emailInput = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
),
)
}
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
onNavigateToLogin = { _, _ -> },
viewModel = viewModel,
)
}
// Clicking to open dropdown
composeTestRule.onNodeWithText(LandingState.RegionOption.BITWARDEN_US.label).performClick()
// Clicking item from the dropdown menu
composeTestRule.onNodeWithText(selectedRegion.label).performClick()
verify {
viewModel.trySendAction(LandingAction.RegionOptionSelect(selectedRegion))
}
}
}

View file

@ -38,7 +38,7 @@ class LandingViewModelTest : BaseViewModelTest() {
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.ContinueButtonClick)
assertEquals(
LandingEvent.NavigateToLogin("input"),
LandingEvent.NavigateToLogin("input", "bitwarden.com"),
awaitItem(),
)
}
@ -106,11 +106,26 @@ class LandingViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `RegionOptionSelect should update value of selected region`() = runTest {
val inputRegion = LandingState.RegionOption.BITWARDEN_EU
val viewModel = LandingViewModel(SavedStateHandle())
viewModel.stateFlow.test {
awaitItem()
viewModel.trySendAction(LandingAction.RegionOptionSelect(inputRegion))
assertEquals(
DEFAULT_STATE.copy(selectedRegion = LandingState.RegionOption.BITWARDEN_EU),
awaitItem(),
)
}
}
companion object {
private val DEFAULT_STATE = LandingState(
emailInput = "",
isContinueButtonEnabled = false,
isRememberMeEnabled = false,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
)
}
}

View file

@ -34,6 +34,7 @@ class LoginScreenTest : BaseComposeTest() {
emailAddress = "",
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
),
)
}
@ -58,6 +59,7 @@ class LoginScreenTest : BaseComposeTest() {
emailAddress = "",
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
),
)
}
@ -82,6 +84,7 @@ class LoginScreenTest : BaseComposeTest() {
emailAddress = "",
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
),
)
}
@ -106,6 +109,7 @@ class LoginScreenTest : BaseComposeTest() {
emailAddress = "",
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
),
)
}
@ -142,6 +146,7 @@ class LoginScreenTest : BaseComposeTest() {
emailAddress = "",
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
),
)
}
@ -167,6 +172,7 @@ class LoginScreenTest : BaseComposeTest() {
emailAddress = "",
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
),
)
}
@ -192,6 +198,7 @@ class LoginScreenTest : BaseComposeTest() {
emailAddress = "",
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
),
)
}

View file

@ -25,6 +25,7 @@ class LoginViewModelTest : BaseViewModelTest() {
private val savedStateHandle = SavedStateHandle().also {
it["email_address"] = "test@gmail.com"
it["region_label"] = ""
}
@BeforeEach
@ -262,6 +263,7 @@ class LoginViewModelTest : BaseViewModelTest() {
emailAddress = "test@gmail.com",
passwordInput = "",
isLoginButtonEnabled = true,
region = "",
)
private const val LOGIN_RESULT_PATH =