Add initial Landing screen & Login nav graph (#19)

This commit is contained in:
Caleb Derosier 2023-08-31 08:41:56 -05:00 committed by Álison Fernandes
parent 6212ef8fa9
commit 24c7dade1e
8 changed files with 398 additions and 34 deletions

View file

@ -0,0 +1,24 @@
package com.x8bit.bitwarden.ui.feature.landing
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
const val LANDING_ROUTE: String = "landing"
/**
* Navigate to the landing screen.
*/
fun NavController.navigateToLanding(navOptions: NavOptions? = null) {
this.navigate(LANDING_ROUTE, navOptions)
}
/**
* Add the Landing screen to the nav graph.
*/
fun NavGraphBuilder.landingDestination(onNavigateToCreateAccount: () -> Unit) {
composable(route = LANDING_ROUTE) {
LandingScreen(onNavigateToCreateAccount = onNavigateToCreateAccount)
}
}

View file

@ -0,0 +1,135 @@
package com.x8bit.bitwarden.ui.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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.base.util.EventsEffect
import com.x8bit.bitwarden.ui.components.BitwardenTextField
/**
* The top level composable for the Landing screen.
*/
@Composable
@Suppress("LongMethod")
fun LandingScreen(
onNavigateToCreateAccount: () -> Unit,
viewModel: LandingViewModel = viewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
LandingEvent.NavigateToCreateAccount -> onNavigateToCreateAccount()
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 16.dp),
) {
Image(
painter = painterResource(id = R.drawable.logo_legacy),
contentDescription = null,
modifier = Modifier
.padding(start = 48.dp, top = 48.dp, end = 48.dp)
.fillMaxWidth(),
)
Text(
text = stringResource(id = R.string.log_in_or_create_account),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier
.align(alignment = Alignment.CenterHorizontally)
.padding(horizontal = 24.dp),
)
BitwardenTextField(label = state.initialEmailAddress)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 16.dp),
) {
Text(
text = stringResource(id = R.string.remember_me),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
)
Switch(
checked = state.isRememberMeEnabled,
onCheckedChange = {
viewModel.trySendAction(LandingAction.RememberMeToggle(it))
},
)
}
Button(
onClick = { viewModel.trySendAction(LandingAction.ContinueButtonClick) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.testTag("Continue button"),
enabled = state.isContinueButtonEnabled,
) {
Text(
text = stringResource(id = R.string.continue_button),
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.bodyMedium,
)
}
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 16.dp),
) {
Text(
text = stringResource(id = R.string.new_around_here),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
)
Text(
modifier = Modifier
.clickable {
viewModel.trySendAction(LandingAction.CreateAccountClick)
}
.padding(start = 2.dp),
text = stringResource(id = R.string.create_account),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
)
}
}
}

View file

@ -0,0 +1,84 @@
package com.x8bit.bitwarden.ui.feature.landing
import com.x8bit.bitwarden.ui.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* Manages application state for the initial landing screen.
*/
@HiltViewModel
class LandingViewModel @Inject constructor() :
BaseViewModel<LandingState, LandingEvent, LandingAction>(
initialState = LandingState(
initialEmailAddress = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
),
) {
override fun handleAction(action: LandingAction) {
when (action) {
LandingAction.ContinueButtonClick -> handleContinueButtonClicked()
LandingAction.CreateAccountClick -> handleCreateAccountClicked()
is LandingAction.RememberMeToggle -> handleRememberMeToggled(action)
}
}
private fun handleContinueButtonClicked() {
mutableStateFlow.value = mutableStateFlow.value.copy(
isContinueButtonEnabled = false,
)
}
private fun handleCreateAccountClicked() {
sendEvent(LandingEvent.NavigateToCreateAccount)
}
private fun handleRememberMeToggled(action: LandingAction.RememberMeToggle) {
mutableStateFlow.value = mutableStateFlow.value.copy(
isRememberMeEnabled = action.isChecked,
)
}
}
/**
* Models state of the landing screen.
*/
data class LandingState(
val initialEmailAddress: String,
val isContinueButtonEnabled: Boolean,
val isRememberMeEnabled: Boolean,
)
/**
* Models events for the landing screen.
*/
sealed class LandingEvent {
/**
* Navigates to the Create Account screen.
*/
data object NavigateToCreateAccount : LandingEvent()
}
/**
* Models actions for the landing screen.
*/
sealed class LandingAction {
/**
* Indicates that the continue button has been clicked and the app should navigate to Login.
*/
data object ContinueButtonClick : LandingAction()
/**
* Indicates that the Create Account text was clicked.
*/
data object CreateAccountClick : LandingAction()
/**
* Indicates that the Remember Me switch has been toggled.
*/
data class RememberMeToggle(
val isChecked: Boolean,
) : LandingAction()
}

View file

