Enhanced form validation system with asynchronous validation support for server-side checks. Provides type-safe validators, state management, and seamless integration with existing synchronous validators.
Package: code.yousef.summon.components.forms Since: 1.0.0 (async support added in 0.4.8.4)
Interface for validators that perform asynchronous validation (e.g., server-side checks).
interface AsyncValidator {
suspend fun validate(value: String): String?
}
Method:
validate(value: String): String? - Validates a value asynchronously
null if validsuspend for coroutine supportExample:
class UsernameAvailabilityValidator(
private val apiClient: ApiClient
) : AsyncValidator {
override suspend fun validate(value: String): String? {
if (value.isBlank()) return null
return withContext(Dispatchers.IO) {
val isAvailable = apiClient.checkUsernameAvailability(value)
if (!isAvailable) {
"Username '$value' is already taken"
} else null
}
}
}
Manages validation state for entire forms with support for both synchronous and asynchronous validators.
class FormValidationState {
fun registerField(fieldName: String, validators: List<Validator>)
fun registerAsyncValidator(fieldName: String, validator: AsyncValidator)
fun validateField(fieldName: String, value: String): String?
suspend fun validateFieldAsync(fieldName: String, value: String): String?
fun validateAll(values: Map<String, String>): Map<String, String?>
fun getError(fieldName: String): String?
fun hasError(fieldName: String): Boolean
fun isValid(): Boolean
fun clearFieldError(fieldName: String)
fun clearAllErrors()
}
Registers synchronous validators for a field.
fun registerField(fieldName: String, validators: List<Validator>)
Parameters:
fieldName: String - The field identifiervalidators: List<Validator> - List of synchronous validatorsExample:
validationState.registerField("email", listOf(
Validator.required("Email is required"),
Validator.email("Must be a valid email")
))
Registers an asynchronous validator for a field.
fun registerAsyncValidator(fieldName: String, validator: AsyncValidator)
Parameters:
fieldName: String - The field identifiervalidator: AsyncValidator - The async validator instanceExample:
validationState.registerAsyncValidator(
"username",
UsernameAvailabilityValidator(apiClient)
)
Validates a field synchronously (runs sync validators only).
fun validateField(fieldName: String, value: String): String?
Parameters:
fieldName: String - The field to validatevalue: String - The field valueReturns: First error message, or null if valid
Example:
val error = validationState.validateField("email", emailValue)
if (error != null) {
showError(error)
}
Validates a field asynchronously (runs sync validators first, then async).
suspend fun validateFieldAsync(fieldName: String, value: String): String?
Parameters:
fieldName: String - The field to validatevalue: String - The field valueReturns: Error message if invalid, null if valid
Behavior:
Example:
launch {
val error = validationState.validateFieldAsync("username", usernameValue)
if (error != null) {
usernameError = error
}
}
Validates all registered fields synchronously.
fun validateAll(values: Map<String, String>): Map<String, String?>
Parameters:
values: Map<String, String> - Map of field names to valuesReturns: Map of field names to error messages (null if valid)
Example:
val errors = validationState.validateAll(mapOf(
"email" to emailValue,
"password" to passwordValue
))
if (validationState.isValid()) {
submitForm()
} else {
displayErrors(errors)
}
Gets the current error message for a field.
fun getError(fieldName: String): String?
Parameters:
fieldName: String - The field identifierReturns: Error message, or null if no error
Example:
val emailError = validationState.getError("email")
if (emailError != null) {
Text(emailError, modifier = Modifier().color("red"))
}
Checks if a field has an error.
fun hasError(fieldName: String): Boolean
Parameters:
fieldName: String - The field identifierReturns: true if field has error, false otherwise
Example:
TextField(
modifier = Modifier()
.borderColor(
if (validationState.hasError("email")) "red" else "#ccc"
)
)
Checks if all fields are valid.
fun isValid(): Boolean
Returns: true if all fields are valid, false if any field has error
Example:
Button(
onClick = { submitForm() },
enabled = validationState.isValid()
)
Clears the error for a specific field.
fun clearFieldError(fieldName: String)
Parameters:
fieldName: String - The field identifierExample:
// Clear error when user starts typing
TextField(
onValueChange = {
value = it
validationState.clearFieldError("email")
}
)
Clears all field errors.
fun clearAllErrors()
Example:
// Clear all errors when closing form
fun closeForm() {
validationState.clearAllErrors()
isFormOpen = false
}
Represents the result of validation (sync or async).
sealed class ValidationResult {
data class Sync(val error: String?) : ValidationResult()
data class Async(val pending: Boolean, val error: String?) : ValidationResult()
}
Variants:
Sync(error) - Synchronous validation resultAsync(pending, error) - Asynchronous validation with pending stateExample:
val result = when (validationResult) {
is ValidationResult.Sync -> {
if (result.error != null) "Error: ${result.error}" else "Valid"
}
is ValidationResult.Async -> {
if (result.pending) "Validating..."
else if (result.error != null) "Error: ${result.error}"
else "Valid"
}
}
Request object for server-side validation.
data class ServerValidationRequest(
val fieldName: String,
val value: String,
val formData: Map<String, String> = emptyMap()
)
Fields:
fieldName - The field being validatedvalue - The field valueformData - Optional additional form contextExample:
val request = ServerValidationRequest(
fieldName = "username",
value = usernameValue,
formData = mapOf("email" to emailValue)
)
Response object from server-side validation.
data class ServerValidationResponse(
val valid: Boolean,
val error: String? = null
)
Fields:
valid - Whether the value is validerror - Error message if invalidExample:
val response = ServerValidationResponse(
valid = false,
error = "Username is already taken"
)
@Composable
fun RegistrationForm() {
val usernameState = remember { mutableStateOf("") }
val validationState = remember { FormValidationState() }
// Setup validators
LaunchedEffect(Unit) {
validationState.registerField("username", listOf(
Validator.required("Username is required"),
Validator.minLength(3, "Must be at least 3 characters")
))
validationState.registerAsyncValidator(
"username",
UsernameAvailabilityValidator(apiClient)
)
}
Column {
TextField(
value = usernameState.value,
onValueChange = {
usernameState.value = it
// Validate on change
scope.launch {
validationState.validateFieldAsync("username", it)
}
}
)
validationState.getError("username")?.let { error ->
Text(error, modifier = Modifier().color("red"))
}
}
}
@Composable
fun CompleteForm() {
val validationState = remember { FormValidationState() }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// Register all fields
validationState.registerField("email", listOf(
Validator.required(),
Validator.email()
))
validationState.registerField("password", listOf(
Validator.required(),
Validator.minLength(8)
))
// Async validators
validationState.registerAsyncValidator(
"email",
EmailAvailabilityValidator(apiClient)
)
}
fun handleSubmit() {
scope.launch {
// Validate all fields
val errors = validationState.validateAll(mapOf(
"email" to emailValue,
"password" to passwordValue
))
if (validationState.isValid()) {
// All sync validation passed
// Now check async
val emailError = validationState.validateFieldAsync(
"email",
emailValue
)
if (emailError == null) {
submitForm()
}
}
}
}
}
@Composable
fun UsernameField() {
val username = remember { mutableStateOf("") }
val validationState = remember { FormValidationState() }
val scope = rememberCoroutineScope()
var validationJob: Job? = null
TextField(
value = username.value,
onValueChange = { newValue ->
username.value = newValue
// Cancel previous validation
validationJob?.cancel()
// Debounce validation
validationJob = scope.launch {
delay(500) // Wait 500ms
validationState.validateFieldAsync("username", newValue)
}
}
)
validationState.getError("username")?.let { error ->
Text(error, modifier = Modifier().color("red"))
}
}
class PasswordStrengthValidator(
private val apiClient: ApiClient
) : AsyncValidator {
override suspend fun validate(value: String): String? {
if (value.isEmpty()) return null
return withContext(Dispatchers.IO) {
try {
val strength = apiClient.checkPasswordStrength(value)
when {
strength < 0.3 -> "Password is too weak"
strength < 0.6 -> "Password strength is fair"
else -> null // Strong enough
}
} catch (e: Exception) {
// Handle error - don't block submission on API failure
null
}
}
}
}
// Always validate synchronously before async
val syncError = validationState.validateField("email", value)
if (syncError == null) {
// Only then run async validation
validationState.validateFieldAsync("email", value)
}
// Don't validate on every keystroke
var job: Job? = null
onValueChange = { value ->
job?.cancel()
job = scope.launch {
delay(500)
validationState.validateFieldAsync("field", value)
}
}
class MyAsyncValidator : AsyncValidator {
override suspend fun validate(value: String): String? {
return try {
// API call
performValidation(value)
} catch (e: Exception) {
// Don't block user on network errors
null // or show a warning
}
}
}
val isValidating = remember { mutableStateOf(false) }
TextField(
onValueChange = { value ->
isValidating.value = true
scope.launch {
validationState.validateFieldAsync("field", value)
isValidating.value = false
}
},
trailingIcon = {
if (isValidating.value) {
Spinner()
}
}
)