Red en Marcador: la lista de árbitros
Al terminar: Cargar los árbitros desde internet y pintarlos con los estados Cargando, Éxito y Error.
Al terminar esta lección Marcador tendrá una pantalla nueva, Árbitros, que carga de internet los diez árbitros de la liga. Mientras espera muestra una rueda de carga; si no hay red, muestra «No hay conexión» y un botón Reintentar. Es la primera vez que el estado Cargando de la app es de verdad: la red tarda, y eso se nota.
Importa porque una lista que viene de la red puede tardar, fallar o no llegar nunca, y la pantalla tiene que contar cada caso con claridad en vez de quedarse en blanco. Para eso necesitas tres piezas: un repositorio cuya fuente es la red, un ViewModel que traduce el resultado a estado, y una pantalla que pinta ese estado. Temas: repositorio de red, traducir DTO a dominio, UiState con Cargando, Exito y Error, y reintentar.
Un repositorio cuya fuente es la red
En Retrofit y la primera llamada dejaste escrita LigaApi, que devuelve ArbitroDto. La pantalla no debe verla: debe recibir Arbitro, la clase de Marcador. El que traduce es el repositorio: pide los DTO a LigaApi y los convierte en Arbitro en un solo sitio.
LigaApi (red) → ArbitrosRepositoryRed → ArbitrosViewModel → PantallaArbitros
ArbitroDto traduce a Arbitro UiState pintaLa interfaz vive en domain/ y tiene una función suspend: pedir la lista es una acción que tarda y termina. No es un Flow, porque nadie avisa cuando el servidor cambia (eso llega en la lección de las dos fuentes). El ViewModel solo conoce la interfaz, así que en los tests le darás un repositorio falso.
Quién construye OkHttp y Retrofit: un módulo de red
La cadena de piezas de esta pantalla es larga: el ViewModel necesita un ArbitrosRepository, el repositorio necesita la LigaApi, y la LigaApi necesita un OkHttpClient. Alguien tiene que construirlas en ese orden, y construirlas una sola vez: el cliente HTTP guarda las conexiones abiertas y sus hilos, y tener dos por cada pantalla las duplicaría.
Ese alguien es Hilt, pero no puede hacerlo todo solo. Un ArbitrosRepositoryRed es una clase tuya: le pones @Inject constructor y Hilt sabe construirla. OkHttpClient y LigaApi no lo son: la primera es de OkHttp y la segunda la escribe Retrofit al arrancar a partir de tu interfaz, así que no puedes ponerles @Inject. Para ellas escribes una receta con @Provides, y la agrupas con las demás de la red en un módulo, RedModule. En Retrofit y la primera llamada dejaste hechas crearOkHttp() y crearLigaApi(): las recetas solo las llaman.
Un ejemplo con datos ajenos (una cartelera de cine). Un módulo de red con dos recetas encadenadas y otro con un @Binds, anotado línea a línea:
// Dos clases que NO son tuyas (imagina que vienen de una librería): no puedes ponerles @Inject
class ClienteHttp(val timeoutSegundos: Int)
class CarteleraApi(val cliente: ClienteHttp)
interface CarteleraRepository
// Una clase TUYA: con @Inject constructor, Hilt sabe construirla y le pasa la CarteleraApi
class CarteleraRepositoryRed @Inject constructor(private val api: CarteleraApi) : CarteleraRepository
@Module // «aquí hay recetas»
@InstallIn(SingletonComponent::class) // vivirán mientras viva la app
object RedCineModule {
@Provides @Singleton // receta 1: un solo cliente para toda la app
fun cliente(): ClienteHttp = ClienteHttp(timeoutSegundos = 10)
@Provides @Singleton // receta 2: pide un cliente; Hilt le pasa el de la receta 1
fun api(cliente: ClienteHttp): CarteleraApi = CarteleraApi(cliente)
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoriosCineModule { // con @Binds, el módulo es abstract class (sin cuerpo en la función)
@Binds @Singleton // «quien pida un CarteleraRepository, recibe un CarteleraRepositoryRed»
abstract fun cartelera(impl: CarteleraRepositoryRed): CarteleraRepository
}Cuando alguien pide un CarteleraRepository, Hilt recorre la cadena de atrás hacia delante, sin que tú escribas el orden:
pide CarteleraRepository
→ @Binds: es un CarteleraRepositoryRed
→ su constructor pide una CarteleraApi
→ receta api(): pide un ClienteHttp
→ receta cliente(): lo construye (y lo guarda: es @Singleton)Y lo comprueba al compilar: si en la cadena faltara una receta, la compilación falla en vez de romper la app al abrir la pantalla.
El estado de una pantalla que espera a la red
Hay tres pantallas posibles y se excluyen entre sí: esperando, con la lista o con un error. Ese es el caso del sealed interface. El ViewModel pide los datos en cuanto se crea (en su bloque init, el código que se ejecuta al crearse el objeto) y deja el resultado en el estado. Si falla, ofrece una función para volver a intentarlo.
Un ejemplo con datos ajenos (una cartelera de cine) y su salida. Usa un modelo sencillo en vez de un ViewModel para poder ejecutarlo en el Kotlin Playground. En vez de recoger el flujo de estados (eso lo hace la pantalla), imprime el valor actual (.value) antes y después de cargar:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.io.IOException
data class Pelicula(val titulo: String, val minutos: Int)
// Tres estados que se excluyen: la pantalla es una u otra
sealed interface CarteleraUiState {
data object Cargando : CarteleraUiState
data class Exito(val peliculas: List<Pelicula>) : CarteleraUiState
data class Error(val mensaje: String) : CarteleraUiState
}
interface CarteleraRepository { suspend fun peliculas(): List<Pelicula> }
class CarteleraModelo(private val repositorio: CarteleraRepository) {
// Empieza en Cargando: lo primero que ve la pantalla
private val _uiState = MutableStateFlow<CarteleraUiState>(CarteleraUiState.Cargando)
val uiState: StateFlow<CarteleraUiState> = _uiState
suspend fun cargar() {
_uiState.value = CarteleraUiState.Cargando // vuelve a esperar (al reintentar)
_uiState.value = try {
CarteleraUiState.Exito(repositorio.peliculas()) // la petición: tarda
} catch (e: IOException) { // IOException = no hubo respuesta (sin red)
CarteleraUiState.Error("No hay conexión")
}
}
}
// Dos repositorios de mentira: uno responde, otro simula no tener red
class CarteleraConRed : CarteleraRepository {
override suspend fun peliculas(): List<Pelicula> { delay(100); return listOf(Pelicula("Alien", 117)) }
}
class CarteleraSinRed : CarteleraRepository {
override suspend fun peliculas(): List<Pelicula> { delay(100); throw IOException("sin red") }
}
fun main() = runBlocking { // runBlocking: un sitio desde donde llamar a funciones suspend
val buena = CarteleraModelo(CarteleraConRed())
println(buena.uiState.value) // antes de cargar: Cargando
buena.cargar()
println(buena.uiState.value) // después: Exito
val mala = CarteleraModelo(CarteleraSinRed())
println(mala.uiState.value) // Cargando
mala.cargar()
println(mala.uiState.value) // Error
}Cargando
Exito(peliculas=[Pelicula(titulo=Alien, minutos=117)])
Cargando
Error(mensaje=No hay conexión)Una nota sobre StateFlow: si le pones dos veces seguidas el mismo valor, no avisa dos veces. Por eso, en la pantalla, un Cargando que sustituye a otro Cargando no se nota.
Y la pieza que usa el ViewModel para pasar de un Result a un estado: Result.fold. Recibe dos funciones y ejecuta una, según el Result haya salido bien o mal. Un ejemplo de dos líneas:
import java.io.IOException
fun main() {
val bien: Result<Int> = Result.success(42) // un resultado que salió bien
val mal: Result<Int> = Result.failure(IOException("sin red")) // otro que falló
// fold: si salió bien ejecuta onSuccess con el valor; si falló, onFailure con el error
println(bien.fold(onSuccess = { "salió bien: $it" }, onFailure = { "falló: ${it.message}" }))
println(mal.fold(onSuccess = { "salió bien: $it" }, onFailure = { "falló: ${it.message}" }))
}salió bien: 42
falló: sin redEl ejemplo atrapa IOException, la excepción de «no hubo respuesta». Hay otro tipo de fallo, el de una respuesta con código de error, que verás en POST y errores de red. La pantalla, que muestra ese estado, la escribirás en el paso.
Reto · Ejercicio: el estado de una pantalla de recetas
Fuera de Marcador, en el Kotlin Playground.
Modela una pantalla de recetas con los mismos tres estados: Cargando, Exito (con la lista de recetas; una receta tiene un nombre y unos minutos) y Error (con un mensaje). Escribe una interfaz de repositorio con una función suspend que devuelve las recetas, y dos repositorios de mentira: uno devuelve «Tortilla» (20 minutos) y «Gazpacho» (15), y el otro lanza una IOException. Escribe una función suspend que reciba un repositorio y devuelva el estado final: Exito si todo va bien, y Error con el mensaje «No hay conexión» si se lanza la IOException.
En main (con runBlocking, como en el ejemplo) no necesitas StateFlow: imprime con println el estado Cargando, el estado final con el repositorio bueno, Cargando otra vez y el estado final con el repositorio sin red. La consola debe mostrar:
Cargando
Exito(recetas=[Receta(nombre=Tortilla, minutos=20), Receta(nombre=Gazpacho, minutos=15)])
Cargando
Error(mensaje=No hay conexión)Ver solución
import kotlinx.coroutines.*
import java.io.IOException
data class Receta(val nombre: String, val minutos: Int)
sealed interface RecetasUiState {
data object Cargando : RecetasUiState
data class Exito(val recetas: List<Receta>) : RecetasUiState
data class Error(val mensaje: String) : RecetasUiState
}
interface RecetasRepository { suspend fun recetas(): List<Receta> }
class RecetasConRed : RecetasRepository {
override suspend fun recetas() = listOf(Receta("Tortilla", 20), Receta("Gazpacho", 15))
}
class RecetasSinRed : RecetasRepository {
override suspend fun recetas(): List<Receta> = throw IOException("sin red")
}
// El estado final: Exito si la petición va bien, Error si no hay respuesta
suspend fun cargar(repositorio: RecetasRepository): RecetasUiState =
try {
RecetasUiState.Exito(repositorio.recetas())
} catch (e: IOException) {
RecetasUiState.Error("No hay conexión")
}
fun main() = runBlocking {
println(RecetasUiState.Cargando)
println(cargar(RecetasConRed()))
println(RecetasUiState.Cargando)
println(cargar(RecetasSinRed()))
}El try solo atrapa la IOException: es el fallo que significa «sin red». Cualquier otro error (un fallo de programación) no se disfraza de falta de conexión.
Paso · La pantalla de árbitros
Objetivo. Crear el repositorio de red, el estado, el ViewModel y la pantalla de árbitros; darle a Hilt los dos módulos que necesita (el de la red y el de los repositorios), enchufar la pantalla a la navegación y añadir un botón Árbitros en la barra de Partidos. Al terminar verás los diez árbitros, y con el modo avión, «No hay conexión» y un botón Reintentar. Escribirás además dos tests.
Crea los ficheros. Con el selector en Android, despliega app → kotlin+java. Aquí nace la feature arbitros: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .feature.arbitros.domain al final del nombre que aparece ya escrito. Repite con .feature.arbitros.data y con .feature.arbitros.ui. Una feature es una parte de la app con nombre propio y sus capas dentro: domain dice qué necesita la parte, data de dónde lo saca y ui cómo se ve. Después, clic derecho sobre domain, dentro de arbitros → New → Kotlin Class/File → File → Arbitros; lo mismo sobre el data de arbitros (ArbitrosRepositoryRed) y sobre su ui (ArbitrosUiState, ArbitrosViewModel y PantallaArbitros). Si ves feature.arbitros en una sola línea, es la misma carpeta: Android Studio junta en una línea las que solo tienen una hija. Los dos módulos de Hilt van en di, una sola carpeta para toda la app que ya creaste en Hilt: instalarlo y el primer módulo.
Los tests copian las carpetas de main: en el paquete com.sebastiannarvaez.marcador (test) (el que lleva (test) detrás, la carpeta app/src/test), clic derecho → New → Package → añade .feature.arbitros.ui y crea ahí ArbitrosViewModelTest. Falsos.kt ya está en su carpeta core. Cuando Android Studio marque una referencia en rojo, pulsa Option+Enter y elige el import.
Piezas nuevas de este paso:
@Inject constructoren un repositorio y@HiltViewModelen unViewModel: las conoces; hoy se las pones aArbitrosRepositoryRedy aArbitrosViewModel.ListItem(headlineContent = …, supportingContent = …): una fila de lista de Material con un texto principal y otro más pequeño debajo. Aquí, el nombre del árbitro y su ciudad.Result.fold(onSuccess, onFailure): lo acabas de ver arriba.intentar { … }(Casos de uso: RegistrarGol) devuelve unResult, yfoldlo convierte en un estado de la pantalla.init { … }en elViewModel: se ejecuta al crearse. Es donde se pide la lista la primera vez.vm::reintentares una referencia a la funciónreintentardelViewModel: hace lo mismo que{ vm.reintentar() }.- En la pantalla,
CircularProgressIndicator()es la rueda de carga,items(lista, key = { it.id })pinta una fila por elemento y las reconoce por suid, yArrangement.spacedBy(16.dp, Alignment.CenterVertically)separa y centra los hijos de una columna.WindowInsets(0)y@OptIn(ExperimentalMaterial3Api::class)van tal cual: el primero evita márgenes dobles y el segundo confirma una pieza de Material aún cambiante.
Intenta escribirlo tú, en este orden:
- En
feature/arbitros/domain/Arbitros.kt: la claseArbitro(id, nombre y ciudad) y la interfazArbitrosRepositorycon una funciónsuspendque devuelve la lista. - En
feature/arbitros/data/ArbitrosRepositoryRed.kt: la clase que recibeLigaApi(con@Inject constructor, para que Hilt sepa construirla), pide los DTO y los traduce aArbitro(el nombre sale denamey la ciudad deaddress.city). ArbitrosUiState(sellado:Cargando,Exitocon la lista yErrorcon el mensaje) yArbitrosViewModel(@HiltViewModely@Inject constructor), que pide la lista eninit, atrapa el fallo conintentar, lo convierte en estado confoldy ofrecereintentar(). Si el fallo es unaIOException, el mensaje es «No hay conexión»; si no, «No se pudieron cargar los árbitros».PantallaArbitros: una rueda centrada conCargando; conError, el mensaje y un botón «Reintentar»; conExito, una lista con una fila por árbitro.di/RedModule.kt: unobjectcon las dos recetas de la red (OkHttpClientyLigaApi, las dos@Singleton) ydi/RepositoriosModule.kt: unaabstract classcon un@Bindsque dice que quien pidaArbitrosRepositoryrecibeArbitrosRepositoryRed. Son dos módulos y no uno porque@Provides(construir una pieza a mano, comoDatosModule) va en unobject, y@Binds(decir «esta interfaz es esta clase») exige una claseabstract: no caben juntos. Y los cambios deAppMarcadorque se listan justo debajo.- En los tests,
FakeArbitrosRepositoryyArbitrosViewModelTestconpasaDeCargandoAExito(lo escribes tú) ysinRedSaleElErrorYReintentarVuelveACargar.
Debes ver la barra de Partidos con el botón Árbitros y, al pulsarlo, la lista; 9 tests que pasan (12 con el ejercicio del temporizador) y la compilación de Gradle en verde: si falta alguna pieza de la cadena, será Hilt quien lo diga al compilar.
Dónde va cada trozo (obligatorio). Los ficheros nuevos van enteros. En los que ya existen:
AppMarcador.kt: los dosimportdeArbitrosViewModelyPantallaArbitros, la claveArbitrosKeyjunto aGoleadoresKey, el botón en la barra de Partidos y el destino nuevo al final de la lista deentry.Falsos.kt: dosimportnuevos y la claseFakeArbitrosRepositoryal final.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.feature.arbitros.domain
// Un arbitro de la liga, como lo usa la app: en espanol y solo con lo que se pinta.
// No es el JSON (eso es `ArbitroDto`, en core/network): si manana el servidor cambia un
// nombre de campo, cambia el DTO y su traduccion, y esta clase ni se entera.
data class Arbitro(val id: Int, val nombre: String, val ciudad: String)
// El repositorio de arbitros: la pantalla pide la lista y no sabe que viene de la red.
// `suspend` porque es UNA peticion que tarda y termina (no un flujo que cambia solo).
interface ArbitrosRepository {
suspend fun arbitros(): List<Arbitro>
}package com.sebastiannarvaez.marcador.feature.arbitros.data
import com.sebastiannarvaez.marcador.core.network.LigaApi
import com.sebastiannarvaez.marcador.feature.arbitros.domain.Arbitro
import com.sebastiannarvaez.marcador.feature.arbitros.domain.ArbitrosRepository
import javax.inject.Inject
// El repositorio con la RED como unica fuente: pide la lista y traduce DTO -> dominio.
// Es el unico que conoce `LigaApi`; hacia fuera solo salen `Arbitro`.
class ArbitrosRepositoryRed @Inject constructor(private val api: LigaApi) : ArbitrosRepository {
override suspend fun arbitros(): List<Arbitro> =
api.arbitros().map { Arbitro(id = it.id, nombre = it.name, ciudad = it.address.city) }
}package com.sebastiannarvaez.marcador.feature.arbitros.ui
import com.sebastiannarvaez.marcador.feature.arbitros.domain.Arbitro
// Tres estados excluyentes: la pantalla entera cambia segun el caso, asi que sealed.
// Esta vez el `Cargando` es de verdad: la red tarda.
sealed interface ArbitrosUiState {
data object Cargando : ArbitrosUiState
data class Exito(val arbitros: List<Arbitro>) : ArbitrosUiState
data class Error(val mensaje: String) : ArbitrosUiState
}package com.sebastiannarvaez.marcador.feature.arbitros.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.marcador.feature.arbitros.domain.ArbitrosRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import com.sebastiannarvaez.marcador.torneo.intentar
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.IOException
// Pide los arbitros al abrirse la pantalla (init) y otra vez con «Reintentar».
// `intentar` (de :torneo) atrapa el fallo como Result y RELANZA la cancelacion: si la
// pantalla se cierra a mitad de la peticion, la corrutina se cancela de verdad.
@HiltViewModel
class ArbitrosViewModel @Inject constructor(private val repositorio: ArbitrosRepository) : ViewModel() {
private val _uiState = MutableStateFlow<ArbitrosUiState>(ArbitrosUiState.Cargando)
val uiState: StateFlow<ArbitrosUiState> = _uiState.asStateFlow()
init {
cargar()
}
fun reintentar() = cargar()
private fun cargar() {
_uiState.value = ArbitrosUiState.Cargando
viewModelScope.launch {
_uiState.value = intentar { repositorio.arbitros() }.fold(
onSuccess = { ArbitrosUiState.Exito(it) },
// IOException = no hubo respuesta: sin red, servidor caido o timeout.
onFailure = { error ->
ArbitrosUiState.Error(if (error is IOException) "No hay conexión" else "No se pudieron cargar los árbitros")
},
)
}
}
}package com.sebastiannarvaez.marcador.feature.arbitros.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.sebastiannarvaez.marcador.feature.arbitros.domain.Arbitro
// Sin estado: recibe el UiState y avisa de «Reintentar» hacia arriba.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PantallaArbitros(estado: ArbitrosUiState, onReintentar: () -> Unit, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0),
topBar = { CenterAlignedTopAppBar(title = { Text("Árbitros") }) },
) { interior ->
when (estado) {
ArbitrosUiState.Cargando -> Box(Modifier.padding(interior).fillMaxSize(), Alignment.Center) {
CircularProgressIndicator()
}
is ArbitrosUiState.Error -> Column(
Modifier.padding(interior).fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(estado.mensaje, color = MaterialTheme.colorScheme.error)
Button(onClick = onReintentar) { Text("Reintentar") }
}
is ArbitrosUiState.Exito -> LazyColumn(Modifier.padding(interior)) {
items(estado.arbitros, key = { it.id }) { arbitro ->
ListItem(
headlineContent = { Text(arbitro.nombre) },
supportingContent = { Text(arbitro.ciudad) },
)
HorizontalDivider()
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PantallaArbitrosPreview() {
MaterialTheme {
PantallaArbitros(
ArbitrosUiState.Exito(listOf(Arbitro(1, "Leanne Graham", "Gwenborough"), Arbitro(2, "Ervin Howell", "Wisokyburgh"))),
onReintentar = {},
)
}
}package com.sebastiannarvaez.marcador.di
import com.sebastiannarvaez.marcador.core.network.LigaApi
import com.sebastiannarvaez.marcador.core.network.crearLigaApi
import com.sebastiannarvaez.marcador.core.network.crearOkHttp
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import okhttp3.OkHttpClient
// CAPA DE RED. OkHttpClient y Retrofit no son clases nuestras (no podemos ponerles
// @Inject): se dan con @Provides, que es «la receta» para construirlas. Las recetas
// reutilizan crearOkHttp() y crearLigaApi() de LigaApi.kt.
// @Singleton: UN solo cliente para toda la app (comparte conexiones e hilos).
@Module
@InstallIn(SingletonComponent::class)
object RedModule {
@Provides
@Singleton
fun okHttp(): OkHttpClient = crearOkHttp()
// Hilt ve que esta receta pide un OkHttpClient y le pasa el de arriba.
@Provides
@Singleton
fun ligaApi(cliente: OkHttpClient): LigaApi = crearLigaApi(cliente)
}package com.sebastiannarvaez.marcador.di
import com.sebastiannarvaez.marcador.feature.arbitros.data.ArbitrosRepositoryRed
import com.sebastiannarvaez.marcador.feature.arbitros.domain.ArbitrosRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
// REPOSITORIOS: «quien pide la interfaz, recibe esta implementacion».
// @Binds sirve cuando la implementacion ya tiene @Inject constructor: Hilt sabe
// construirla y solo falta decir para que interfaz. Las funciones con @Binds no tienen
// cuerpo (son `abstract`), asi que el modulo es una `abstract class`, no un `object`.
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoriosModule {
// Quien pida ArbitrosRepository recibe ArbitrosRepositoryRed. Cambiar de implementacion es
// cambiar ESTA linea: ni el ViewModel ni la pantalla se enteran.
@Binds
@Singleton
abstract fun arbitros(impl: ArbitrosRepositoryRed): ArbitrosRepository
}En AppMarcador.kt, la clave nueva va justo debajo de GoleadoresKey:
@Serializable
data object ArbitrosKey : NavKeyEl botón: en la barra de Partidos, la línea actions = { TextButton(onClick = onVerDemos) { Text("Pruebas de Android") } }, pasa a tener dos botones:
actions = {
TextButton(onClick = { pila.add(ArbitrosKey) }) { Text("Árbitros") }
TextButton(onClick = onVerDemos) { Text("Pruebas de Android") }
},Y el destino, al final de la lista de entry, después del de GoleadoresKey:
entry<ArbitrosKey> {
val vm: ArbitrosViewModel = hiltViewModel()
val estado by vm.uiState.collectAsStateWithLifecycle()
PantallaArbitros(estado, onReintentar = vm::reintentar)
}AppMarcador.kt usa dos piezas de otra carpeta (feature → arbitros → ui), así que necesita sus import. Van junto a los demás del proyecto (si prefieres, Option+Enter sobre cada nombre en rojo los añade):
import com.sebastiannarvaez.marcador.feature.arbitros.ui.ArbitrosViewModel
import com.sebastiannarvaez.marcador.feature.arbitros.ui.PantallaArbitrosLos tests. En Falsos.kt van estos dos import junto a los demás:
import com.sebastiannarvaez.marcador.feature.arbitros.domain.Arbitro
import com.sebastiannarvaez.marcador.feature.arbitros.domain.ArbitrosRepositoryY esta clase al final del fichero:
// La red, de mentira: devuelve la lista o lanza el fallo que el test le ponga.
class FakeArbitrosRepository(
var arbitros: List<Arbitro> = listOf(Arbitro(1, "Leanne Graham", "Gwenborough")),
var fallo: Exception? = null,
) : ArbitrosRepository {
override suspend fun arbitros(): List<Arbitro> {
fallo?.let { throw it } // si el test le puso un fallo, lo lanza; si no, sigue y devuelve la lista
return arbitros
}
}package com.sebastiannarvaez.marcador.feature.arbitros.ui
import app.cash.turbine.test
import com.sebastiannarvaez.marcador.core.FakeArbitrosRepository
import com.sebastiannarvaez.marcador.feature.arbitros.domain.Arbitro
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import java.io.IOException
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
// Las mismas tres piezas que PartidoViewModelTest: setMain, runTest y Turbine. La red no
// se toca: el ViewModel recibe un repositorio falso.
@OptIn(ExperimentalCoroutinesApi::class)
class ArbitrosViewModelTest {
private val despachador = StandardTestDispatcher()
@BeforeTest
fun antes() = Dispatchers.setMain(despachador)
@AfterTest
fun despues() = Dispatchers.resetMain()
@Test
fun pasaDeCargandoAExito() = runTest {
val viewModel = ArbitrosViewModel(FakeArbitrosRepository())
viewModel.uiState.test {
assertEquals(ArbitrosUiState.Cargando, awaitItem())
assertEquals(ArbitrosUiState.Exito(listOf(Arbitro(1, "Leanne Graham", "Gwenborough"))), awaitItem())
}
}
@Test
fun sinRedSaleElErrorYReintentarVuelveACargar() = runTest {
val repositorio = FakeArbitrosRepository(fallo = IOException("sin red"))
val viewModel = ArbitrosViewModel(repositorio)
viewModel.uiState.test {
assertEquals(ArbitrosUiState.Cargando, awaitItem())
assertEquals(ArbitrosUiState.Error("No hay conexión"), awaitItem())
repositorio.fallo = null // vuelve la red
viewModel.reintentar()
assertEquals(ArbitrosUiState.Cargando, awaitItem())
assertEquals(ArbitrosUiState.Exito(listOf(Arbitro(1, "Leanne Graham", "Gwenborough"))), awaitItem())
}
}
}Lo que ocurre al abrir Árbitros:
| Paso | Quién | Qué hace |
|---|---|---|
| 1 | hiltViewModel() | Pide a Hilt el ArbitrosViewModel; Hilt recorre la cadena: RepositoriosModule (el @Binds), ArbitrosRepositoryRed y las dos recetas de RedModule |
| 2 | ArbitrosViewModel | En init pone Cargando y lanza la petición |
| 3 | ArbitrosRepositoryRed | Pide los DTO a LigaApi y los traduce a Arbitro |
| 4 | ArbitrosViewModel | Con la respuesta pone Exito; si hay IOException, Error |
| 5 | PantallaArbitros | Pinta la rueda, la lista o el mensaje con el botón |
Probarlo. Ejecuta la app con Run y emulador arrancado. En la barra de Partidos pulsa Árbitros. La rueda de carga dura una fracción de segundo, porque el servidor responde rápido, y aparecen diez filas: la primera es «Leanne Graham» con «Gwenborough» debajo, y la última, «Clementina DuBuque» con «Lebsackbury», en el orden en que las manda el servidor.
Ahora el error: activa el modo avión del emulador (arrastra dos veces desde el borde de arriba para abrir los ajustes rápidos y pulsa el icono del avión), vuelve atrás y entra otra vez en Árbitros. Debe verse «No hay conexión» y el botón Reintentar. Quita el modo avión y pulsa Reintentar: vuelve la lista.
Punto de control
- La app compila y la barra de Partidos tiene el botón Árbitros.
- Árbitros muestra diez filas, de «Leanne Graham · Gwenborough» a «Clementina DuBuque · Lebsackbury».
- Con el modo avión, la pantalla dice «No hay conexión» y tiene el botón Reintentar; al quitarlo y pulsarlo, vuelve la lista.
./gradlew :app:testGratisDebugUnitTestpasa con 12 tests con tus ejercicios (si hiciste el del temporizador) y 9 sin ellos: los 7 de antes y los 2 nuevos. Los ejercicios suman sus propios tests.
Con la red como única fuente, la lista desaparece en cuanto falla. En POST y errores de red aprenderás a enviar datos y a distinguir los errores; y en Dos fuentes: el almacén y el repositorio, a que la lista no se pierda.
Comprueba lo que entendiste
8 preguntas · necesitas 6 para completar la lección
1.¿Por qué
ArbitrosRepositorydevuelvesuspend fun arbitros(): List<Arbitro>y no unFlow?2.¿Quién convierte el
ArbitroDto(la forma del JSON) enArbitro(la clase de la app)?3.¿Por qué
ArbitrosUiStatees unsealed interfaceconCargando,ExitoyError?4.En
ArbitrosViewModel, ¿qué significa que el fallo sea unaIOException?5.El test
sinRedSaleElErrorYReintentarVuelveACargarapaga la red del repositorio falso y luego la «enciende». ¿Qué comprueba?6.En
ArbitrosViewModel,intentar { repositorio.arbitros() }.fold(onSuccess = …, onFailure = …). ¿Qué hacefold?7.¿Por qué
OkHttpClientyLigaApise declaran con@ProvidesenRedModuley no con@Inject constructor?8.
ArbitrosRepositoryRedes una clase tuya con@Inject constructory cumpleArbitrosRepository. ¿Qué escribes enRepositoriosModulepara que quien pida la interfaz reciba esa clase?