@ -0,0 +1,40 @@
package com.x8bit.bitwarden.ui.feature.login
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.feature.createaccount.createAccountDestinations
import com.x8bit.bitwarden.ui.feature.createaccount.navigateToCreateAccount
import com.x8bit.bitwarden.ui.feature.landing.LANDING_ROUTE
import com.x8bit.bitwarden.ui.feature.landing.landingDestination
const val LOGIN_ROUTE: String = "login"
/**
* Add login destinations to the nav graph.
*/
fun NavGraphBuilder.loginDestinations(navController: NavHostController) {
navigation(
startDestination = LANDING_ROUTE,
route = LOGIN_ROUTE,
) {
createAccountDestinations()
landingDestination(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
)
}
}
/**
* Navigate to the login screen. Note this will only work if login destination was added
* via [loginDestinations].
*/
fun NavController.navigateToLoginAsRoot() {
navigate(LANDING_ROUTE) {
// When changing root navigation state, pop everything else off the back stack:
popUpTo(graph.id) {
inclusive = true
}
}
}

View file

@ -10,7 +10,8 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.x8bit.bitwarden.ui.components.PlaceholderComposable
import com.x8bit.bitwarden.ui.feature.createaccount.CreateAccountScreen
import com.x8bit.bitwarden.ui.feature.login.loginDestinations
import com.x8bit.bitwarden.ui.feature.login.navigateToLoginAsRoot
/**
* Controls root level [NavHost] for the app.
@ -27,7 +28,7 @@ fun RootNavScreen(
startDestination = SplashRoute,
) {
splashDestinations()
loginDestinations()
loginDestinations(navController)
}
// When state changes, navigate to different root navigation state
@ -75,35 +76,3 @@ private fun NavController.navigateToSplashAsRoot() {
}
}
}
/**
* TODO move to login package(BIT-146)
*/
@Suppress("TopLevelPropertyNaming")
private const val LoginRoute = "login"
/**
* Add login destinations to the nav graph.
*
* TODO: move to login package (BIT-146)
*/
private fun NavGraphBuilder.loginDestinations() {
composable(LoginRoute) {
CreateAccountScreen()
}
}
/**
* Navigate to the splash screen. Note this will only work if login destination was added
* via [loginDestinations].
*
* TODO: move to login package (BIT-146)
*/
private fun NavController.navigateToLoginAsRoot() {
navigate(LoginRoute) {
// When changing root navigation state, pop everything else off the back stack:
popUpTo(graph.id) {
inclusive = true
}
}
}

View file

@ -9,4 +9,12 @@
<string name="input_label_re_type_master_password">Re-type master password</string>
<string name="input_label_master_password_hint">Master password hint (optional)</string>
<string name="button_submit">SUBMIT</string>
<!-- Landing screen -->
<string name="continue_button">Continue</string>
<string name="create_account">Create account</string>
<string name="email_address">Email address</string>
<string name="log_in_or_create_account">Log in or create a new account to access your secure vault.</string>
<string name="new_around_here">New around here?</string>
<string name="remember_me">Remember me</string>
</resources>

View file

@ -0,0 +1,46 @@
package com.x8bit.bitwarden.example.ui.feature.landing
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.example.ui.BaseComposeTest
import com.x8bit.bitwarden.ui.feature.landing.LandingAction
import com.x8bit.bitwarden.ui.feature.landing.LandingScreen
import com.x8bit.bitwarden.ui.feature.landing.LandingState
import com.x8bit.bitwarden.ui.feature.landing.LandingViewModel
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import org.junit.Test
/**
* Example showing that Compose tests using "junit" imports and Robolectric work.
*/
class LandingScreenTest : BaseComposeTest() {
@Test
fun `continue button click should send ContinueButtonClicked action`() {
val viewModel = mockk<LandingViewModel>(relaxed = true) {
every { eventFlow } returns emptyFlow()
every { stateFlow } returns MutableStateFlow(
LandingState(
initialEmailAddress = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
),
)
every { trySendAction(LandingAction.ContinueButtonClick) } returns Unit
}
composeTestRule.setContent {
LandingScreen(
onNavigateToCreateAccount = {},
viewModel = viewModel,
)
}
composeTestRule.onNodeWithTag("Continue button").performClick()
verify {
viewModel.trySendAction(LandingAction.ContinueButtonClick)
}
}
}

View file

@ -0,0 +1,58 @@
package com.x8bit.bitwarden.example.ui.feature.landing
import app.cash.turbine.test
import com.x8bit.bitwarden.example.ui.BaseViewModelTest
import com.x8bit.bitwarden.ui.feature.landing.LandingAction
import com.x8bit.bitwarden.ui.feature.landing.LandingEvent
import com.x8bit.bitwarden.ui.feature.landing.LandingState
import com.x8bit.bitwarden.ui.feature.landing.LandingViewModel
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class LandingViewModelTest : BaseViewModelTest() {
@Test
fun `ContinueButtonClick should disable continue button`() = runTest {
val viewModel = LandingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.ContinueButtonClick)
assertEquals(
viewModel.stateFlow.value,
DEFAULT_STATE.copy(isContinueButtonEnabled = false),
)
}
}
@Test
fun `CreateAccountClick should emit NavigateToCreateAccount`() = runTest {
val viewModel = LandingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.CreateAccountClick)
assertEquals(
LandingEvent.NavigateToCreateAccount,
awaitItem(),
)
}
}
@Test
fun `RememberMeToggle should update value of isRememberMeToggled`() = runTest {
val viewModel = LandingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.RememberMeToggle(true))
assertEquals(
viewModel.stateFlow.value,
DEFAULT_STATE.copy(isRememberMeEnabled = true),
)
}
}
companion object {
private val DEFAULT_STATE = LandingState(
initialEmailAddress = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
)
}
}