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
| 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
- 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
Học về Trang Onboarding.
Last updated on