Solo un partido a la vez
Al terminar: Impedir que haya dos partidos en juego: el aviso en el otro partido, los chips de duración y el botón para ir al que se juega.
Al terminar esta lección, Marcador solo deja jugar un partido a la vez, y lo dice con claridad. Con el partido 1 en juego abres el partido 2 y, en vez del botón «Empezar partido» que no hace nada, ves una tarjeta «Ya hay un partido en juego» con un botón «Ir al partido». Los chips de 90 y 60 minutos salen grises mientras el partido de esa pantalla se juega. Y el botón «Ir al partido» te lleva al partido que corre, sin dejar pantallas repetidas en el «atrás». Un test más, hasta 36.
Importa porque una pantalla que ofrece algo que no funciona es una pantalla que miente: la regla «uno a la vez» ya existe en PartidoEnJuego, pero el usuario solo la nota cuando toca un botón y no pasa nada. Temas: cómo una pantalla se entera de lo que pasa en toda la app (combine con tres flujos), el aviso que sustituye a un botón, desactivar chips y volver en la pila de pantallas en vez de apilar otra.
Compruébalo: lo que falla hoy
Ejecuta la app (variante gratisDebug) y fíjate en dos cosas, una detrás de otra.
- Los chips del partido que se juega. Abre el partido 1 y pulsa «Empezar partido». Mientras corre, prueba los chips «90 min» y «60 min» de esa misma pantalla: se pueden pulsar y no cambian nada. El
ViewModellos ignora, pero se ven como botones vivos. - El botón del otro partido. Vuelve atrás con el botón del móvil (el partido 1 sigue jugándose) y abre el partido 2. El botón «Empezar partido» está ahí, parece normal, y al pulsarlo no pasa nada. Por dentro,
PartidoEnJuego.empezarha respondidofalse, y la pantalla no se ha enterado.
La regla ya está bien puesta (en el dominio). Lo que falta es que la pantalla la refleje: que no ofrezca lo que no se puede hacer, y que diga por qué.
La pantalla se entera del partido en juego
Para decidir si enseña el botón o el aviso, la pantalla del partido 2 necesita saber una cosa que no es suya: si hay otro partido en juego. Es un dato de toda la app, así que viene de PartidoEnJuego.actual. El PartidoViewModel ya combina dos flujos de su directo (el marcador y el reloj); ahora combina un tercero, enJuego.actual, y con los tres rellena un campo nuevo del estado: otroEnJuego, el id del otro partido o null si no hay ninguno.
Un ejemplo con datos ajenos a Marcador, para ver combine con tres flujos y el takeIf de dos condiciones: un gimnasio donde solo se puede usar una máquina a la vez, y la pantalla de la máquina 2. En el Kotlin Playground:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// Lo que se sabe de la máquina ocupada en el gimnasio: cuál es y si su turno ya terminó
data class Uso(val maquina: Int, val terminado: Boolean)
// Lo que pinta la pantalla de UNA máquina
data class EstadoDeLaPantalla(val series: Int, val kilos: Int, val otraEnUso: Int?)
fun main() = runBlocking {
val miMaquina = 2 // la máquina que mira ESTA pantalla
val series = MutableStateFlow(0) // series de ESTA máquina
val kilos = MutableStateFlow(20) // peso de ESTA máquina
val enUso = MutableStateFlow<Uso?>(null) // la máquina ocupada en TODO el gimnasio (null = ninguna)
val pantalla = launch {
// combine espera a que los tres flujos tengan valor y recalcula cuando cambia CUALQUIERA
combine(series, kilos, enUso) { s, k, uso ->
// takeIf: «esa máquina, pero solo si no es la mía Y no ha terminado»; si no, null
EstadoDeLaPantalla(s, k, otraEnUso = uso?.takeIf { it.maquina != miMaquina && !it.terminado }?.maquina)
}.collect { println(it) }
}
delay(50); enUso.value = Uso(1, terminado = false) // alguien ocupa la máquina 1
delay(50); enUso.value = Uso(1, terminado = true) // la 1 termina su turno
delay(50); series.value = 1 // yo hago una serie
delay(50); pantalla.cancel()
}EstadoDeLaPantalla(series=0, kilos=20, otraEnUso=null)
EstadoDeLaPantalla(series=0, kilos=20, otraEnUso=1)
EstadoDeLaPantalla(series=0, kilos=20, otraEnUso=null)
EstadoDeLaPantalla(series=1, kilos=20, otraEnUso=null)Cada línea es un cambio en alguno de los tres flujos. Primero no hay nadie (null). Cuando la máquina 1 se ocupa, otraEnUso vale 1. Cuando la 1 termina su turno, vuelve a null: un turno terminado ya no estorba. La cuarta línea es un cambio de mi máquina (una serie más), que recalcula todo sin tocar lo demás.
En Marcador es lo mismo: enJuego.actual es el tercer flujo, y el takeIf dice «el partido en juego, pero solo si no es este y no ha terminado». Un partido terminado se queda en actual (la barra de la lección siguiente lo enseña como «Final») hasta que alguien lo cierra, y no debe impedir que empiece otro.
El aviso, los chips y «Ir al partido»
Con otroEnJuego en el estado, la pantalla hace tres cosas:
- El aviso. Si
otroEnJuegono esnull, el botón «Empezar partido» desaparece y en su lugar sale una tarjeta con el texto «Ya hay un partido en juego» y un botón «Ir al partido».PanelDelCronometrogana un parámetrosePuedeEmpezarpara ocultar el botón, y la tarjeta es un composable nuevo,AvisoDePartidoEnJuego. - Los chips. Reciben
enabled = !estado.corriendo: mientras este partido corre, salen grises y no responden. ElViewModelsigue ignorando el cambio por si acaso, pero ahora la pantalla ya no ofrece un botón falso. - «Ir al partido». La tarjeta no navega por sí misma: avisa con una función,
onIrAlPartido, que sube hastaAppMarcador, el que tiene la pila de pantallas.
Volver en la pila, en vez de apilar otra pantalla
La pila de pantallas es una lista (pila). La forma ingenua de «ir al partido 1» sería pila.add(PartidoKey(1)), pero si el 1 ya está en la pila (por ejemplo, entraste en sus goleadores desde él), apilar otro 1 haría que «atrás» pasara por dos copias del mismo partido. Lo que se quiere es volver hasta el 1, quitando lo que hay encima.
| Pila antes | irAlPartido(pila, 1) | Pila después |
|---|---|---|
| Lista, Partido 1, Goleadores | el 1 ya está: se quita lo de encima | Lista, Partido 1 |
| Lista, Partido 2 | el 1 no está: se abre | Lista, Partido 2, Partido 1 |
Desde el aviso del partido 2 pasarás siempre por la segunda fila; la primera la verás en acción con la barra de la lección siguiente.
La función usa pila.lastIndexOf(clave), que da la posición de la última coincidencia, o −1 si la clave no está. Compara con ==, y como PartidoKey es una data class, PartidoKey(1) == PartidoKey(1) es verdadero. Y internal delante de fun significa «visible dentro de este módulo» (Delegación y JSON): la usará también la barra de la lección siguiente.
Reto · Ejercicio: la impresora de la oficina
En el Kotlin Playground. Es la misma idea del gimnasio con otros datos: en una oficina hay una impresora, y el escritorio de Marta quiere saber si otra persona la está usando. Parte de este código, que ya está escrito (los tres flujos y la secuencia de cambios):
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// La impresión que hay en la oficina: de quién es y si ya terminó
data class Impresion(val persona: String, val terminada: Boolean)
// Lo que pinta el escritorio de UNA persona
data class Escritorio(val hojas: Int, val color: Boolean, val ocupadaPor: String?)
fun main() = runBlocking {
val yo = "Marta" // la persona que mira ESTE escritorio
val hojas = MutableStateFlow(10) // hojas en MI bandeja
val color = MutableStateFlow(false) // si MI impresión es en color
val imprimiendo = MutableStateFlow<Impresion?>(null) // la impresión en TODA la oficina (null = ninguna)
val escritorio = launch {
// TU PARTE: combina los tres flujos en un Escritorio e imprime cada uno con println.
// ocupadaPor vale el nombre de la persona de la impresión, pero solo si no soy yo
// y no ha terminado; en cualquier otro caso, null.
}
delay(50); imprimiendo.value = Impresion("Luis", terminada = false) // Luis imprime
delay(50); imprimiendo.value = Impresion("Luis", terminada = true) // Luis termina
delay(50); hojas.value = 9 // yo gasto una hoja
delay(50); color.value = true // yo elijo color
delay(50); imprimiendo.value = Impresion("Luis", terminada = false) // Luis imprime otra vez
delay(50); imprimiendo.value = Impresion("Marta", terminada = false) // ahora imprimo yo
delay(50); escritorio.cancel()
}Rellena el launch con un combine de los tres flujos, y un takeIf con las dos condiciones, para sacar ocupadaPor. Debes ver siete líneas: una por el valor inicial y una por cada cambio.
Escritorio(hojas=10, color=false, ocupadaPor=null)
Escritorio(hojas=10, color=false, ocupadaPor=Luis)
Escritorio(hojas=10, color=false, ocupadaPor=null)
Escritorio(hojas=9, color=false, ocupadaPor=null)
Escritorio(hojas=9, color=true, ocupadaPor=null)
Escritorio(hojas=9, color=true, ocupadaPor=Luis)
Escritorio(hojas=9, color=true, ocupadaPor=null)Mira la última línea: cuando la impresión es tuya, no cuenta como «otra persona», aunque no haya terminado.
Ver solución
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
data class Impresion(val persona: String, val terminada: Boolean)
data class Escritorio(val hojas: Int, val color: Boolean, val ocupadaPor: String?)
fun main() = runBlocking {
val yo = "Marta"
val hojas = MutableStateFlow(10)
val color = MutableStateFlow(false)
val imprimiendo = MutableStateFlow<Impresion?>(null)
val escritorio = launch {
// combine: espera a que los tres tengan valor y recalcula cuando cambia CUALQUIERA
combine(hojas, color, imprimiendo) { h, c, imp ->
// takeIf: la impresión, pero solo si no es mía Y no ha terminado; si no, null
Escritorio(h, c, ocupadaPor = imp?.takeIf { it.persona != yo && !it.terminada }?.persona)
}.collect { println(it) }
}
delay(50); imprimiendo.value = Impresion("Luis", terminada = false)
delay(50); imprimiendo.value = Impresion("Luis", terminada = true)
delay(50); hojas.value = 9
delay(50); color.value = true
delay(50); imprimiendo.value = Impresion("Luis", terminada = false)
delay(50); imprimiendo.value = Impresion("Marta", terminada = false)
delay(50); escritorio.cancel()
}Es la misma forma que el uiState de PartidoViewModel: los dos primeros flujos son «lo mío» (aquí hojas y color; allí el marcador y el reloj) y el tercero es el dato de toda la app (aquí la impresión; allí enJuego.actual). El ?.takeIf { … }?.persona se lee de dentro afuera: si no hay impresión, el resultado es null; si la hay pero es mía o ya terminó, takeIf da null y el ?. corta ahí; si no, llega al nombre.
Paso · Solo un partido a la vez
Objetivo. Que el estado de la pantalla sepa si hay otro partido en juego, que la pantalla enseñe el aviso en vez del botón, que los chips se desactiven y que «Ir al partido» vuelva en la pila. Al terminar, ./gradlew :app:testGratisDebugUnitTest cuenta 36 tests (39 si hiciste el ejercicio del temporizador) y 0 fallos.
Haz los cambios en este orden y ejecuta al final: hasta que estén todos, el proyecto no compila (la pantalla pide una función nueva que AppMarcador aún no le da).
Piezas nuevas de este paso:
combinecon tres flujos: igual que con dos, y la lambda recibe tres valores.pila.lastIndexOf(clave): la posición de la última coincidencia en la lista, o −1.internal fun: visible solo dentro del módulo:app.enabled = …en unFilterChip: el chip sale gris y no responde si valefalse.- Una función como parámetro de una pantalla (
onIrAlPartido: (Int) -> Unit): la pantalla avisa «quiero ir al partido tal» y no sabe cómo se hace.
Ya vistas: takeIf y ?.let (Nulabilidad); skipItems y expectMostRecentItem() de Turbine (Tests de un ViewModel: fakes, setMain y Turbine); backgroundScope y runCurrent() (Tests con backgroundScope y Turbine); Card y Row, con Arrangement.SpaceBetween (los hijos pegados a los extremos, con el espacio sobrante en medio) y verticalAlignment = Alignment.CenterVertically (centrados en vertical), de Compose: composables y modifiers; y los import de la pantalla, que ya están en PantallaMarcador.kt.
Intenta escribirlo tú, en este orden:
MarcadorUiState.Exito: un campo nuevo,otroEnJuego, el id de otro partido en juego onull.PartidoViewModel: eluiStatecombina tres flujos (marcador, reloj yenJuego.actual) y rellenaotroEnJuegocon el partido en juego solo si no es este y no ha terminado. Dos comentarios dealEventose ajustan.Efectos.kt:PanelDelCronometrorecibesePuedeEmpezary, si valefalse, no dibuja el botón.PantallaMarcador.kt: la tarjetaAvisoDePartidoEnJuego; la pantalla recibeonIrAlPartidoy lo pasa hacia abajo; el aviso sale si hay otro partido; los chips conenabled; y una vista previa nueva con el aviso.AppMarcador.kt:irAlPartido(pila, id)y pasárselo a la pantalla.- Una prueba nueva: la pantalla de otro partido ve el aviso y su «Empezar» no hace nada.
Lo que debes ver: en el emulador, partido 1 en juego, abres el 2 y sale el aviso en vez del botón; los chips del 1 están grises mientras corre; «Ir al partido» te devuelve al 1.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
En MarcadorUiState.kt, Exito gana el último campo (el resto del fichero no cambia):
data class Exito(
val partidoId: Int,
val partido: Partido,
val marcador: Marcador,
val minuto: Int,
val corriendo: Boolean,
val ultimoAviso: String,
val duracion: Int = 90,
// El id de OTRO partido que se esta jugando (null si no hay): entonces no se puede empezar este.
val otroEnJuego: Int? = null,
) : MarcadorUiStateEn PartidoViewModel.kt, el uiState pasa a combinar tres flujos (sustituye al que tenías, desde @OptIn hasta el .stateIn(…)):
// EL ESTADO QUE VE LA PANTALLA, en un solo StateFlow.
// Ya no calcula nada: traduce el `Directo` (dominio) a un MarcadorUiState.
@OptIn(ExperimentalCoroutinesApi::class)
val uiState: StateFlow<MarcadorUiState> = carga.flatMapLatest { paso ->
when (paso) {
Carga.EnCurso -> flowOf(MarcadorUiState.Cargando)
is Carga.Fallo -> flowOf(MarcadorUiState.Error(paso.motivo))
// Tres flujos: el marcador y el reloj de ESTE directo, y el partido en juego de la app
// (para saber si hay OTRO sin terminar).
is Carga.Lista -> combine(paso.directo.marcador, paso.directo.instante, enJuego.actual) { marcador, instante, actual ->
MarcadorUiState.Exito(
partidoId = paso.directo.partidoId,
partido = paso.directo.partido,
marcador = marcador,
minuto = instante.minuto,
corriendo = instante.corriendo,
ultimoAviso = instante.ultimoAviso,
duracion = paso.directo.minutosDelPartido,
otroEnJuego = actual?.takeIf { it.partidoId != paso.directo.partidoId && !it.terminado }?.partidoId,
)
}
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MarcadorUiState.Cargando)El takeIf se lee de dentro afuera: de actual (el partido en juego, o null) se queda con él solo si su id no es el de esta pantalla y no ha terminado; de ese, coge su partidoId. Si algo falla en el camino, el resultado es null: no hay «otro».
En alEvento solo cambian dos comentarios (el código no cambia, así que no pasa nada si te saltas esto). El de Empezar y el de CambiarDuracion quedan así:
// Lo empieza el partido en juego, no este ViewModel. Si hay otro sin terminar, no hace
// nada (la pantalla ni siquiera ensena el boton: ensena el aviso). // Se guarda en DataStore y se prepara un directo NUEVO con esa duracion. Mientras este
// partido se juega, no: la pantalla desactiva los chips y aqui se ignora.En Efectos.kt, PanelDelCronometro completo (con su @Composable):
@Composable
fun PanelDelCronometro(
minuto: Int,
corriendo: Boolean,
ultimoAviso: String,
pausas: Int,
onEmpezar: () -> Unit,
duracion: Int = MINUTOS_DEL_PARTIDO,
sePuedeEmpezar: Boolean = true,
modifier: Modifier = Modifier,
) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Minuto $minuto'", style = MaterialTheme.typography.headlineMedium)
// Sin el boton si hay otro partido en juego: ahi va el aviso «Ya hay un partido en juego».
if (sePuedeEmpezar) {
Button(onClick = onEmpezar, enabled = !corriendo && minuto == 0) {
Text(if (minuto >= duracion) "Partido terminado" else if (corriendo) "En juego" else "Empezar partido")
}
}
Text("Último aviso (cada 15'): $ultimoAviso")
Text("Veces que la pantalla pasó a segundo plano: $pausas")
}
}En PantallaMarcador.kt, la pantalla con estado y la firma de la sin estado (su primera línea, con los parámetros) reciben onIrAlPartido (el resto de cada función no cambia):
@Composable
fun PantallaMarcador(
onVerGoleadores: () -> Unit,
onVerCronica: () -> Unit,
onVolver: () -> Unit,
onIrAlPartido: (Int) -> Unit,
modifier: Modifier = Modifier,
viewModel: PartidoViewModel,
) {
val estado by viewModel.uiState.collectAsStateWithLifecycle()
// Esto SI es estado de la pantalla (cuantas veces paso a segundo plano), y
// rememberSaveable basta: es un Int.
var pausas by rememberSaveable { mutableIntStateOf(0) }
ObservadorDelCiclo(alPararse = { pausas++ })
PantallaMarcadorContenido(estado, pausas, viewModel::alEvento, onVerGoleadores, onVerCronica, onVolver, onIrAlPartido, modifier)
}
// SIN ESTADO: recibe el UiState y una lambda de eventos. Se puede previsualizar con
// cualquiera de las tres variantes sin ViewModel (ver las @Preview de abajo).
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PantallaMarcadorContenido(
estado: MarcadorUiState,
pausas: Int,
onEvento: (MarcadorEvento) -> Unit,
onVerGoleadores: () -> Unit,
onVerCronica: () -> Unit,
onVolver: () -> Unit,
onIrAlPartido: (Int) -> Unit,
modifier: Modifier = Modifier,
) {Dentro de extras, sustituye desde el PanelDelCronometro( hasta el final de la Row de los chips, y añade la línea del aviso justo encima del PanelDelCronometro( (lo de antes y lo de después no cambia):
// UNO A LA VEZ: si hay otro partido en juego, en vez de «Empezar» va el aviso.
estado.otroEnJuego?.let { otro -> AvisoDePartidoEnJuego(onIrAlPartido = { onIrAlPartido(otro) }) }
PanelDelCronometro(
estado.minuto, estado.corriendo, estado.ultimoAviso, pausas,
onEmpezar = { onEvento(MarcadorEvento.Empezar) },
duracion = estado.duracion,
sePuedeEmpezar = estado.otroEnJuego == null,
)
// La preferencia, guardada en DataStore: sobrevive a cerrar la app.
Text("Duración del partido (preferencia guardada)")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PreferenciasRepository.DURACIONES.forEach { minutos ->
FilterChip(
selected = estado.duracion == minutos,
onClick = { onEvento(MarcadorEvento.CambiarDuracion(minutos)) },
label = { Text("$minutos min") },
// Bloqueada solo mientras ESTE partido se juega.
enabled = !estado.corriendo,
)
}
}Ese ?.let { otro -> … } se lee: «si otroEnJuego no es null, llámalo otro y dibuja el aviso». La tarjeta nueva va justo debajo de PantallaMarcadorContenido:
// El aviso de «uno a la vez»: una tarjeta con el boton que lleva al partido que se esta jugando.
@Composable
fun AvisoDePartidoEnJuego(onIrAlPartido: () -> Unit, modifier: Modifier = Modifier) {
Card(modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Ya hay un partido en juego", style = MaterialTheme.typography.titleSmall)
Button(onClick = onIrAlPartido) { Text("Ir al partido") }
}
}
}Y las vistas previas: la que ya tenías recibe onIrAlPartido = {}, y hay una nueva para el aviso:
@Preview(showBackground = true)
@Composable
private fun PantallaMarcadorPreview() {
MaterialTheme {
PantallaMarcadorContenido(
MarcadorUiState.Exito(1, Ejemplo.rayoContraToros, Marcador(2, 1), 45, true, "minuto 30 con 1-1"),
pausas = 0, onEvento = {}, onVerGoleadores = {}, onVerCronica = {}, onVolver = {}, onIrAlPartido = {},
)
}
}
// Otro partido en juego: el aviso sustituye al boton «Empezar».
@Preview(showBackground = true)
@Composable
private fun PantallaMarcadorConOtroEnJuegoPreview() {
MaterialTheme {
PantallaMarcadorContenido(
MarcadorUiState.Exito(2, Ejemplo.lobosContraAguilas, Marcador(0, 0), 0, false, "ninguno", otroEnJuego = 1),
pausas = 0, onEvento = {}, onVerGoleadores = {}, onVerCronica = {}, onVolver = {}, onIrAlPartido = {},
)
}
}En AppMarcador.kt, la entrada del partido le pasa a la pantalla qué hacer con «Ir al partido»:
PantallaMarcador(
onVerGoleadores = { pila.add(GoleadoresKey) },
onVerCronica = { pila.add(CronicaKey(clave.partidoId)) },
onVolver = { pila.removeAt(pila.lastIndex) },
onIrAlPartido = { id -> irAlPartido(pila, id) },
viewModel = vm,
)Y irAlPartido va al final del fichero, debajo de irAPestana:
// Ir a un partido: si ya esta en la pila, se vuelve hasta el (quitando lo de encima); si no, se abre.
internal fun irAlPartido(pila: MutableList<NavKey>, partidoId: Int) {
val clave = PartidoKey(partidoId)
val donde = pila.lastIndexOf(clave)
if (donde == -1) {
pila.add(clave)
} else {
while (pila.lastIndex > donde) pila.removeAt(pila.lastIndex)
}
}En PartidoViewModelTest.kt, un import nuevo junto a los demás de torneo:
import com.sebastiannarvaez.marcador.torneo.PartidoY la prueba nueva, justo encima de laDuracionNoCambiaMientrasSeJuega. Esa otra prueba solo cambia su comentario, que pasa a decir «Mientras se juega, la duración no cambia (la pantalla desactiva los chips; aquí se ignora).»:
// UNO A LA VEZ: la pantalla de otro partido ve el aviso y su «Empezar» no hace nada.
@Test
fun otroPartidoVeElAvisoYNoEmpieza() = runTest {
val repo = FakePartidosRepository(
listOf(PartidoDeLista(1, Partido(Ejemplo.rayo, Ejemplo.toros)), PartidoDeLista(2, Partido(Ejemplo.toros, Ejemplo.rayo))),
)
val enJuego = PartidoEnJuego(backgroundScope)
val primero = crear(repo, enJuego = enJuego)
runCurrent()
primero.alEvento(MarcadorEvento.Empezar)
val segundo = crear(repo, partidoInicial = 2, enJuego = enJuego)
segundo.uiState.test {
skipItems(1)
runCurrent()
assertEquals(1, (expectMostRecentItem() as MarcadorUiState.Exito).otroEnJuego)
segundo.alEvento(MarcadorEvento.Empezar)
runCurrent()
assertEquals(1, enJuego.actual.value?.partidoId)
cancelAndIgnoreRemainingEvents()
}
}Qué comprueba la prueba nueva. Crea dos partidos, empieza el 1 y abre una segunda pantalla para el 2, las dos con el mismo PartidoEnJuego: como en la app, donde hay uno solo. Comprueba que el estado del 2 trae otroEnJuego == 1 y que, aunque insista con «Empezar», el partido en juego sigue siendo el 1. cancelAndIgnoreRemainingEvents() cierra el test { } de Turbine sin quejarse de los valores que quedaron sin leer.
| Paso | Quién | Qué hace |
|---|---|---|
| 1 | Pantalla del partido 1 | «Empezar partido» → enJuego.empezar(directo) arranca el reloj |
| 2 | PartidoEnJuego | actual pasa de null al partido 1 |
| 3 | PartidoViewModel del partido 2 | Su combine recibe el cambio y pone otroEnJuego = 1 |
| 4 | Pantalla del partido 2 | Dibuja el aviso en vez del botón |
| 5 | Pantalla del partido 2 | «Ir al partido» → irAlPartido(pila, 1) vuelve o abre el 1 |
Probarlo. Ejecuta la app (variante gratisDebug). Abre el partido 1 y pulsa «Empezar partido»: los chips «90 min» y «60 min» se ponen grises. Vuelve atrás y abre el partido 2: en lugar del botón sale «Ya hay un partido en juego» con «Ir al partido». Los chips del 2 siguen vivos (ese partido no se juega). Toca «Ir al partido»: estás en el 1, en su minuto de ahora, y «atrás» te devuelve al 2, de donde venías.
Punto de control
./gradlew :app:testGratisDebugUnitTesttermina enBUILD SUCCESSFULy cuenta 36 tests (39 si hiciste el ejercicio del temporizador), 0 fallos;PartidoViewModelTesttiene una prueba nueva,otroPartidoVeElAvisoYNoEmpieza.- El partido 2, abierto con el 1 en juego, enseña «Ya hay un partido en juego» y «Ir al partido», sin «Empezar partido».
- «Ir al partido» abre el 1 en su minuto de ahora, y «atrás» desde el 1 vuelve al 2.
- Los chips «90 min» y «60 min» del partido que se juega están grises.
- El ejercicio de la impresora te imprime en el Playground las siete líneas de arriba.
El partido ya sigue aunque salgas y no se puede duplicar, pero solo se ve dentro de su pantalla. Falta enseñarlo en todas las demás: es la barra de abajo, en La barra del partido en juego.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.La pantalla del partido 2 enseña un botón «Empezar partido» que, con el 1 en juego, no hace nada. ¿Qué falla y dónde está bien puesta la regla?
2.El
uiStatepasa de combinar dos flujos a combinar tres, conenJuego.actual. ¿Por qué hace falta ese tercero?3.El partido 1 acaba de terminar, pero sigue como
actualhasta que alguien lo cierre. En la pantalla del partido 2, ¿cuánto valeotroEnJuego?4.La pila es
[Lista, Partido 1, Goleadores]y se llama airAlPartido(pila, 1). ¿Cómo queda?5.Los chips de duración llevan
enabled = !estado.corriendo, aunque elViewModelya ignora el cambio mientras se juega. ¿Para qué sirve entonces?