Skip to Content

Trang Login với Validation

Hướng dẫn xây dựng trang đăng nhập hoàn chỉnh với form validation trong Compose Multiplatform.

Kết quả cuối cùng

Trang login với:

  • Email/Password fields với validation
  • Show/hide password
  • Remember me checkbox
  • Forgot password link
  • Loading state
  • Error handling

Login State

// LoginState.kt data class LoginState( val email: String = "", val password: String = "", val emailError: String? = null, val passwordError: String? = null, val rememberMe: Boolean = false, val isLoading: Boolean = false, val loginError: String? = null ) sealed class LoginEvent { object LoginSuccess : LoginEvent() data class ShowError(val message: String) : LoginEvent() }

Login ViewModel

class LoginScreenModel( private val authRepository: AuthRepository ) : ScreenModel { private val _state = MutableStateFlow(LoginState()) val state: StateFlow<LoginState> = _state.asStateFlow() private val _events = MutableSharedFlow<LoginEvent>() val events: SharedFlow<LoginEvent> = _events.asSharedFlow() fun onEmailChange(email: String) { _state.update { it.copy( email = email, emailError = null, loginError = null ) } } fun onPasswordChange(password: String) { _state.update { it.copy( password = password, passwordError = null, loginError = null ) } } fun onRememberMeChange(checked: Boolean) { _state.update { it.copy(rememberMe = checked) } } fun login() { val currentState = _state.value // Validate val emailError = validateEmail(currentState.email) val passwordError = validatePassword(currentState.password) if (emailError != null || passwordError != null) { _state.update { it.copy( emailError = emailError, passwordError = passwordError ) } return } // Login screenModelScope.launch { _state.update { it.copy(isLoading = true, loginError = null) } try { authRepository.login( email = currentState.email, password = currentState.password, rememberMe = currentState.rememberMe ) _events.emit(LoginEvent.LoginSuccess) } catch (e: Exception) { _state.update { it.copy( isLoading = false, loginError = e.message ?: "Login failed" ) } } } } private fun validateEmail(email: String): String? { return when { email.isBlank() -> "Email is required" !email.contains("@") -> "Invalid email format" else -> null } } private fun validatePassword(password: String): String? { return when { password.isBlank() -> "Password is required" password.length < 6 -> "Password must be at least 6 characters" else -> null } } }

Login Screen

@Composable fun LoginScreen( onLoginSuccess: () -> Unit, onForgotPassword: () -> Unit, onSignUp: () -> Unit ) { val screenModel = getScreenModel<LoginScreenModel>() val state by screenModel.state.collectAsState() LaunchedEffect(Unit) { screenModel.events.collect { event -> when (event) { is LoginEvent.LoginSuccess -> onLoginSuccess() is LoginEvent.ShowError -> { /* Show snackbar */ } } } } LoginContent( state = state, onEmailChange = screenModel::onEmailChange, onPasswordChange = screenModel::onPasswordChange, onRememberMeChange = screenModel::onRememberMeChange, onLogin = screenModel::login, onForgotPassword = onForgotPassword, onSignUp = onSignUp ) } @Composable private fun LoginContent( state: LoginState, onEmailChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onRememberMeChange: (Boolean) -> Unit, onLogin: () -> Unit, onForgotPassword: () -> Unit, onSignUp: () -> Unit ) { var passwordVisible by remember { mutableStateOf(false) } Column( modifier = Modifier .fillMaxSize() .padding(24.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(Modifier.height(48.dp)) // Logo Icon( Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.primary ) Spacer(Modifier.height(24.dp)) // Title Text( "Welcome Back", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold ) Text( "Sign in to continue", style = MaterialTheme.typography.bodyMedium, color = Color.Gray ) Spacer(Modifier.height(48.dp)) // Email field OutlinedTextField( value = state.email, onValueChange = onEmailChange, label = { Text("Email") }, placeholder = { Text("Enter your email") }, leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }, isError = state.emailError != null, supportingText = state.emailError?.let { { Text(it) } }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Email, imeAction = ImeAction.Next ), singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp) ) Spacer(Modifier.height(16.dp)) // Password field OutlinedTextField( value = state.password, onValueChange = onPasswordChange, label = { Text("Password") }, placeholder = { Text("Enter your password") }, leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, trailingIcon = { IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon( if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, contentDescription = "Toggle password visibility" ) } }, visualTransformation = if (passwordVisible) { VisualTransformation.None } else { PasswordVisualTransformation() }, isError = state.passwordError != null, supportingText = state.passwordError?.let { { Text(it) } }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( onDone = { onLogin() } ), singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp) ) Spacer(Modifier.height(8.dp)) // Remember me + Forgot password Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( checked = state.rememberMe, onCheckedChange = onRememberMeChange ) Text("Remember me", style = MaterialTheme.typography.bodyMedium) } TextButton(onClick = onForgotPassword) { Text("Forgot Password?") } } // Login error state.loginError?.let { error -> Spacer(Modifier.height(8.dp)) Text( error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall ) } Spacer(Modifier.height(24.dp)) // Login button Button( onClick = onLogin, modifier = Modifier .fillMaxWidth() .height(56.dp), enabled = !state.isLoading, shape = RoundedCornerShape(12.dp) ) { if (state.isLoading) { CircularProgressIndicator( modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp ) } else { Text("Sign In", style = MaterialTheme.typography.titleMedium) } } Spacer(Modifier.height(24.dp)) // Or divider Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { HorizontalDivider(modifier = Modifier.weight(1f)) Text( " OR ", color = Color.Gray, style = MaterialTheme.typography.bodySmall ) HorizontalDivider(modifier = Modifier.weight(1f)) } Spacer(Modifier.height(24.dp)) // Social login buttons OutlinedButton( onClick = { /* Google login */ }, modifier = Modifier .fillMaxWidth() .height(56.dp), shape = RoundedCornerShape(12.dp) ) { Icon(Icons.Default.Person, contentDescription = null) Spacer(Modifier.width(8.dp)) Text("Continue with Google") } Spacer(Modifier.weight(1f)) // Sign up link Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Text("Don't have an account?") TextButton(onClick = onSignUp) { Text("Sign Up") } } Spacer(Modifier.height(16.dp)) } }

Reusable TextField Component

@Composable fun AppTextField( value: String, onValueChange: (String) -> Unit, label: String, modifier: Modifier = Modifier, placeholder: String = "", leadingIcon: ImageVector? = null, isPassword: Boolean = false, error: String? = null, keyboardType: KeyboardType = KeyboardType.Text, imeAction: ImeAction = ImeAction.Next, onImeAction: () -> Unit = {} ) { var passwordVisible by remember { mutableStateOf(false) } OutlinedTextField( value = value, onValueChange = onValueChange, label = { Text(label) }, placeholder = { Text(placeholder) }, leadingIcon = leadingIcon?.let { { Icon(it, contentDescription = null) } }, trailingIcon = if (isPassword) { { IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon( if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, contentDescription = null ) } } } else null, visualTransformation = if (isPassword && !passwordVisible) { PasswordVisualTransformation() } else { VisualTransformation.None }, isError = error != null, supportingText = error?.let { { Text(it) } }, keyboardOptions = KeyboardOptions( keyboardType = keyboardType, imeAction = imeAction ), keyboardActions = KeyboardActions( onDone = { onImeAction() }, onNext = { onImeAction() } ), singleLine = true, modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp) ) }

📝 Tóm tắt

ComponentMục đích
OutlinedTextFieldInput fields
PasswordVisualTransformationHide password
keyboardOptionsKeyboard type
isError + supportingTextError display
CheckboxRemember me
TextButtonSecondary actions

Validation Best Practices

  1. Validate on submit, clear errors on change
  2. Show inline errors dưới field
  3. Disable button khi loading
  4. Show loading indicator trong button

Tiếp theo

Học về Trang Onboarding.

Last updated on