Formularios: el estado y las reglas
Al terminar: Guardar el estado del formulario en el ViewModel con campos tocados y calcular los errores como un tipo, con pruebas.
Al terminar esta lección, Marcador tendrá el cerebro del formulario «Añadir jugador», sin pantalla todavía: un FormularioJugador que guarda lo que se escribe y sabe decir qué está mal, un AnadirJugadorViewModel que lo conserva y lo guarda, y 7 tests nuevos que comprueban cada regla. La app se ve igual que antes: lo que ves es el marcador de tests, que sube a 23.
Importa porque en un formulario lo difícil no es dibujar los campos, sino decidir dónde vive el texto, cuándo se enseña un error y qué reglas lo producen. Si eso está bien resuelto, la pantalla de la lección Formularios: la pantalla, el teclado y las pruebas es corta. Temas: estado de un formulario, mutableStateOf en un ViewModel, campo «tocado», errores como un enum que se calcula y la diferencia entre las reglas del formulario y las del dominio.
Dónde vive el texto de un campo
Un formulario tiene dos clases de cosas que recordar: lo que el usuario escribió y lo que la app sabe (aquí, la plantilla guardada). La regla de dónde poner el estado de un campo es la que viste en State hoisting: lo más cerca posible de quien lo usa.
Dos casos, entonces:
| Caso | Dónde vive el texto | Ejemplo |
|---|---|---|
| Nadie más necesita el texto, ninguna regla lo consulta | En el composable, con rememberSaveable | El campo de «Tu nombre» de Campos de texto: TextField |
| Hay una regla que lo necesita junto a datos guardados | En el ViewModel | Hoy: «este nombre ya está en la plantilla» |
En «Añadir jugador», dos reglas necesitan lo guardado: el nombre no puede repetirse en el equipo y el dorsal tampoco. Eso es lógica de negocio, y el texto sube al ViewModel.
mutableStateOf y no StateFlow
Los ViewModel de las lecciones anteriores guardan su estado en un MutableStateFlow. El texto de un campo es el caso aparte: con la forma clásica de un campo, la de value y onValueChange que ya conoces, Google guarda el texto en un mutableStateOf y deja MutableStateFlow para el estado más complejo. Hay una forma más nueva, TextFieldState, que se ve en La forma más nueva: TextFieldState y SecureTextField; aquí usamos la clásica, que es la que Marcador mantiene.
La razón práctica: el texto cambia en cada tecla, y mutableStateOf se escribe y se lee directamente, sin corrutinas por medio. Compose repinta lo que lo leyó. En el ViewModel se declara así, con private set para que la pantalla lo lea pero solo cambie llamando a las funciones del ViewModel (mutableStateOf lo viste en Estado en Compose y private set en Propiedades y visibilidad):
var formulario by mutableStateOf(FormularioJugador())
private setEl formulario como datos
El estado del formulario es una data class con lo escrito y qué campos se han tocado. Dos decisiones, las dos con su porqué:
- Todo lo escrito es un
String, también el dorsal. Mientras alguien escribe, el texto puede ser"1","12","12a"o"": no todos son números. Convertirlo a número es lo último, cuando ya es válido. - Un campo está «tocado» en cuanto el usuario escribe en él. Hasta entonces no se enseña ningún error: nadie quiere ver «Escribe un nombre» en rojo nada más abrir la pantalla.
«Tocado» es «ya escribió en él», no «ya salió del campo». Es más sencillo (no hace falta vigilar cuándo el campo pierde el foco) y tiene un efecto que conviene saber: un dorsal "0" da error en el acto, y desaparece si sigues y escribes "07".
Los errores se calculan, no se guardan
Si el formulario guardara también el error («el nombre está vacío»), habría dos datos que pueden contradecirse: el texto y el error. Si alguien actualiza uno y olvida el otro, la pantalla enseña un error que ya no existe. Por eso el error no se guarda: se calcula cada vez, a partir del texto y de lo guardado. Y se devuelve como un enum, un caso por motivo: la regla dice qué está mal, y la pantalla elige cómo decirlo. Las reglas no saben nada de textos.
Un ejemplo ajeno, el alta de un socio en un club, con la misma forma:
// Un enum por campo: cada caso es UN motivo de error. No lleva ningún texto
enum class ErrorCorreo { VACIO, SIN_ARROBA }
enum class ErrorEdad { NO_ES_NUMERO, MENOR_DE_EDAD }
// El formulario guarda lo que se ESCRIBIÓ (siempre texto) y qué campos se han tocado.
// Los errores NO se guardan: se calculan cada vez, con las funciones de abajo
data class FormularioSocio(
val correo: String = "",
val edad: String = "",
val correoTocado: Boolean = false,
val edadTocado: Boolean = false,
) {
fun errorCorreo(): ErrorCorreo? = when {
correo.isBlank() -> ErrorCorreo.VACIO // nada escrito
'@' !in correo -> ErrorCorreo.SIN_ARROBA // '@' (comillas simples) es UN solo carácter, un Char; «!in» = «no está en»
else -> null // null = sin error
}
fun errorEdad(): ErrorEdad? {
if (edad.isBlank()) return null // la edad es opcional: vacía vale
// toIntOrNull da null si no es un número. `?:` actúa solo con ese null y ejecuta lo de su derecha,
// que aquí es un `return`: la función TERMINA en esta línea y devuelve NO_ES_NUMERO
val numero = edad.trim().toIntOrNull() ?: return ErrorEdad.NO_ES_NUMERO
return if (numero < 18) ErrorEdad.MENOR_DE_EDAD else null
}
fun esValido(): Boolean = errorCorreo() == null && errorEdad() == null
}
// Lo que se VE: el error solo cuenta si el campo está tocado
fun mostrar(f: FormularioSocio) {
val correo = if (f.correoTocado) f.errorCorreo() else null
val edad = if (f.edadTocado) f.errorEdad() else null
println("correo «${f.correo}», edad «${f.edad}» -> se ve: $correo / $edad · válido: ${f.esValido()}")
}
fun main() {
var f = FormularioSocio() // recién abierto
mostrar(f)
f = f.copy(correo = "ana.correo.com", correoTocado = true) // escribe el correo
mostrar(f)
f = f.copy(correo = "ana@correo.com", edad = "15", edadTocado = true) // corrige el correo y escribe la edad
mostrar(f)
f = f.copy(edad = "") // borra la edad
mostrar(f)
}correo «», edad «» -> se ve: null / null · válido: false
correo «ana.correo.com», edad «» -> se ve: SIN_ARROBA / null · válido: false
correo «ana@correo.com», edad «15» -> se ve: null / MENOR_DE_EDAD · válido: false
correo «ana@correo.com», edad «» -> se ve: null / null · válido: trueMira la primera línea: el formulario recién abierto no es válido (falta el correo), pero no se ve ningún error, porque nada está tocado. Y en la última, borrar la edad la deja válida de nuevo, porque era opcional. El botón de guardar mirará esValido(); los mensajes bajo cada campo mirarán el error y si el campo está tocado.
Ejercicio: la reserva de una mesa
Fuera de Marcador, en el Kotlin Playground.
Reto · Ejercicio: la reserva de una mesa
Copia este punto de partida: ya trae el main.
// Aquí escribes Reserva, los dos enum y las dos funciones
fun main() {
val reservas = listOf(
Reserva("4", "612345678"),
Reserva("", ""),
Reserva("seis", "612345678"),
Reserva("13", ""),
Reserva("2", "6123"),
)
for (r in reservas) {
println("personas «${r.personas}», teléfono «${r.telefono}» -> ${errorPersonas(r)} / ${errorTelefono(r)}")
}
}Escribe:
Reserva, un dato con dos textos:personasytelefono.- Un
enumErrorPersonascon tres casos,VACIO,NO_ES_NUMEROyFUERA_DE_RANGO, y otroErrorTelefonocon un caso,NO_SON_9_CIFRAS. errorPersonas(reserva), que devuelve el error de las personas, onullsi no hay: obligatorio, un número, de 1 a 12.errorTelefono(reserva), que devuelve el error del teléfono, onull: es opcional (vacío vale) y, si se escribe, tiene que tener exactamente 9 cifras.
Dos piezas que te hacen falta: texto.length es cuántos caracteres tiene el texto, y texto.all { it.isDigit() } es true si todos son cifras ("6123".all { it.isDigit() } da true, "61a3".all { it.isDigit() } da false).
La consola debe mostrar:
personas «4», teléfono «612345678» -> null / null
personas «», teléfono «» -> VACIO / null
personas «seis», teléfono «612345678» -> NO_ES_NUMERO / null
personas «13», teléfono «» -> FUERA_DE_RANGO / null
personas «2», teléfono «6123» -> null / NO_SON_9_CIFRASVer solución
data class Reserva(val personas: String, val telefono: String)
enum class ErrorPersonas { VACIO, NO_ES_NUMERO, FUERA_DE_RANGO }
enum class ErrorTelefono { NO_SON_9_CIFRAS }
fun errorPersonas(reserva: Reserva): ErrorPersonas? {
if (reserva.personas.isBlank()) return ErrorPersonas.VACIO
val numero = reserva.personas.trim().toIntOrNull() ?: return ErrorPersonas.NO_ES_NUMERO
return if (numero !in 1..12) ErrorPersonas.FUERA_DE_RANGO else null
}
fun errorTelefono(reserva: Reserva): ErrorTelefono? {
if (reserva.telefono.isBlank()) return null
val limpio = reserva.telefono.trim()
return if (limpio.length == 9 && limpio.all { it.isDigit() }) null else ErrorTelefono.NO_SON_9_CIFRAS
}El orden de las comprobaciones importa: primero «vacío», después «¿es un número?» y por último el rango. Cada return temprano evita preguntar lo siguiente a un texto que ya falló. El teléfono vacío sale antes de mirar las cifras porque es opcional. Nada guarda el error: se calcula cada vez que se llama.
Reglas que consultan lo guardado
Las reglas de «Añadir jugador» son estas, con la plantilla guardada del equipo como segundo dato:
| Campo | Regla | Error |
|---|---|---|
| Nombre | No está vacío (sin contar espacios) | VACIO |
| Nombre | Como mucho 25 letras | LARGO |
| Nombre | No coincide con otro jugador del equipo, sin distinguir mayúsculas | REPETIDO |
| Dorsal | Es opcional: vacío vale | — |
| Dorsal | Es un número | NO_ES_NUMERO |
| Dorsal | Está entre 1 y 99 | FUERA_DE_RANGO |
| Dorsal | Ningún otro jugador del equipo lo lleva | REPETIDO |
La regla del nombre repetido existe por un motivo concreto: los goles y los goleadores apuntan a un jugador por su nombre (lo viste en La plantilla, guardada en Room). Con dos «Ana» en el Rayo, un gol sería ambiguo. Es la regla que hace cierta la key de la lista de plantilla.
Estas reglas consultan lo guardado: la plantilla. El ViewModel la observa (observarPlantilla(equipo), que ya existe) y la copia a otro mutableStateOf, y así los errores, que la leen, se recalculan también cuando la plantilla cambia.
Dos niveles de reglas
El dominio ya tiene sus propias reglas: Equipo no admite un nombre vacío ni dos jugadores con el mismo dorsal, y lo comprueba al construirse (Clases que se validan). Son dos niveles con dos papeles:
- El formulario da mensajes amables mientras se escribe, campo a campo. Es más estricto: añade el máximo de 25 letras, el rango 1 a 99 y el nombre repetido.
- El dominio garantiza que los datos son correctos, pase lo que pase.
anadirJugadorvuelve a construir elEquipocon el jugador nuevo, y si su dorsal se repitiera, el constructor lanza y no se guarda nada.
Paso · añadir un jugador, sin pantalla
Objetivo. Dar a Marcador la operación de guardar un jugador, las reglas del formulario, el ViewModel que las usa y sus tests. Debes ver: la app igual que antes, y 23 tests en verde (los 7 nuevos son los de las reglas).
Crea los ficheros. Con el selector en Android, clic derecho sobre ui (el de feature → plantilla, que ya creaste con la pantalla) → New → Kotlin Class/File → File → FormularioJugador, y lo mismo con AnadirJugadorViewModel. Los tests copian las carpetas del código, y en los tests esa carpeta aún no existe: clic derecho sobre com.sebastiannarvaez.marcador (test) (el que lleva (test) detrás) → New → Package → añade .feature.plantilla.ui al final del nombre que aparece ya escrito. Sobre ese ui nuevo: New → Kotlin Class/File → File → FormularioJugadorTest.
Piezas nuevas de este paso:
-
var … by mutableStateOf(…)conprivate setdentro de unViewModel: del ejemplo de arriba. -
Una propiedad calculada, con
get(): no guarda un valor, sino una cuenta que se vuelve a hacer cada vez que alguien la lee. Un ejemplo ajeno:ejemplo mínimo: una propiedad calculada class Caja(var ancho: Int, var alto: Int) { val area: Int get() = ancho * alto // no se guarda: se recalcula en cada lectura } // Caja(2, 3).area da 6; si luego cambias el ancho a 5, area da 15En el
ViewModel,val errorNombre: ErrorNombre? get() = …se recalcula cada vez que la pantalla lo lee. -
equals(otro, ignoreCase = true): compara dos textos sin distinguir mayúsculas. -
enum classconwhen: del ejemplo del club. Y?: return, también de ahí:a ?: return xsignifica «siaesnull, sal de la función devolviendox; si no, sigue con el valor dea». -
Char: un solo carácter, entre comillas simples ('@'), a diferencia de unString, que va entre comillas dobles. -
mapa + (clave to valor): devuelve un mapa nuevo igual que el anterior salvo esa clave, que queda con el valor nuevo (si ya existía, se sustituye).clave to valorforma el par, y+entre una lista y un elemento da una lista con el elemento al final (+se aplica antes queto). -
"a".repeat(26): un texto con laarepetida 26 veces. Sirve en los tests para tener un nombre largo sin teclearlo.
Ya vistas: data class y copy (data class e igualdad); companion object con const val (object, companion y vararg); dorsalDesdeTexto, que escribiste en Ficheros y depurador y que quita los espacios sobrantes (por eso " 7" da 7); inyección asistida (El id del partido: inyección asistida); observarPlantilla (La plantilla, guardada en Room); kotlin.test (Tests del ViewModel de Marcador).
Intenta escribirlo tú, en este orden:
- La operación de guardar. En
Partidos.kt,PartidosRepositorygana una funciónsuspend,anadirJugador(equipo, jugador). EnPartidoDao.kt, una función que inserta un jugador. EnPartidosRepositoryRoom.kt, la implementación: espera a la siembra, lee los jugadores de ese equipo, construye unEquipocon la plantilla y el jugador nuevo y después inserta la fila. EseEquipono se guarda en ningún sitio: se construye solo para que suinitcompruebe la regla del dominio (si el dorsal se repite, lanza y no se inserta nada), y el resultado se tira. EnFalsos.kt, el falso suma el jugador a su mapa de plantillas. Hasta que el falso lo tenga, los tests no compilan. - Las reglas.
FormularioJugador.kt: ladata classconnombre,dorsaly los dos «tocado», las tres funcioneserrorNombre,errorDorsalyesValido(cada una recibe la plantilla),aJugador()y el máximo de 25 en uncompanion object; y los dosenum. - Los tests de las reglas.
FormularioJugadorTest.kt. Escribe tú los dos primeros (nombre vacío y nombre largo); los demás siguen el mismo patrón, y la tabla de abajo dice qué comprueba cada uno. - El
ViewModel.AnadirJugadorViewModel.kt, con inyección asistida con el nombre del equipo.
Compila con ./gradlew :app:assembleGratisDebug tras el paso 1 y ejecuta los tests al final.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
En Partidos.kt, la interfaz gana una línea al final (después de observarPlantilla):
// La plantilla de un equipo, observada: si alguien anade un jugador, vuelve a emitir.
fun observarPlantilla(equipo: String): Flow<List<Jugador>>
suspend fun anadirJugador(equipo: String, jugador: Jugador)En PartidoDao.kt, después de insertarJugadores:
@Insert
suspend fun insertarJugadores(jugadores: List<JugadorEntity>)
@Insert
suspend fun insertarJugador(jugador: JugadorEntity)En PartidosRepositoryRoom.kt, después de observarPlantilla y antes de equiposDesde (el import de Equipo y el de Jugador ya los tienes):
// El formulario ya comprobo sus reglas. Aun asi, la del DOMINIO sigue mandando: se arma el
// equipo con el jugador nuevo y, si su dorsal se repitiera, `Equipo` lanza y no se guarda nada.
override suspend fun anadirJugador(equipo: String, jugador: Jugador) {
sembrado.join()
val plantilla = dao.jugadores().filter { it.equipo == equipo }.map { Jugador(it.nombre, it.dorsal) }
Equipo(equipo, plantilla + jugador)
dao.insertarJugador(JugadorEntity(equipo = equipo, nombre = jugador.nombre, dorsal = jugador.dorsal))
}En el Falsos.kt de los tests, dentro de FakePartidosRepository, después de observarPlantilla (el falso suma el jugador a su mapa de plantillas, que hace de tabla jugador):
override fun observarPlantilla(equipo: String): Flow<List<Jugador>> = plantillas.map { it[equipo].orEmpty() }
override suspend fun anadirJugador(equipo: String, jugador: Jugador) {
plantillas.value = plantillas.value + (equipo to plantillas.value[equipo].orEmpty() + jugador)
}package com.sebastiannarvaez.marcador.feature.plantilla.ui
import com.sebastiannarvaez.marcador.torneo.Jugador
import com.sebastiannarvaez.marcador.torneo.dorsalDesdeTexto
// EL FORMULARIO «AÑADIR JUGADOR», COMO DATOS.
//
// Guarda lo que el usuario ESCRIBIO, tal cual (texto, aunque el dorsal sea un numero: «1»,
// «12», «12a» y «» son todos textos posibles mientras se escribe), y que campos ha TOCADO ya.
// Los errores NO se guardan: se CALCULAN cada vez a partir del texto y de la plantilla. Asi no
// pueden quedarse viejos ni contradecir lo escrito.
data class FormularioJugador(
val nombre: String = "",
val dorsal: String = "",
// Tocado = el usuario ya escribio algo en el campo. Antes de eso, un error no se ensena:
// nadie quiere ver «Escribe un nombre» en rojo nada mas abrir la pantalla.
val nombreTocado: Boolean = false,
val dorsalTocado: Boolean = false,
) {
// `plantilla` llega de fuera: las reglas «no repetido» consultan lo GUARDADO.
fun errorNombre(plantilla: List<Jugador>): ErrorNombre? {
val limpio = nombre.trim()
return when {
limpio.isEmpty() -> ErrorNombre.VACIO
limpio.length > MAXIMO_NOMBRE -> ErrorNombre.LARGO
plantilla.any { it.nombre.equals(limpio, ignoreCase = true) } -> ErrorNombre.REPETIDO
else -> null
}
}
fun errorDorsal(plantilla: List<Jugador>): ErrorDorsal? {
if (dorsal.isBlank()) return null // opcional: vacio es valido
val numero = dorsalDesdeTexto(dorsal) ?: return ErrorDorsal.NO_ES_NUMERO
return when {
numero !in 1..99 -> ErrorDorsal.FUERA_DE_RANGO
plantilla.any { it.dorsal == numero } -> ErrorDorsal.REPETIDO
else -> null
}
}
fun esValido(plantilla: List<Jugador>): Boolean = errorNombre(plantilla) == null && errorDorsal(plantilla) == null
// Solo tiene sentido si es valido: el nombre sin espacios de sobra y el dorsal como numero (o null).
fun aJugador(): Jugador = Jugador(nombre.trim(), dorsalDesdeTexto(dorsal))
companion object {
const val MAXIMO_NOMBRE = 25
}
}
// Un `enum` por campo: cada caso es UN motivo. La pantalla elige el texto de cada uno (y podria
// traducirlo); las reglas no saben nada de textos.
enum class ErrorNombre { VACIO, LARGO, REPETIDO }
enum class ErrorDorsal { NO_ES_NUMERO, FUERA_DE_RANGO, REPETIDO }package com.sebastiannarvaez.marcador.feature.plantilla.ui
import com.sebastiannarvaez.marcador.torneo.Ejemplo
import com.sebastiannarvaez.marcador.torneo.Jugador
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
// LAS REGLAS DEL FORMULARIO, sin pantalla, sin ViewModel y sin corrutinas: un FormularioJugador
// y una plantilla. La del Rayo FC: Ana #9, Luis sin dorsal y Marta #1.
class FormularioJugadorTest {
private val rayo = Ejemplo.rayo.plantilla
@Test
fun unNombreVacioOSoloConEspaciosEsUnError() {
assertEquals(ErrorNombre.VACIO, FormularioJugador(nombre = "").errorNombre(rayo))
assertEquals(ErrorNombre.VACIO, FormularioJugador(nombre = " ").errorNombre(rayo))
}
@Test
fun unNombreDeMasDe25LetrasEsDemasiadoLargo() {
assertEquals(ErrorNombre.LARGO, FormularioJugador(nombre = "a".repeat(26)).errorNombre(rayo))
assertNull(FormularioJugador(nombre = "a".repeat(25)).errorNombre(rayo))
}
@Test
fun unNombreQueYaEstaEnLaPlantillaEsUnError() {
assertEquals(ErrorNombre.REPETIDO, FormularioJugador(nombre = " ana ").errorNombre(rayo))
}
@Test
fun elDorsalEsOpcional() {
assertNull(FormularioJugador(nombre = "Bea", dorsal = "").errorDorsal(rayo))
assertTrue(FormularioJugador(nombre = "Bea", dorsal = "").esValido(rayo))
}
@Test
fun unDorsalQueNoEsUnNumeroDel1Al99EsUnError() {
assertEquals(ErrorDorsal.NO_ES_NUMERO, FormularioJugador(dorsal = "diez").errorDorsal(rayo))
assertEquals(ErrorDorsal.FUERA_DE_RANGO, FormularioJugador(dorsal = "0").errorDorsal(rayo))
assertEquals(ErrorDorsal.FUERA_DE_RANGO, FormularioJugador(dorsal = "100").errorDorsal(rayo))
}
@Test
fun unDorsalQueYaLlevaOtroJugadorEsUnError() {
assertEquals(ErrorDorsal.REPETIDO, FormularioJugador(nombre = "Bea", dorsal = "9").errorDorsal(rayo))
assertFalse(FormularioJugador(nombre = "Bea", dorsal = "9").esValido(rayo))
}
@Test
fun unFormularioValidoDaElJugadorLimpio() {
assertEquals(Jugador("Bea", 7), FormularioJugador(nombre = " Bea ", dorsal = " 7").aJugador())
}
}package com.sebastiannarvaez.marcador.feature.plantilla.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import com.sebastiannarvaez.marcador.torneo.Jugador
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
// EL ESTADO DEL FORMULARIO VIVE AQUI, en `mutableStateOf` y no en un StateFlow.
//
// Un campo de texto necesita que su valor cambie EN EL ACTO, en la misma pasada en la que se
// pulsa la tecla. `mutableStateOf` se escribe y se lee sin corrutinas ni esperas; Compose
// repinta lo que lo leyo. Es la forma clásica de guardar el texto de un TextField.
@HiltViewModel(assistedFactory = AnadirJugadorViewModel.Fabrica::class)
class AnadirJugadorViewModel @AssistedInject constructor(
private val repositorio: PartidosRepository,
@Assisted val equipo: String,
) : ViewModel() {
@AssistedFactory
interface Fabrica {
fun crear(equipo: String): AnadirJugadorViewModel
}
// `private set`: la pantalla lee el formulario, pero solo cambia llamando a las funciones de abajo.
var formulario by mutableStateOf(FormularioJugador())
private set
// La plantilla GUARDADA, para las reglas «no repetido». Llega de Room como Flow y se copia a
// un mutableStateOf: asi los errores (que la leen) se recalculan tambien cuando ella cambia.
private var plantilla by mutableStateOf(emptyList<Jugador>())
// Pasa a true cuando el jugador ya esta guardado: la pantalla lo ve y vuelve atras.
var guardado by mutableStateOf(false)
private set
private var guardando = false
init {
viewModelScope.launch {
repositorio.observarPlantilla(equipo).collect { plantilla = it }
}
}
// Lo que ve la pantalla: el error SOLO si el campo esta tocado; si no, null.
val errorNombre: ErrorNombre?
get() = if (formulario.nombreTocado) formulario.errorNombre(plantilla) else null
val errorDorsal: ErrorDorsal?
get() = if (formulario.dorsalTocado) formulario.errorDorsal(plantilla) else null
// El boton «Guardar» solo se activa con el formulario valido, tocado o no.
val sePuedeGuardar: Boolean
get() = formulario.esValido(plantilla)
fun alEscribirNombre(texto: String) {
formulario = formulario.copy(nombre = texto, nombreTocado = true)
}
fun alEscribirDorsal(texto: String) {
formulario = formulario.copy(dorsal = texto, dorsalTocado = true)
}
fun guardar() {
if (!sePuedeGuardar || guardando) return
guardando = true
viewModelScope.launch {
repositorio.anadirJugador(equipo, formulario.aJugador())
guardado = true
}
}
}Qué hace cada pieza
FormularioJugador. Las funciones de reglas reciben plantilla porque las reglas «no repetido» consultan lo guardado. aJugador() solo tiene sentido con el formulario válido. Los dos enum no llevan texto: son motivos.
AnadirJugadorViewModel.
plantillaes la copia de lo guardado: eninit, una corrutina recogeobservarPlantilla(equipo)y la deja en unmutableStateOf.errorNombreyerrorDorsalson propiedades calculadas: el error solo si el campo está tocado; si no,null.sePuedeGuardarmiraesValido, tocado o no: con el formulario recién abierto, el botón estará apagado aunque no se vea ningún error.guardar()no hace nada si el formulario no es válido o ya se está guardando; si no, llama aanadirJugadory poneguardadoatrue.guardandoes unvarnormal porque la pantalla no lo lee, y no vuelve afalseporque la pantalla se cierra al terminar.
FormularioJugadorTest. Prueba las reglas sin pantalla, sin ViewModel y sin corrutinas: un FormularioJugador y la plantilla del Rayo FC (Ana #9, Luis sin dorsal y Marta #1). Es la ventaja de que las reglas sean funciones de datos. Cada test, en una línea:
| Test | Qué comprueba |
|---|---|
unNombreVacioOSoloConEspaciosEsUnError | "" y " " dan VACIO |
unNombreDeMasDe25LetrasEsDemasiadoLargo | 26 letras dan LARGO; 25 no dan error |
unNombreQueYaEstaEnLaPlantillaEsUnError | " ana " da REPETIDO (sin espacios ni mayúsculas) |
elDorsalEsOpcional | dorsal vacío: sin error y formulario válido |
unDorsalQueNoEsUnNumeroDel1Al99EsUnError | "diez" da NO_ES_NUMERO; "0" y "100", FUERA_DE_RANGO |
unDorsalQueYaLlevaOtroJugadorEsUnError | "9" (el de Ana) da REPETIDO y el formulario no es válido |
unFormularioValidoDaElJugadorLimpio | aJugador() quita los espacios: Jugador("Bea", 7) |
En el último test, assertEquals compara dos Jugador creados por separado y da verde porque Jugador sí es una data class: compara por contenido. Es lo contrario de Equipo, que comparaba por identidad en La plantilla, guardada en Room.
Ejecuta los tests desde la Terminal:
./gradlew :app:testGratisDebugUnitTestPunto de control
./gradlew :app:testGratisDebugUnitTesttermina enBUILD SUCCESSFULy cuenta 23 tests (26 si hiciste el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine) y 0 fallos. Los 7 nuevos son los deFormularioJugadorTest.- La app se ve igual que antes: todavía no hay pantalla de «Añadir jugador».
AnadirJugadorViewModelyFormularioJugadorexisten enfeature/plantilla/ui, yPartidosRepositorytieneanadirJugador.
En Formularios: la pantalla, el teclado y las pruebas dibujas el formulario, lo conectas a este ViewModel y lo pruebas.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.En «Añadir jugador», el texto de los campos vive en el
ViewModely no en el composable. ¿Por qué?2.El formulario guarda el dorsal como
Stringy no comoInt. ¿Cuál es la razón?3.Se abre «Añadir jugador» y el nombre está vacío, pero no se ve ningún error. ¿Qué lo explica?
4.¿Por qué los errores del formulario se devuelven como un
enumcalculado y no se guardan en ladata class?5.Alguien llama a
anadirJugadorsaltándose el formulario, con un dorsal que ya lleva otro jugador. ¿Qué pasa?