BIT-141 Setup basic Login and Landing screens (#40)

Co-authored-by: Caleb Derosier <caleb@livefront.com>
This commit is contained in:
Andrew Haisting
2023-09-11 14:44:09 -05:00
committed by Álison Fernandes
parent d8de9bb753
commit 024376b0d2
13 changed files with 727 additions and 30 deletions

View File

@@ -8,7 +8,10 @@ import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.auth.feature.createaccount.createAccountDestinations
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount
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.landing.landingDestinations
import com.x8bit.bitwarden.ui.auth.feature.landing.navigateToLanding
import com.x8bit.bitwarden.ui.auth.feature.login.loginDestinations
import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin
const val AUTH_ROUTE: String = "auth"
@@ -21,8 +24,12 @@ fun NavGraphBuilder.authDestinations(navController: NavHostController) {
route = AUTH_ROUTE,
) {
createAccountDestinations()
landingDestination(
landingDestinations(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
onNavigateToLogin = { emailAddress -> navController.navigateToLogin(emailAddress) },
)
loginDestinations(
onNavigateToLanding = { navController.navigateToLanding() },
)
}
}

View File

@@ -17,8 +17,14 @@ fun NavController.navigateToLanding(navOptions: NavOptions? = null) {
/**
* Add the Landing screen to the nav graph.
*/
fun NavGraphBuilder.landingDestination(onNavigateToCreateAccount: () -> Unit) {
fun NavGraphBuilder.landingDestinations(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (String) -> Unit,
) {
composable(route = LANDING_ROUTE) {
LandingScreen(onNavigateToCreateAccount = onNavigateToCreateAccount)
LandingScreen(
onNavigateToCreateAccount = onNavigateToCreateAccount,
onNavigateToLogin = onNavigateToLogin,
)
}
}

View File

@@ -34,12 +34,14 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
@Suppress("LongMethod")
fun LandingScreen(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (emailAddress: 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)
}
}
@@ -68,8 +70,10 @@ fun LandingScreen(
)
BitwardenTextField(
modifier = Modifier.testTag("Email address"),
value = state.emailInput,
onValueChange = { viewModel.trySendAction(LandingAction.EmailInputChanged(it)) },
label = stringResource(id = R.string.email_address),
initialValue = state.initialEmailAddress,
)
Row(
@@ -87,6 +91,7 @@ fun LandingScreen(
)
Switch(
modifier = Modifier.testTag("Remember me"),
checked = state.isRememberMeEnabled,
onCheckedChange = {
viewModel.trySendAction(LandingAction.RememberMeToggle(it))
@@ -95,7 +100,9 @@ fun LandingScreen(
}
Button(
onClick = { viewModel.trySendAction(LandingAction.ContinueButtonClick) },
onClick = {
viewModel.trySendAction(LandingAction.ContinueButtonClick)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)

View File

@@ -1,34 +1,55 @@
package com.x8bit.bitwarden.ui.auth.feature.landing
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Manages application state for the initial landing screen.
*/
@HiltViewModel
class LandingViewModel @Inject constructor() :
BaseViewModel<LandingState, LandingEvent, LandingAction>(
initialState = LandingState(
initialEmailAddress = "",
class LandingViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LandingState, LandingEvent, LandingAction>(
initialState = savedStateHandle[KEY_STATE]
?: LandingState(
emailInput = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
),
) {
) {
init {
// As state updates, write to saved state handle:
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: LandingAction) {
when (action) {
LandingAction.ContinueButtonClick -> handleContinueButtonClicked()
is LandingAction.ContinueButtonClick -> handleContinueButtonClicked()
LandingAction.CreateAccountClick -> handleCreateAccountClicked()
is LandingAction.RememberMeToggle -> handleRememberMeToggled(action)
is LandingAction.EmailInputChanged -> handleEmailInputUpdated(action)
}
}
private fun handleEmailInputUpdated(action: LandingAction.EmailInputChanged) {
mutableStateFlow.update { it.copy(emailInput = action.input) }
}
private fun handleContinueButtonClicked() {
mutableStateFlow.value = mutableStateFlow.value.copy(
isContinueButtonEnabled = false,
)
sendEvent(LandingEvent.NavigateToLogin(mutableStateFlow.value.emailInput))
}
private fun handleCreateAccountClicked() {
@@ -36,20 +57,19 @@ class LandingViewModel @Inject constructor() :
}
private fun handleRememberMeToggled(action: LandingAction.RememberMeToggle) {
mutableStateFlow.value = mutableStateFlow.value.copy(
isRememberMeEnabled = action.isChecked,
)
mutableStateFlow.update { it.copy(isRememberMeEnabled = action.isChecked) }
}
}
/**
* Models state of the landing screen.
*/
@Parcelize
data class LandingState(
val initialEmailAddress: String,
val emailInput: String,
val isContinueButtonEnabled: Boolean,
val isRememberMeEnabled: Boolean,
)
) : Parcelable
/**
* Models events for the landing screen.
@@ -59,6 +79,13 @@ sealed class LandingEvent {
* Navigates to the Create Account screen.
*/
data object NavigateToCreateAccount : LandingEvent()
/**
* Navigates to the Login screen with the given email address.
*/
data class NavigateToLogin(
val emailAddress: String,
) : LandingEvent()
}
/**
@@ -81,4 +108,11 @@ sealed class LandingAction {
data class RememberMeToggle(
val isChecked: Boolean,
) : LandingAction()
/**
* Indicates that the input on the email field has changed.
*/
data class EmailInputChanged(
val input: String,
) : LandingAction()
}

View File

@@ -0,0 +1,49 @@
package com.x8bit.bitwarden.ui.auth.feature.login
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
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}"
/**
* Class to retrieve login arguments from the [SavedStateHandle].
*/
class LoginArgs(val emailAddress: String) {
constructor(savedStateHandle: SavedStateHandle) : this(
checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String,
)
}
/**
* Navigate to the login screen with the given email address.
*/
fun NavController.navigateToLogin(
emailAddress: String,
navOptions: NavOptions? = null,
) {
this.navigate("login/$emailAddress", navOptions)
}
/**
* Add the Login screen to the nav graph.
*/
fun NavGraphBuilder.loginDestinations(
onNavigateToLanding: () -> Unit,
) {
composable(
route = LOGIN_ROUTE,
arguments = listOf(
navArgument(EMAIL_ADDRESS) { type = NavType.StringType },
),
) {
LoginScreen(
onNavigateToLanding = { onNavigateToLanding() },
)
}
}

View File

@@ -0,0 +1,100 @@
package com.x8bit.bitwarden.ui.auth.feature.login
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
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.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.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
/**
* The top level composable for the Login screen.
*/
@Composable
@Suppress("LongMethod")
fun LoginScreen(
onNavigateToLanding: () -> Unit,
viewModel: LoginViewModel = viewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
LoginEvent.NavigateToLanding -> onNavigateToLanding()
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 16.dp, vertical = 32.dp),
) {
BitwardenTextField(
modifier = Modifier.testTag("Master password"),
value = state.passwordInput,
onValueChange = { viewModel.trySendAction(LoginAction.PasswordInputChanged(it)) },
label = stringResource(id = R.string.master_password),
)
Button(
onClick = { viewModel.trySendAction(LoginAction.LoginButtonClick) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.testTag("Login button"),
enabled = state.isLoginButtonEnabled,
) {
Text(
text = stringResource(id = R.string.log_in_with_master_password),
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.bodyMedium,
)
}
Button(
onClick = { viewModel.trySendAction(LoginAction.SingleSignOnClick) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.testTag("Single sign-on button"),
enabled = state.isLoginButtonEnabled,
) {
Text(
text = stringResource(id = R.string.enterprise_single_sign_on),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
text = stringResource(id = R.string.logging_in_as, state.emailAddress),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
)
Text(
modifier = Modifier
.clickable { viewModel.trySendAction(LoginAction.NotYouButtonClick) },
text = stringResource(id = R.string.not_you),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
)
}
}

View File

@@ -0,0 +1,107 @@
package com.x8bit.bitwarden.ui.auth.feature.login
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Manages application state for the initial login screen.
*/
@HiltViewModel
class LoginViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LoginState, LoginEvent, LoginAction>(
initialState = savedStateHandle[KEY_STATE]
?: LoginState(
emailAddress = LoginArgs(savedStateHandle).emailAddress,
isLoginButtonEnabled = false,
passwordInput = "",
),
) {
init {
// As state updates, write to saved state handle:
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: LoginAction) {
when (action) {
LoginAction.LoginButtonClick -> handleLoginButtonClicked()
LoginAction.NotYouButtonClick -> handleNotYouButtonClicked()
LoginAction.SingleSignOnClick -> handleSingleSignOnClicked()
is LoginAction.PasswordInputChanged -> handlePasswordInputChanged(action)
}
}
private fun handleLoginButtonClicked() {
// TODO BIT-133 make login request and allow user to login
}
private fun handleNotYouButtonClicked() {
sendEvent(LoginEvent.NavigateToLanding)
}
private fun handleSingleSignOnClicked() {
// TODO BIT-204 navigate to single sign on
}
private fun handlePasswordInputChanged(action: LoginAction.PasswordInputChanged) {
mutableStateFlow.update { it.copy(passwordInput = action.input) }
}
}
/**
* Models state of the login screen.
*/
@Parcelize
data class LoginState(
val passwordInput: String,
val emailAddress: String,
val isLoginButtonEnabled: Boolean,
) : Parcelable
/**
* Models events for the login screen.
*/
sealed class LoginEvent {
/**
* Navigates to the Landing screen.
*/
data object NavigateToLanding : LoginEvent()
}
/**
* Models actions for the login screen.
*/
sealed class LoginAction {
/**
* Indicates that the Login button has been clicked.
*/
data object LoginButtonClick : LoginAction()
/**
* Indicates that the "Not you?" text was clicked.
*/
data object NotYouButtonClick : LoginAction()
/**
* Indicates that the Enterprise single sign-on button has been clicked.
*/
data object SingleSignOnClick : LoginAction()
/**
* Indicates that the password input has changed.
*/
data class PasswordInputChanged(val input: String) : LoginAction()
}

View File

@@ -19,7 +19,10 @@ import androidx.compose.ui.unit.dp
* @param label label for the text field.
* @param initialValue initial input text.
* @param onTextChange callback that is triggered when the input of the text field changes.
*
* TODO: remove deprecated version: BIT-289
*/
@Deprecated(message = "Use overloaded BitwardenTextField that takes an input instead of an initialText.")
@Composable
fun BitwardenTextField(
label: String,
@@ -39,3 +42,28 @@ fun BitwardenTextField(
},
)
}
/**
* Component that allows the user to input text. This composable will manage the state of
* the user's input.
* @param label label for the text field.
* @param value current next on the text field.
* @param modifier modifier for the composable.
* @param onValueChange callback that is triggered when the input of the text field changes.
*/
@Composable
fun BitwardenTextField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TextField(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
label = { Text(label) },
value = value,
onValueChange = onValueChange,
)
}