Bỏ qua để đến nội dung

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.

Trang login với:

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

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()
}

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
}
}
}

@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))
}
}

@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)
)
}

Component Mục đích
OutlinedTextField Input fields
PasswordVisualTransformation Hide password
keyboardOptions Keyboard type
isError + supportingText Error display
Checkbox Remember me
TextButton Secondary actions
  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

Học về Trang Onboarding.