Trang Login
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
Phần tiêu đề “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
Phần tiêu đề “Login State”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
Phần tiêu đề “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
Phần tiêu đề “Login Screen”@Composablefun 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 )}
@Composableprivate 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
Phần tiêu đề “Reusable TextField Component”@Composablefun 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
Phần tiêu đề “📝 Tóm tắt”| Component | Mục đích |
|---|---|
OutlinedTextField |
Input fields |
PasswordVisualTransformation |
Hide password |
keyboardOptions |
Keyboard type |
isError + supportingText |
Error display |
Checkbox |
Remember me |
TextButton |
Secondary actions |
Validation Best Practices
Phần tiêu đề “Validation Best Practices”- Validate on submit, clear errors on change
- Show inline errors dưới field
- Disable button khi loading
- Show loading indicator trong button
Tiếp theo
Phần tiêu đề “Tiếp theo”Học về Trang Onboarding.