Overview
The LoginViewModel manages the authentication flow for the application, including login form state, session validation, and navigation after successful authentication. It follows the MVVM+MVI architectural pattern.
Source: viewmodel/LoginViewModel.kt:15
class LoginViewModel(
private val authRepository: AuthRepository
) : ViewModel()
UI State
The ViewModel exposes a LoginUiState that represents the complete login screen state.
Source: viewmodel/LoginUiState.kt:7
data class LoginUiState(
val username: String = "",
val password: String = "",
val isPasswordVisible: Boolean = false,
val isLoading: Boolean = false,
val isValidatingSession: Boolean = false,
val error: String? = null,
val isLoginSuccessful: Boolean = false
)
State Fields
Current value of the username input field
Current value of the password input field
Whether the password field is showing plain text (true) or masked (false)
Loading state during login attempt
Loading state during session validation on app start
Error message to display if login fails
Flag indicating successful login, triggers navigation to main screen
Computed Properties
The LoginUiState provides a computed property for form validation:
Returns true if the login button should be enabled. Requires non-blank username and password, and no ongoing loading operations.
val isLoginEnabled: Boolean
get() = username.isNotBlank() &&
password.isNotBlank() &&
!isLoading &&
!isValidatingSession
Events
All user interactions are handled through the LoginEvent sealed interface.
Source: viewmodel/LoginEvent.kt:7
sealed interface LoginEvent {
/**
* User typed in the username field.
*/
data class OnUsernameChanged(val username: String) : LoginEvent
/**
* User typed in the password field.
*/
data class OnPasswordChanged(val password: String) : LoginEvent
/**
* User clicked the login button.
*/
data object OnLoginClicked : LoginEvent
/**
* User toggled password visibility.
*/
data object OnTogglePasswordVisibility : LoginEvent
/**
* Dismiss current error message.
*/
data object DismissError : LoginEvent
/**
* Navigation to main screen completed, reset navigation flag.
*/
data object OnNavigationHandled : LoginEvent
}
Event Descriptions
OnUsernameChanged
- Updates username field value
- Clears any existing error message
OnPasswordChanged
- Updates password field value
- Clears any existing error message
OnLoginClicked
- Validates form is enabled
- Initiates login attempt with
AuthRepository
- Trims username whitespace before submission
OnTogglePasswordVisibility
- Toggles between plain text and masked password display
- Does not affect the password value itself
DismissError
- Clears the current error message
- Allows user to retry login
OnNavigationHandled
- Resets the
isLoginSuccessful flag after navigation completes
- Prevents repeated navigation triggers
Methods
onEvent
fun onEvent(event: LoginEvent)
Single entry point for all user interactions following MVI pattern. Dispatches events to appropriate private handlers.
State Management
The ViewModel uses Kotlin StateFlow for reactive state management:
val uiState: StateFlow<LoginUiState>
Exposed as an immutable StateFlow for UI consumption:
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
Session Validation
The ViewModel automatically validates existing sessions on initialization:
init {
validateSession()
}
private fun validateSession() {
viewModelScope.launch {
// First check if there's a token at all
if (!authRepository.isAuthenticated()) {
return@launch
}
// Token exists, validate with backend
_uiState.update { it.copy(isValidatingSession = true) }
val isValid = authRepository.validateSession()
_uiState.update {
it.copy(
isValidatingSession = false,
isLoginSuccessful = isValid
)
}
}
}
Session Validation Flow
- Check if authentication token exists locally
- If no token, skip validation (user needs to log in)
- If token exists, validate with backend API
- If valid, set
isLoginSuccessful = true to navigate to main screen
- If invalid/expired, user must log in again
Login Flow
The login process follows these steps:
private fun performLogin() {
val currentState = _uiState.value
if (!currentState.isLoginEnabled) return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
authRepository.login(
username = currentState.username.trim(),
password = currentState.password
)
.onSuccess {
_uiState.update {
it.copy(
isLoading = false,
isLoginSuccessful = true
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isLoading = false,
error = error.message ?: "Login failed. Please try again."
)
}
}
}
}
Login Success Handling
When isLoginSuccessful becomes true, the UI layer should:
- Navigate to the main screen
- Call
onEvent(LoginEvent.OnNavigationHandled) to reset the flag
Usage Example
import org.koin.compose.viewmodel.koinViewModel
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit
) {
val viewModel: LoginViewModel = koinViewModel()
val state by viewModel.uiState.collectAsState()
// Handle successful login navigation
LaunchedEffect(state.isLoginSuccessful) {
if (state.isLoginSuccessful) {
onLoginSuccess()
viewModel.onEvent(LoginEvent.OnNavigationHandled)
}
}
// Show loading during session validation
if (state.isValidatingSession) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
return
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Logo or branding
Image(
painter = painterResource(Res.drawable.logo),
contentDescription = "App Logo",
modifier = Modifier.size(120.dp)
)
Spacer(modifier = Modifier.height(32.dp))
// Username field
OutlinedTextField(
value = state.username,
onValueChange = {
viewModel.onEvent(LoginEvent.OnUsernameChanged(it))
},
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
)
)
Spacer(modifier = Modifier.height(16.dp))
// Password field
OutlinedTextField(
value = state.password,
onValueChange = {
viewModel.onEvent(LoginEvent.OnPasswordChanged(it))
},
label = { Text("Password") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
visualTransformation = if (state.isPasswordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(
onClick = {
viewModel.onEvent(LoginEvent.OnTogglePasswordVisibility)
}
) {
Icon(
imageVector = if (state.isPasswordVisible) {
Icons.Default.Visibility
} else {
Icons.Default.VisibilityOff
},
contentDescription = "Toggle password visibility"
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
if (state.isLoginEnabled) {
viewModel.onEvent(LoginEvent.OnLoginClicked)
}
}
)
)
Spacer(modifier = Modifier.height(24.dp))
// Error message
state.error?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Login button
Button(
onClick = {
viewModel.onEvent(LoginEvent.OnLoginClicked)
},
enabled = state.isLoginEnabled,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(
"Login",
style = MaterialTheme.typography.labelLarge
)
}
}
}
}
State Flow Pattern
Field Updates
Field changes clear existing errors for better UX:
private fun updateUsername(username: String) {
_uiState.update { it.copy(username = username, error = null) }
}
private fun updatePassword(password: String) {
_uiState.update { it.copy(password = password, error = null) }
}
Password Visibility Toggle
private fun togglePasswordVisibility() {
_uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
}
Error Handling
Errors from the repository are captured and displayed:
authRepository.login(username, password)
.onSuccess {
_uiState.update { it.copy(isLoginSuccessful = true) }
}
.onFailure { error ->
_uiState.update {
it.copy(
error = error.message ?: "Login failed. Please try again."
)
}
}
Security Considerations
- Password Trimming - Only username is trimmed, password is sent as-is to preserve intentional spaces
- Session Validation - Existing tokens are validated with the backend, not just checked for existence
- Error Messages - Generic error message on failure to avoid revealing whether username exists
- State Clearing - Errors are cleared when user types to allow retry without dismissing
Dependency Injection
The ViewModel is provided via Koin:
// In di/PresentationModule.kt
val presentationModule = module {
viewModelOf(::LoginViewModel)
}
AuthRepository - Repository interface for authentication operations
LoginUiState - Immutable state data class
LoginEvent - Sealed interface for user events
Navigation Integration
Typical navigation setup in the app:
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "login"
) {
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("dashboard") {
popUpTo("login") { inclusive = true }
}
}
)
}
composable("dashboard") {
DashboardScreen()
}
// Other screens...
}
}