mirror of
https://github.com/bitwarden/android.git
synced 2024-11-21 08:55:48 +03:00
Add initial Landing screen & Login nav graph (#19)
This commit is contained in:
parent
6212ef8fa9
commit
24c7dade1e
8 changed files with 398 additions and 34 deletions
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue