Formularios: la pantalla, el teclado y las pruebas
Al terminar: Montar el formulario de añadir jugador con errores bajo cada campo, teclado numérico, foco y un botón Guardar.
Al terminar esta lección, la plantilla de un equipo tendrá un botón Añadir jugador que abre un formulario de verdad: un campo Nombre con su contador «3/25», un campo Dorsal (opcional) con teclado de números, el error en rojo debajo de cada campo cuando algo no vale, un botón Guardar que solo se activa si todo es válido y la vuelta automática a la plantilla, con el jugador nuevo ya en la lista. Y cuatro tests más del ViewModel del formulario.
Importa porque un formulario se juzga por lo que se siente al usarlo: que el teclado sea el adecuado, que el error salga en el sitio donde se mira y que «Hecho» haga lo que el botón. Nada de eso son reglas del negocio: son cosas de la pantalla, y esta lección las pone todas. Temas: isError y supportingText, KeyboardOptions e ImeAction, un evento único modelado como estado y los tests de un ViewModel cuyo estado es un mutableStateOf.
Esta lección continúa donde terminó Formularios: el estado y las reglas: ya tienes FormularioJugador, AnadirJugadorViewModel y 23 tests, pero ninguna pantalla los usa. Hoy la pantalla.
Una pantalla que solo pinta
La pantalla del formulario no decide nada. Recibe el formulario, los errores y si se puede guardar, los dibuja, y avisa de cada tecla y de cada pulsación. Las reglas ya están en FormularioJugador, y el estado, en el ViewModel.
| Quién | Qué decide | En Marcador |
|---|---|---|
| Las reglas | Si un nombre o un dorsal valen y por qué no | FormularioJugador |
El ViewModel | Qué se ha escrito, qué campo está tocado, si se puede guardar | AnadirJugadorViewModel |
| La pantalla | Cómo se ve cada error y qué teclado sale | PantallaAnadirJugador |
| Quien tiene la pila de pantallas | A dónde se vuelve | La entrada de AppMarcador |
El error, bajo el campo: isError y supportingText
Un campo de Material tiene dos parámetros para enseñar un error: isError y supportingText. isError es un booleano: con true, el borde y la etiqueta del campo se ponen en rojo. supportingText es una lambda que pinta una línea debajo del campo, que es donde el usuario mira al equivocarse.
Las reglas de la lección anterior devuelven un enum (ErrorDorsal.REPETIDO), no una frase. La frase la elige la pantalla con un when, igual que el when sin else sobre un enum de Enum y when exhaustivo: si mañana se añade un caso al enum, el compilador avisa de que falta su texto. Y si un día la app se traduce, solo cambia la pantalla.
Hay un detalle más: el mismo sitio, supportingText, sirve para dos cosas. Si hay error, enseña el error; si no, puede enseñar una ayuda, como el contador «3/25» del nombre.
El teclado: KeyboardOptions e ImeAction
El teclado en pantalla es un programa aparte, el IME (siglas de Input Method Editor, «editor de método de entrada»). Tu app no lo dibuja, pero le puede pedir cosas con KeyboardOptions:
keyboardType: qué teclas se ofrecen (KeyboardType.Numberpara un dorsal,KeyboardType.Emailpara un correo, con la arroba a mano,KeyboardType.Passwordpara una contraseña).capitalization: cuándo pone mayúsculas solo (KeyboardCapitalization.Wordsle pide al teclado mayúscula al empezar cada palabra, como en «Ana Pérez»).imeAction: qué hace la tecla de acción, la de abajo a la derecha.ImeAction.Next(«Siguiente») pasa al campo de debajo;ImeAction.Done(«Hecho») cierra el teclado y termina.
Lo segundo importa: KeyboardOptions es una petición, no una orden. Por eso el campo del dorsal pide el teclado de números y, aun así, la regla «Escribe solo números» sigue existiendo: otro teclado, o un texto pegado, puede traer letras.
La tecla de acción por sí sola hace poco. ImeAction.Next mueve el foco al campo siguiente sin que escribas código, y es lo que querrás casi siempre. ImeAction.Done solo pone la tecla «Hecho»: qué hace al pulsarla lo decides tú con KeyboardActions(onDone = { … }). En Marcador, «Hecho» en el dorsal hace lo mismo que el botón Guardar: llama a guardar(). Y no hay peligro de guardar un formulario inválido, porque guardar() empieza con if (!sePuedeGuardar || guardando) return.
Volver al guardar: un evento único, como estado
Tras guardar, hay que volver a la plantilla. ¿Quién da la orden? No puede ser el ViewModel: no conoce la pila de pantallas ni debe conocerla. Tiene que ser quien la tiene. En el ejemplo de abajo es la propia pantalla; en Marcador la pila está en AppMarcador, así que el efecto va en su entrada (entry<AnadirJugadorKey>) y no dentro de PantallaAnadirJugador, que solo pinta. Pero quien mueve la pila no se entera de que se guardó... salvo que el ViewModel se lo diga. Una forma sencilla: el ViewModel pone un booleano, guardado, a true, y el otro lado lo mira.
Leer el booleano es fácil; lo delicado es actuar. Una función @Composable puede ejecutarse muchas veces (Efectos en Compose: LaunchedEffect), y volver atrás no se puede repetir. Por eso la acción va dentro de un LaunchedEffect(guardado): su bloque corre cuando entra en la composición y cada vez que guardado cambia, no en cada recomposición.
class ReservaViewModel : ViewModel() {
// La nota de la nevera. `private set`: solo el ViewModel la cambia
var reservada by mutableStateOf(false)
private set
fun reservar() {
// ...aquí iría guardar la reserva...
reservada = true
}
}
@Composable
fun PantallaReserva(viewModel: ReservaViewModel, alTerminar: () -> Unit) {
// Clave = reservada: el bloque corre al entrar (reservada es false: no hace nada)
// y otra vez cuando pasa a true, que es cuando hay que volver atrás
LaunchedEffect(viewModel.reservada) {
if (viewModel.reservada) alTerminar()
}
Button(onClick = viewModel::reservar) { Text("Reservar mesa") }
}Lo que pasa: se pinta el botón Reservar mesa; al pulsarlo, reservada pasa a true, la clave del efecto cambia, el efecto vuelve a correr y llama a alTerminar(). Tras volver, la pantalla desaparece y la nota se tira con ella: no hace falta ponerla en false.
viewModel::reservar es una referencia a una función: una forma corta de pasar reservar como lambda, sin escribir { viewModel.reservar() }.
El formulario completo, en un ejemplo
Un ejemplo para leer, con datos ajenos a Marcador: el alta de un socio de un club, con las mismas piezas que Marcador usará dentro de un rato. No hace falta escribirlo: te sirve de modelo para el ejercicio de después, donde Android Studio propone los import con Option+Enter sobre cada nombre en rojo.
// Las piezas de la lección anterior, abreviadas: los datos del formulario y un enum por campo
data class FormularioSocio(
val correo: String = "",
val edad: String = "",
val correoTocado: Boolean = false,
val edadTocado: Boolean = false,
)
enum class ErrorCorreo { VACIO, SIN_ARROBA }
enum class ErrorEdad { NO_ES_NUMERO, MENOR_DE_EDAD }
// Una pantalla SIN estado propio: pinta lo que recibe y avisa de cada tecla
@Composable
fun AltaDeSocio(
formulario: FormularioSocio,
errorCorreo: ErrorCorreo?, // null = sin error (o campo sin tocar)
errorEdad: ErrorEdad?,
sePuedeAnotar: Boolean,
onCorreo: (String) -> Unit,
onEdad: (String) -> Unit,
onAnotar: () -> Unit,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = formulario.correo,
onValueChange = onCorreo,
label = { Text("Correo") },
singleLine = true,
isError = errorCorreo != null, // borde y etiqueta en rojo
supportingText = { errorCorreo?.let { Text(textoDe(it)) } }, // la línea de debajo
// Teclado de correo (con la arroba a mano) y la tecla «Siguiente» abajo a la derecha
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = formulario.edad,
onValueChange = onEdad,
label = { Text("Edad") },
singleLine = true,
isError = errorEdad != null,
supportingText = { errorEdad?.let { Text(textoDe(it)) } },
// Teclado de números y la tecla «Hecho»; al pulsarla se hace lo mismo que con el botón
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onAnotar() }),
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = onAnotar, enabled = sePuedeAnotar, modifier = Modifier.fillMaxWidth()) {
Text("Anotar socio")
}
}
}
// El enum se convierte en frase AQUÍ, en la pantalla: las reglas no saben de idiomas.
// Hay DOS funciones llamadas `textoDe`: Kotlin elige cuál ejecutar por el tipo del argumento
private fun textoDe(error: ErrorCorreo): String = when (error) {
ErrorCorreo.VACIO -> "Escribe un correo"
ErrorCorreo.SIN_ARROBA -> "Al correo le falta la arroba"
}
private fun textoDe(error: ErrorEdad): String = when (error) {
ErrorEdad.NO_ES_NUMERO -> "Escribe solo números"
ErrorEdad.MENOR_DE_EDAD -> "Hay que tener 18 años"
}Dos funciones con el mismo nombre, textoDe, no chocan: se llama sobrecarga, y Kotlin elige la que corresponde por el tipo de lo que le pasas (un ErrorCorreo o un ErrorEdad). Con una vista previa que le pase errorCorreo = ErrorCorreo.SIN_ARROBA, errorEdad = ErrorEdad.MENOR_DE_EDAD y sePuedeAnotar = false, se vería: dos campos con el borde en rojo, «Al correo le falta la arroba» bajo el primero, «Hay que tener 18 años» bajo el segundo, y el botón Anotar socio gris. Fíjate en lo que no hay: ni un if que decida si el correo vale ni un remember. La pantalla recibe todo hecho.
Reto · Ejercicio: cuántas entradas
En tu carpeta ejercicios (la que creaste en Rotación y muerte del proceso), crea la subcarpeta entradas: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .ejercicios.entradas al final del nombre que aparece ya escrito. Después, clic derecho sobre esa carpeta → New → Kotlin Class/File → File → Entradas. La primera línea debe ser package com.sebastiannarvaez.marcador.ejercicios.entradas.
Un campo para pedir entradas de cine, de 1 a 10. Parte de este enum (pégalo en el fichero) y escribe dos piezas, como en el ejercicio del termostato de State hoisting:
enum class ErrorEntradas { NO_ES_NUMERO, FUERA_DE_RANGO }CampoDeEntradas, sin estado propio: recibe el texto, el error (que puede sernull), un aviso por cada cambio de texto y un modificador opcional. Dibuja unOutlinedTextFieldde una línea con la etiqueta «Entradas», teclado de números y la tecla «Hecho». Con error, borde rojo y debajo la frase («Escribe solo números» o «Solo de 1 a 10»); sin error, debajo la ayuda «De 1 a 10».Entradas, el dueño del estado: guarda el texto (sobrevive al giro) y calcula el error. Nada escrito no es error; un texto que no es un número esNO_ES_NUMERO; un número fuera de 1 a 10 esFUERA_DE_RANGO.
Añade dos vistas previas: CampoDeEntradas con el texto 12 y el error FUERA_DE_RANGO, y Entradas tal cual.
Lo que debes ver en la primera: el borde rojo y «Solo de 1 a 10» debajo. En la segunda, en el modo interactivo (Estado en Compose):
| Escribes | Debe verse |
|---|---|
| Nada | «De 1 a 10», sin rojo |
abc | Borde rojo y «Escribe solo números» |
12 | Borde rojo y «Solo de 1 a 10» |
4 | «De 1 a 10», sin rojo |
Pistas, todo de lo visto: la forma de AltaDeSocio para el campo; texto.trim().toIntOrNull() convierte el texto en un número, o en null si no lo es, y numero !in 1..10 pregunta si queda fuera del rango (Formularios: el estado y las reglas); el dueño del estado, como Recetas en Una fila que se toca: la plantilla. Un when sobre un valor que puede ser null admite una rama null ->.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.entradas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
enum class ErrorEntradas { NO_ES_NUMERO, FUERA_DE_RANGO }
@Composable
fun CampoDeEntradas(
texto: String,
error: ErrorEntradas?,
onTexto: (String) -> Unit,
modifier: Modifier = Modifier,
) {
OutlinedTextField(
value = texto,
onValueChange = onTexto,
label = { Text("Entradas") },
singleLine = true,
isError = error != null,
supportingText = {
Text(
when (error) {
ErrorEntradas.NO_ES_NUMERO -> "Escribe solo números"
ErrorEntradas.FUERA_DE_RANGO -> "Solo de 1 a 10"
null -> "De 1 a 10"
},
)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
modifier = modifier.fillMaxWidth(),
)
}
@Composable
fun Entradas() {
var texto by rememberSaveable { mutableStateOf("") }
val numero = texto.trim().toIntOrNull()
// El error se calcula en cada pasada: no se guarda en ninguna variable
val error = when {
texto.isBlank() -> null
numero == null -> ErrorEntradas.NO_ES_NUMERO
numero !in 1..10 -> ErrorEntradas.FUERA_DE_RANGO
else -> null
}
CampoDeEntradas(texto, error, onTexto = { texto = it }, modifier = Modifier.padding(16.dp))
}
@Preview(showBackground = true)
@Composable
private fun CampoDeEntradasPreview() {
MaterialTheme {
CampoDeEntradas("12", ErrorEntradas.FUERA_DE_RANGO, onTexto = {}, modifier = Modifier.padding(16.dp))
}
}
@Preview(showBackground = true)
@Composable
private fun EntradasPreview() {
MaterialTheme { Entradas() }
}CampoDeEntradas no decide nada: pinta lo que recibe, igual que AltaDeSocio. Entradas guarda el texto y calcula el error en cada pasada, sin guardarlo, así que no puede quedarse viejo.
Paso · El formulario de añadir jugador
Objetivo. Dar pantalla al formulario de la lección anterior y llegar a ella desde la plantilla. Al terminar, en Tablas tocas «1. Rayo FC», en la plantilla pulsas Añadir jugador, escribes «Bea» y un dorsal libre, Guardar, y vuelves a la plantilla con Bea al final.
Crea los ficheros. Con el selector en Android, despliega app → kotlin+java → com.sebastiannarvaez.marcador. Clic derecho sobre ui de feature → plantilla (la carpeta de la plantilla, donde ya están FormularioJugador y su ViewModel) → New → Kotlin Class/File → File → PantallaAnadirJugador. El test va en la misma carpeta, pero en la rama com.sebastiannarvaez.marcador (test) (la que lleva (test) detrás): despliega feature → plantilla → ui, donde ya está FormularioJugadorTest, clic derecho sobre ui → New → Kotlin Class/File → File → AnadirJugadorViewModelTest.
Piezas nuevas de este paso:
OutlinedTextFieldconisErrorysupportingText,KeyboardOptionsyKeyboardActions: arriba.Button(enabled = …): un botón con el parámetroenabled; confalse, sale gris y no responde.LaunchedEffect(vm.guardado): el ejemplo de la reserva.vm::alEscribirNombre: una referencia a una función, comoviewModel::reservar.bottomBar: el hueco de abajo delScaffold. UnaRowconArrangement.Centerdentro centra el botón.
Intenta escribirlo tú, en este orden:
PantallaAnadirJugador: título «Añadir jugador», debajo «Nuevo jugador de» y el equipo, y los dos campos con sus errores, su teclado y su botón. Sin estado propio: recibe el equipo, el formulario, los dos errores,sePuedeGuardary tres lambdas (nombre, dorsal, guardar). Las frases de los errores son: «Escribe un nombre», «Como mucho 25 letras» y «Ya hay un jugador con ese nombre»; «Escribe solo números», «Del 1 al 99» y «Ese dorsal ya lo lleva otro jugador». Bajo el nombre, sin error, el contador «n/25». Añade una vista previa con el error de dorsal repetido.- En
PantallaPlantilla: el botón Añadir jugador abajo, con un parámetroonAnadir. Desde que cambies la firma dePantallaPlantilla, el proyecto estará en rojo hasta que hagas también el cambio deAppMarcador. Es normal. - En
AppMarcador: losimport, la clave, la entrada nueva con elLaunchedEffect, yonAnadiren la entrada de la plantilla. - Los tests, con el primero escrito por ti.
Debes ver el botón en la plantilla, el formulario con todo lo de arriba, y 27 tests que pasan.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.feature.plantilla.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
// LA PANTALLA DEL FORMULARIO. No decide nada: pinta el formulario y los errores que le dan, y
// avisa de cada tecla. Lo unico suyo es lo que solo tiene sentido en pantalla: el foco y el
// teclado, y el TEXTO de cada error (las reglas devuelven un enum; aqui se elige la frase).
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PantallaAnadirJugador(
equipo: String,
formulario: FormularioJugador,
errorNombre: ErrorNombre?,
errorDorsal: ErrorDorsal?,
sePuedeGuardar: Boolean,
onNombre: (String) -> Unit,
onDorsal: (String) -> Unit,
onGuardar: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0),
topBar = { CenterAlignedTopAppBar(title = { Text("Añadir jugador") }) },
) { interior ->
Column(
Modifier.padding(interior).fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Nuevo jugador de $equipo", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = formulario.nombre,
onValueChange = onNombre,
label = { Text("Nombre") },
singleLine = true,
isError = errorNombre != null,
// Debajo del campo: el error si lo hay; si no, cuantas letras van de las 25.
supportingText = {
Text(errorNombre?.let(::textoDe) ?: "${formulario.nombre.trim().length}/${FormularioJugador.MAXIMO_NOMBRE}")
},
// Mayuscula al empezar cada palabra, y la tecla «Siguiente» en el teclado: pasa
// el foco al campo de abajo (es lo que hace ImeAction.Next por su cuenta).
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = formulario.dorsal,
onValueChange = onDorsal,
label = { Text("Dorsal (opcional)") },
singleLine = true,
isError = errorDorsal != null,
supportingText = { errorDorsal?.let { Text(textoDe(it)) } },
// Teclado de numeros y la tecla «Hecho», que guarda como el boton.
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onGuardar() }),
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = onGuardar, enabled = sePuedeGuardar, modifier = Modifier.fillMaxWidth()) {
Text("Guardar")
}
}
}
}
// El enum se traduce a texto AQUI, en la pantalla: las reglas no saben de idiomas.
private fun textoDe(error: ErrorNombre): String = when (error) {
ErrorNombre.VACIO -> "Escribe un nombre"
ErrorNombre.LARGO -> "Como mucho ${FormularioJugador.MAXIMO_NOMBRE} letras"
ErrorNombre.REPETIDO -> "Ya hay un jugador con ese nombre"
}
private fun textoDe(error: ErrorDorsal): String = when (error) {
ErrorDorsal.NO_ES_NUMERO -> "Escribe solo números"
ErrorDorsal.FUERA_DE_RANGO -> "Del 1 al 99"
ErrorDorsal.REPETIDO -> "Ese dorsal ya lo lleva otro jugador"
}
@Preview(showBackground = true)
@Composable
private fun PantallaAnadirJugadorPreview() {
MaterialTheme {
PantallaAnadirJugador(
equipo = "Rayo FC",
formulario = FormularioJugador(nombre = "Bea", dorsal = "9", nombreTocado = true, dorsalTocado = true),
errorNombre = null,
errorDorsal = ErrorDorsal.REPETIDO,
sePuedeGuardar = false,
onNombre = {},
onDorsal = {},
onGuardar = {},
)
}
}En PantallaPlantilla.kt, un import nuevo junto a los demás de Material:
import androidx.compose.material3.ButtonLa firma gana onAnadir, antes del modificador:
fun PantallaPlantilla(equipo: String, jugadores: List<Jugador>, onAnadir: () -> Unit, modifier: Modifier = Modifier) {Y el Scaffold gana la barra de abajo, justo detrás del topBar:
bottomBar = {
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.Center) {
Button(onClick = onAnadir) { Text("Añadir jugador") }
}
},La vista previa del final del fichero pasa onAnadir = {}:
@Preview(showBackground = true)
@Composable
private fun PantallaPlantillaPreview() {
MaterialTheme { PantallaPlantilla(Ejemplo.rayo.nombre, Ejemplo.rayo.plantilla, onAnadir = {}) }
}En AppMarcador.kt, tres import nuevos: el de LaunchedEffect junto al de Composable, y los otros dos (de feature → plantilla → ui, otra carpeta) junto a los demás del proyecto. Option+Enter sobre cada nombre en rojo también los añade:
import androidx.compose.runtime.LaunchedEffect
import com.sebastiannarvaez.marcador.feature.plantilla.ui.AnadirJugadorViewModel
import com.sebastiannarvaez.marcador.feature.plantilla.ui.PantallaAnadirJugadorLa clave nueva, debajo de PlantillaKey:
@Serializable
data class AnadirJugadorKey(val equipo: String) : NavKeyEn la entrada de PlantillaKey, la última línea (la llamada a PantallaPlantilla) pasa a llevar onAnadir:
PantallaPlantilla(clave.equipo, jugadores, onAnadir = { pila.add(AnadirJugadorKey(clave.equipo)) })Y la entrada nueva, justo debajo de la de PlantillaKey:
entry<AnadirJugadorKey> { clave ->
val vm = hiltViewModel<AnadirJugadorViewModel, AnadirJugadorViewModel.Fabrica> { fabrica ->
fabrica.crear(clave.equipo)
}
// Guardado: se vuelve a la plantilla, que ya muestra al jugador nuevo (la observa).
LaunchedEffect(vm.guardado) {
if (vm.guardado) pila.removeAt(pila.lastIndex)
}
PantallaAnadirJugador(
equipo = clave.equipo,
formulario = vm.formulario,
errorNombre = vm.errorNombre,
errorDorsal = vm.errorDorsal,
sePuedeGuardar = vm.sePuedeGuardar,
onNombre = vm::alEscribirNombre,
onDorsal = vm::alEscribirDorsal,
onGuardar = vm::guardar,
)
}Con eso el proyecto vuelve a compilar. Ahora los tests:
package com.sebastiannarvaez.marcador.feature.plantilla.ui
import com.sebastiannarvaez.marcador.core.FakePartidosRepository
import com.sebastiannarvaez.marcador.torneo.Ejemplo
import com.sebastiannarvaez.marcador.torneo.Jugador
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
// El ViewModel del formulario, con el repositorio falso (la plantilla del Rayo FC: Ana #9,
// Luis sin dorsal y Marta #1). El formulario es un mutableStateOf: se lee DIRECTAMENTE, sin
// Turbine. Lo unico asincrono es la plantilla (llega por Flow) y el guardado.
@OptIn(ExperimentalCoroutinesApi::class)
class AnadirJugadorViewModelTest {
@BeforeTest
fun antes() = Dispatchers.setMain(StandardTestDispatcher())
@AfterTest
fun despues() = Dispatchers.resetMain()
private val rayo = Ejemplo.rayo.nombre
@Test
fun alAbrirNoSeVeNingunErrorYNoSePuedeGuardar() = runTest {
val viewModel = AnadirJugadorViewModel(FakePartidosRepository(), rayo)
advanceUntilIdle()
assertNull(viewModel.errorNombre)
assertNull(viewModel.errorDorsal)
assertFalse(viewModel.sePuedeGuardar)
}
@Test
fun borrarElNombreEnsenaElErrorPorqueElCampoYaEstaTocado() = runTest {
val viewModel = AnadirJugadorViewModel(FakePartidosRepository(), rayo)
viewModel.alEscribirNombre("B")
viewModel.alEscribirNombre("")
assertEquals(ErrorNombre.VACIO, viewModel.errorNombre)
}
@Test
fun unDorsalDeLaPlantillaGuardadaEsUnError() = runTest {
val viewModel = AnadirJugadorViewModel(FakePartidosRepository(), rayo)
advanceUntilIdle() // llega la plantilla
viewModel.alEscribirNombre("Bea")
viewModel.alEscribirDorsal("9")
assertEquals(ErrorDorsal.REPETIDO, viewModel.errorDorsal)
assertFalse(viewModel.sePuedeGuardar)
}
@Test
fun guardarUnJugadorValidoLoAnadeALaPlantilla() = runTest {
val repositorio = FakePartidosRepository()
val viewModel = AnadirJugadorViewModel(repositorio, rayo)
advanceUntilIdle()
viewModel.alEscribirNombre("Bea")
viewModel.alEscribirDorsal("7")
assertTrue(viewModel.sePuedeGuardar)
viewModel.guardar()
advanceUntilIdle()
assertTrue(viewModel.guardado)
assertEquals(Jugador("Bea", 7), repositorio.observarPlantilla(rayo).first().last())
}
}Dos líneas que pueden extrañar. errorNombre?.let(::textoDe) pasa la función textoDe como referencia, igual que viewModel::reservar pero sin objeto delante porque es una función suelta; equivale a errorNombre?.let { textoDe(it) }, y Kotlin elige entre los dos textoDe por el tipo del error. Y pila.removeAt(pila.lastIndex): lastIndex es la posición de la última clave de la pila y removeAt quita la que está en esa posición, es decir, la pantalla de arriba: es el «atrás» de la pila de Navigation 3. La plantilla que aparece al volver ya muestra a Bea porque observa la base (La plantilla, guardada en Room); nadie la recarga.
Qué comprueban los cuatro tests. Son del AnadirJugadorViewModel con el repositorio falso de la lección anterior, cuya plantilla del Rayo FC es Ana #9, Luis sin dorsal y Marta #1:
| Test | Qué comprueba |
|---|---|
alAbrirNoSeVeNingunErrorYNoSePuedeGuardar | Con el formulario recién abierto no hay ningún error visible, y aun así Guardar está desactivado |
borrarElNombreEnsenaElErrorPorqueElCampoYaEstaTocado | Escribir «B» y borrarlo deja el nombre vacío y tocado, así que sale el error |
unDorsalDeLaPlantillaGuardadaEsUnError | Con «Bea» y dorsal 9, el 9 es de Ana: ErrorDorsal.REPETIDO y no se puede guardar |
guardarUnJugadorValidoLoAnadeALaPlantilla | Con «Bea» y dorsal 7, guardar() marca guardado y la plantilla del falso acaba con Bea #7 |
Dos cosas distintas de otros tests de ViewModel. Primera: no hay Turbine. El formulario es un mutableStateOf, que se lee directamente (viewModel.errorNombre) y siempre devuelve el valor de ahora; Turbine hace falta para un StateFlow que emite con el tiempo. Segunda: lo único asíncrono es la plantilla, que llega por un Flow recogido en el init del ViewModel. Con un StandardTestDispatcher (Tests de un ViewModel: fakes, setMain y Turbine) esa recogida no corre hasta que el test llama a advanceUntilIdle(). Por eso el segundo test no la espera (el error «nombre vacío» no consulta la plantilla) y los otros sí.
Y una línea del último test: repositorio.observarPlantilla(rayo).first().last(). observarPlantilla devuelve un Flow, y first() espera su primera emisión y la devuelve: la lista de jugadores de ese momento. last() toma el último jugador de esa lista, que es Bea porque se añade al final.
Probarlo. Ejecuta la app. En Tablas, toca «1. Rayo FC»: sale «Plantilla de Rayo FC» con Ana #9, Luis - y Marta #1, y abajo Añadir jugador. Púlsalo.
- Título «Añadir jugador», «Nuevo jugador de Rayo FC», Nombre con «0/25», Dorsal (opcional) y Guardar gris. Ningún error a la vista: nada está tocado.
- Escribe «Bea» y, como dorsal,
9: «3/25» bajo el nombre, «Ese dorsal ya lo lleva otro jugador» en rojo bajo el dorsal y Guardar sigue gris. Gira el móvil: se conservan «Bea», el9, el error y el botón gris; el cursor vuelve a Nombre. - Cambia el dorsal a
0: «Del 1 al 99». Escribe07: ya no hay error y Guardar se activa. Si tu teclado de números te deja escribir letras, prueba7a: «Escribe solo números». - Vuelve a
7y pulsa la tecla Hecho del teclado: guarda y vuelve a la plantilla, que ahora termina en «Bea #7». - Pulsa Añadir jugador otra vez y escribe «bea»: «Ya hay un jugador con ese nombre», aunque cambien las mayúsculas.
Y una comprobación de que el dato es de verdad: cierra la app del todo y ábrela otra vez; en la plantilla del Rayo FC, «Bea #7» sigue ahí.
Punto de control
- En Tablas → «1. Rayo FC» → Añadir jugador se abre el formulario, con Guardar gris y sin errores.
- «Bea» con dorsal 9 muestra «Ese dorsal ya lo lleva otro jugador»; con 7, Guardar se activa y, al pulsarlo (o la tecla Hecho), vuelves a la plantilla, que termina en «Bea #7».
- Al girar el móvil se conservan lo escrito, el error y el botón; el cursor vuelve a Nombre.
- Tras cerrar la app del todo y abrirla, «Bea #7» sigue en la plantilla.
./gradlew :app:testGratisDebugUnitTestcuenta 27 tests (23 de antes y los 4 deAnadirJugadorViewModelTest), o 30 si hiciste el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine. El ejercicio de esta lección no añade tests. 0 fallos.
Marcador ya guarda jugadores de verdad. Queda conocer la otra forma de escribir un campo de texto, la que Google recomienda hoy: La forma más nueva: TextFieldState y SecureTextField.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Las reglas del formulario devuelven un
enum(ErrorDorsal.REPETIDO) y no una frase. ¿Quién convierte elenumen el texto «Ese dorsal ya lo lleva otro jugador»?2.El campo del dorsal usa
ImeAction.DoneconKeyboardActions(onDone = { onGuardar() }). Con un dorsal repetido, el usuario pulsa «Hecho». ¿Qué ocurre?3.Tras guardar, la pantalla vuelve a la plantilla. ¿Por qué lo hace un
LaunchedEffect(vm.guardado)en la pantalla y no el propioViewModel?4.Con el formulario a medias, giras el móvil. ¿Qué pasa con lo escrito, el error y el estado del botón?
5.En
AnadirJugadorViewModelTestse leeviewModel.errorNombredirectamente, sin Turbine. ¿Por qué?