Saltar al contenido
K
Jetpack y arquitecturaPaso a paso35 min Intermedio

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.

  1. 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 ViewModel los ignora, pero se ven como botones vivos.
  2. 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.empezar ha respondido false, 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:

ejemplo mínimo: tres flujos y un «hay otra en uso» (con comentarios)
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 otroEnJuego no es null, 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». PanelDelCronometro gana un parámetro sePuedeEmpezar para 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. El ViewModel sigue 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 hasta AppMarcador, 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 antesirAlPartido(pila, 1)Pila después
Lista, Partido 1, Goleadoresel 1 ya está: se quita lo de encimaLista, Partido 1
Lista, Partido 2el 1 no está: se abreLista, 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):

punto de partida: tres flujos y una 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
solución: combine de tres flujos y takeIf de dos condiciones (con comentarios)
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.

app → kotlin+java
com.sebastiannarvaez.marcador
feature
marcador
ui
MarcadorUiState.ktse modifica
PartidoViewModel.ktse modifica
Efectos.ktse modifica
PantallaMarcador.ktse modifica
ui
AppMarcador.ktse modifica
com.sebastiannarvaez.marcador (test)
feature
marcador
ui
PartidoViewModelTest.ktse modifica
Seis ficheros se modifican, en la vista Android.

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:

  • combine con 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 un FilterChip: el chip sale gris y no responde si vale false.
  • 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:

  1. MarcadorUiState.Exito: un campo nuevo, otroEnJuego, el id de otro partido en juego o null.
  2. PartidoViewModel: el uiState combina tres flujos (marcador, reloj y enJuego.actual) y rellena otroEnJuego con el partido en juego solo si no es este y no ha terminado. Dos comentarios de alEvento se ajustan.
  3. Efectos.kt: PanelDelCronometro recibe sePuedeEmpezar y, si vale false, no dibuja el botón.
  4. PantallaMarcador.kt: la tarjeta AvisoDePartidoEnJuego; la pantalla recibe onIrAlPartido y lo pasa hacia abajo; el aviso sale si hay otro partido; los chips con enabled; y una vista previa nueva con el aviso.
  5. AppMarcador.kt: irAlPartido(pila, id) y pasárselo a la pantalla.
  6. 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):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/MarcadorUiState.kt
    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,
    ) : MarcadorUiState

En PartidoViewModel.kt, el uiState pasa a combinar tres flujos (sustituye al que tenías, desde @OptIn hasta el .stateIn(…)):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
    // 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í:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
        // 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).
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
        // 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):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/Efectos.kt
@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):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
@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):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
                        // 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:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
// 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:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
@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»:

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
                    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:

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
// 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:

app/src/test/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModelTest.kt
import com.sebastiannarvaez.marcador.torneo.Partido

Y 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).»:

app/src/test/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModelTest.kt
    // 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.

PasoQuiénQué hace
1Pantalla del partido 1«Empezar partido» → enJuego.empezar(directo) arranca el reloj
2PartidoEnJuegoactual pasa de null al partido 1
3PartidoViewModel del partido 2Su combine recibe el cambio y pone otroEnJuego = 1
4Pantalla del partido 2Dibuja el aviso en vez del botón
5Pantalla 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:testGratisDebugUnitTest termina en BUILD SUCCESSFUL y cuenta 36 tests (39 si hiciste el ejercicio del temporizador), 0 fallos; PartidoViewModelTest tiene 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. 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. 2.El uiState pasa de combinar dos flujos a combinar tres, con enJuego.actual. ¿Por qué hace falta ese tercero?

  3. 3.El partido 1 acaba de terminar, pero sigue como actual hasta que alguien lo cierre. En la pantalla del partido 2, ¿cuánto vale otroEnJuego?

  4. 4.La pila es [Lista, Partido 1, Goleadores] y se llama a irAlPartido(pila, 1). ¿Cómo queda?

  5. 5.Los chips de duración llevan enabled = !estado.corriendo, aunque el ViewModel ya ignora el cambio mientras se juega. ¿Para qué sirve entonces?

Faltan 5 de 5