diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/landing/LandingNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/landing/LandingNavigation.kt new file mode 100644 index 0000000000..dfab35a772 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/landing/LandingNavigation.kt @@ -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) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/landing/LandingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/landing/LandingScreen.kt new file mode 100644 index 0000000000..06e551964f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/landing/LandingScreen.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/landing/LandingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/landing/LandingViewModel.kt new file mode 100644 index 0000000000..509fccaf30 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/landing/LandingViewModel.kt @@ -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( + 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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/login/LoginNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/login/LoginNavigation.kt new file mode 100644 index 0000000000..869b09e8a4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/login/LoginNavigation.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt index 266e968223..9de8650a3e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt @@ -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 - } - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aabd9b0c0f..3c71898756 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,4 +9,12 @@ Re-type master password Master password hint (optional) SUBMIT + + + Continue + Create account + Email address + Log in or create a new account to access your secure vault. + New around here? + Remember me diff --git a/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/landing/LandingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/landing/LandingScreenTest.kt new file mode 100644 index 0000000000..ac8f4b2637 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/landing/LandingScreenTest.kt @@ -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(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) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/landing/LandingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/landing/LandingViewModelTest.kt new file mode 100644 index 0000000000..1859deff3f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/landing/LandingViewModelTest.kt @@ -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, + ) + } +}