Saltar al contenido
K
Detalle y navegaciónPaso a paso35 min de lectura · 10 min de máquina Avanzado

El detalle en el núcleo

Al terminar: Combinar dos peticiones en paralelo y limpiar el texto que devuelve PokeAPI.

Al terminar esta lección, pedir un Pokémon devuelve su ficha entera, ya limpia: tipos, altura, peso, categoría («Pokémon Semilla»), descripción y las seis estadísticas base. Vive en commonMain y las dos apps la van a pintar sin volver a decidir nada. Y habrá tres tests que lo demuestran.

La ficha trae una dificultad que la lista no tenía: PokeAPI la reparte en dos endpoints (dos direcciones distintas de la API). De ahí salen tres decisiones que se toman una vez y se pagan durante el resto del curso: cómo se piden dos cosas a la vez, qué se hace si una de las dos falla, y dónde se limpia un texto que llega sucio.

Esta lección llega hasta el repositorio. El estado de la pantalla y su ViewModel son la siguiente: El ViewModel del detalle.

Las rutas son las de la vista Project, la que enseña las carpetas tal como están en el disco. Si el panel de la izquierda todavía dice «Android», cámbialo con el selector de arriba del panel.

Paso 1 · La mitad de la ficha está en otro sitio

/pokemon/{id} trae tipos, medidas y estadísticas. La descripción y la categoría —«Pokémon Semilla»— no están ahí: viven en /pokemon-species/{id}.

Hay que tocar tres ficheros de la capa de red, uno de los cuales es nuevo:

shared
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/dto/SpeciesDto.ktse crea
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/dto/PokemonDetailDto.ktgana las estadísticas
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/PokeApi.ktgana species()
Todo cae en el módulo compartido: la ficha no sabe que existen las pantallas.

El archivo. SpeciesDto.kt describe el JSON de la especie. Un DTO (data transfer object) es una clase que copia la forma de lo que manda el servidor, no lo que la app necesita: ya escribiste uno en Del JSON al Pokémon: DTO y repositorio. Créalo con la ruta src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/dto/SpeciesDto.kt y escribe:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/dto/SpeciesDto.kt
package com.sebastiannarvaez.pokedex.data.network.dto
 
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
 
/**
 * La especie: lo que en el juego comparten un Pokemon y sus evoluciones.
 *
 * Aqui viven la descripcion y el genero, que PokeAPI **no** devuelve en
 * `/pokemon/{id}`. Por eso el detalle son dos peticiones y no una.
 */
@Serializable
internal data class SpeciesDto(
    val id: Int,
    @SerialName("is_legendary") val isLegendary: Boolean = false,
    @SerialName("is_mythical") val isMythical: Boolean = false,
    @SerialName("flavor_text_entries") val flavorTexts: List<FlavorTextDto> = emptyList(),
    val genera: List<GenusDto> = emptyList(),
)
 
@Serializable
internal data class FlavorTextDto(
    @SerialName("flavor_text") val text: String,
    val language: NamedRefDto,
)
 
@Serializable
internal data class GenusDto(
    val genus: String,
    val language: NamedRefDto,
)

NamedRefDto no se importa porque ya existe en el mismo paquete (dto): lo escribiste en PokemonDetailDto.kt. genus es la categoría («Pokémon Semilla») y genera es su plural en latín: la lista de categorías, una por idioma.

El segundo fichero. La ficha también necesita las estadísticas, que /pokemon/{id} ya devuelve pero PokemonDetailDto no leía. Ábrelo (Navigate → File…, Cmd+Shift+O, y su nombre). Primero, un import nuevo junto a import kotlinx.serialization.Serializable, porque la clase de las estadísticas usa @SerialName:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/dto/PokemonDetailDto.kt
import kotlinx.serialization.SerialName

Después, una línea debajo de types y, tras el ) que cierra la clase, una clase pequeña:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/dto/PokemonDetailDto.kt
    val types: List<TypeSlotDto> = emptyList(),
    val stats: List<StatSlotDto> = emptyList(),
)
 
@Serializable
internal data class StatSlotDto(
    @SerialName("base_stat") val baseStat: Int = 0,
    val stat: NamedRefDto,
)

El tercero. PokeApi.kt es la interfaz de la red: una función por endpoint. Gana species, en dos sitios (la interfaz y su implementación con Ktor) y un import:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/PokeApi.kt
import com.sebastiannarvaez.pokedex.data.network.dto.SpeciesDto

Dentro de interface PokeApi, debajo de detail, una línea:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/PokeApi.kt
    suspend fun species(id: Int): SpeciesDto

Y dentro de KtorPokeApi, debajo de su detail, la implementación con Ktor:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/PokeApi.kt
    override suspend fun species(id: Int): SpeciesDto =
        client.get("pokemon-species/$id").body()

El resto del fichero no cambia.

Paso 2 · Un tipo nuevo, no un Pokemon con campos vacíos

El archivo. PokemonDetail.kt es la ficha tal como la quiere la app, ya limpia. Va en domain, junto a Pokemon: créalo con la ruta src/commonMain/kotlin/com/sebastiannarvaez/pokedex/domain/PokemonDetail.kt.

Son tres tipos en un fichero: la ficha, una estadística y la lista de estadísticas posibles.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/domain/PokemonDetail.kt
package com.sebastiannarvaez.pokedex.domain
 
/**
 * La ficha completa, que solo hace falta al abrir un Pokemon.
 *
 * Es un tipo distinto de `Pokemon` y no una version con campos nulos: el
 * listado nunca tiene esta informacion, y un modelo con la mitad de los campos
 * vacios obliga a preguntar en cada pantalla si estan o no.
 */
data class PokemonDetail(
    val id: Int,
    val name: String,
    val types: List<PokemonType>,
    /** En centimetros. PokeAPI lo da en decimetros. */
    val heightCm: Int,
    /** En gramos. PokeAPI lo da en hectogramos. */
    val weightG: Int,
    /** «Pokemon Semilla»: la categoria. Viene de la especie, no del Pokemon. */
    val genus: String,
    val description: String,
    val stats: List<PokemonStat>,
    val isLegendary: Boolean,
) {
    val artworkUrl: String
        get() = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/$id.png"
}
 
data class PokemonStat(val kind: StatKind, val value: Int) {
    companion object {
        /** El maximo que alcanza una estadistica base en el juego. */
        const val MAXIMO = 255
    }
}
 
enum class StatKind {
    HP, ATTACK, DEFENSE, SPECIAL_ATTACK, SPECIAL_DEFENSE, SPEED;
 
    companion object {
        /** PokeAPI usa guiones: `special-attack`. */
        fun deApi(nombre: String): StatKind? = when (nombre) {
            "hp" -> HP
            "attack" -> ATTACK
            "defense" -> DEFENSE
            "special-attack" -> SPECIAL_ATTACK
            "special-defense" -> SPECIAL_DEFENSE
            "speed" -> SPEED
            else -> null
        }
    }
}

El concepto. (const val MAXIMO = 255 declara una constante fijada al compilar: un valor que no cambia nunca.) Un modelo con la mitad de los campos nulos obliga a preguntar «¿está o no?» en cada pantalla; un tipo propio, no. Y deApi devuelve StatKind?: una estadística que no conocemos se descarta y la ficha sigue viva. Si mañana PokeAPI añade una nueva, la app pinta las seis que sabe pintar.

Paso 3 · Las dos peticiones van en paralelo

El archivo. PokemonRepository.kt ya existe: es quien traduce lo que da la red a lo que necesita la app. Ábrelo. Ninguna de las dos peticiones depende de la otra, así que pedirlas en serie tardaría el doble sin ganar nada.

Primero, los import nuevos, que se añaden con los que ya hay arriba (el IDE los ofrece con Option+Enter, pero así sabes cuáles son):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRepository.kt
import com.sebastiannarvaez.pokedex.data.network.dto.PokemonDetailDto
import com.sebastiannarvaez.pokedex.data.network.dto.SpeciesDto
import com.sebastiannarvaez.pokedex.domain.PokemonDetail
import com.sebastiannarvaez.pokedex.domain.PokemonStat
import com.sebastiannarvaez.pokedex.domain.StatKind

Y, dentro de la clase, entre la función page y la función pokemon() que ya tiene, la función nueva. Es la lección entera en seis líneas:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRepository.kt
    suspend fun detail(id: Int): PokemonDetail = withContext(dispatchers.io) {
        // withContext: todo el trabajo corre en el dispatcher de entrada y salida
        // (el que se inyectó en Corrutinas en commonMain), no en el hilo de la interfaz.
        coroutineScope {
            // coroutineScope: no termina hasta que terminen sus hijos. Si uno falla,
            // cancela al otro y la excepción sube: la ficha falla entera.
            val basico = async { api.detail(id) }     // arranca la petición de /pokemon/{id}…
            val especie = async { api.species(id) }   // …y, sin esperarla, la de /pokemon-species/{id}
            unirDetalle(basico.await(), especie.await())  // await() espera cada resultado y se juntan
        }
    }

Android Studio marcará unirDetalle en rojo: es la función del paso 4.

El concepto, y la decisión de fondo: si una de las dos falla, falla la ficha entera, y coroutineScope hace exactamente eso. La alternativa —enseñar la ficha sin descripción— se descartó por una razón concreta: el usuario no vería un hueco, vería un Pokémon del que «no hay texto», y no volvería a intentarlo. Un error visible con su botón de reintentar es más honesto que un dato que falta en silencio.

Y una advertencia para quien un día quiera esa alternativa: la salida corta, runCatching { … }.getOrNull() (ejecuta el bloque y devuelve null si falla), también atrapa la CancellationException, que es como una corrutina se entera de que la cancelan, y la traga. Habría que relanzarla a mano antes de atrapar lo demás, como ya hiciste en Errores que se pueden contar.

Paso 4 · Convertir y limpiar, aquí y no en la pantalla

Tres cosas vienen mal de origen. Las tres se arreglan en PokemonRepository.

Las medidas. PokeAPI da decímetros y hectogramos: Bulbasaur mide 7 y pesa 69. Son 70 cm y 6,9 kg.

El idioma. El mismo texto llega en once idiomas dentro del mismo array, y no hay endpoint por idioma: de, en, es, es-419, fr, it, ja, ja-hrkt, ko, zh-hans y zh-hant en /pokemon-species/1. Se filtra en Kotlin, con respaldo a inglés.

Los saltos de cartucho. Los textos traen saltos de línea y de página metidos donde cortaba la pantalla de la Game Boy, no donde acaba una frase. El de Bulbasaur en inglés llega así:

A strange seed was\nplanted on its\nback at birth.\x0cThe plant sprouts\nand grows with\nthis POKéMON.

Ese \x0c es un salto de página, U+000C. Varias de las entradas en inglés de Bulbasaur lo traen.

Una idea nueva: la expresión regular

Para deshacer esos saltos hace falta algo que no habías visto: una expresión regular. Es un patrón que describe un tramo de texto, y sirve para encontrarlo o para cambiarlo. Un patrón se escribe con símbolos que significan cosas: \s es «un espacio, un salto de línea o un tabulador» y + es «uno o más seguidos». Juntos, \s+ es «cualquier tramo de blancos, sea del tamaño que sea».

Un ejemplo con datos ajenos a la Pokédex, una nota de la compra mal escrita:

ejemplo mínimo: una expresión regular para ordenar espacios (con comentarios)
fun main() {
    val nota = "Comprar   pan\ny\tleche  "             // espacios de más, un salto de línea y un tabulador
    val patron = Regex("\\s+")                          // \s = un espacio, salto o tabulador; + = uno o más seguidos
    println(patron.containsMatchIn(nota))               // ¿hay algún tramo así?
    println(patron.findAll(nota).count())               // cuántos tramos encuentra
    println("[" + nota.replace(patron, " ") + "]")      // cada tramo, por un solo espacio
    println("[" + nota.replace(patron, " ").trim() + "]") // y sin espacios en los extremos
}

Pégalo en el Kotlin Playground (play.kotlinlang.org) y ejecútalo: imprime

true
4
[Comprar pan y leche ]
[Comprar pan y leche]

Los cuatro tramos son los tres espacios de «Comprar pan», el salto de línea, el tabulador y los dos espacios del final. Cada uno se sustituye por un único espacio, y trim() quita el que queda sobrante al final.

Una pega de Kotlin: dentro de una cadena, la barra invertida se escribe doble ("\\s+"), porque una sola ya significa algo: \n es un salto de línea.

Las funciones que limpian

Se hace con dos funciones auxiliares, que van fuera de la clase, al final del fichero (después de la llave que la cierra).

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRepository.kt
// private + extensión: solo se ve dentro de este fichero, y se escribe como si
// fuera un método de SpeciesDto (this es la especie).
private fun SpeciesDto.textoEnEspanol(extraer: (SpeciesDto) -> List<Pair<String, String>>): String {
    val textos = extraer(this)  // la lambda saca la lista de parejas (texto, idioma)
    return textos.firstOrNull { it.second == "es" }?.first   // el primero en español…
        ?: textos.firstOrNull { it.second == "en" }?.first   // …si no hay, en inglés…
        ?: ""                                                // …y si no hay ninguno, vacío
}
 
// Es la expresión regular de arriba. \s+ ya incluye el salto de línea y el de
// página, así que los dos primeros replace no harían falta; se dejan escritos
// para quien lea esto y se pregunte de dónde salen los saltos.
private fun String.limpiarSaltos(): String =
    replace('\n', ' ')                  // salto de línea → espacio
        .replace('\u000c', ' ')         // salto de página → espacio
        .replace(Regex("\\s+"), " ")    // varios espacios seguidos → uno
        .trim()                         // y sin espacios en los extremos

Ahora sí, la función que une las dos respuestas. Va dentro de la clase, al final, antes de la llave que la cierra (debajo de pokemon()):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRepository.kt
    private fun unirDetalle(basico: PokemonDetailDto, especie: SpeciesDto): PokemonDetail =
        PokemonDetail(
            id = basico.id,
            name = basico.name,
            // sortedBy slot: PokeAPI numera los tipos (1 y 2) y no garantiza el orden.
            // mapNotNull: un tipo que no conocemos se descarta en vez de romper.
            types = basico.types.sortedBy { it.slot }.mapNotNull { PokemonType.deApi(it.type.name) },
            // PokeAPI mide en decimetros y hectogramos. La conversión vive aquí y no
            // en la pantalla: allí habría que escribirla dos veces (Compose y SwiftUI)
            // y una de las dos acabaría equivocándose.
            heightCm = basico.height * 10,
            weightG = basico.weight * 100,
            genus = especie.textoEnEspanol { it.genera.map { g -> g.genus to g.language.name } },
            description = especie.textoEnEspanol { it.flavorTexts.map { f -> f.text to f.language.name } }
                .limpiarSaltos(),
            // La estadística de nombre desconocido devuelve null y se salta.
            stats = basico.stats.mapNotNull { s ->
                StatKind.deApi(s.stat.name)?.let { PokemonStat(it, s.baseStat) }
            },
            // Legendario o mítico: para la ficha es la misma etiqueta.
            isLegendary = especie.isLegendary || especie.isMythical,
        )

Con esto, el rojo de unirDetalle desaparece. Comprueba que todo el código común compila (los tests aún no: son el último paso):

./gradlew :shared:compileKotlinJvm

Debe terminar en BUILD SUCCESSFUL. Si queda algo en rojo, lo más probable es un import que falta en PokemonRepository.kt o en PokemonDetailDto.kt (el de SerialName): Option+Enter sobre el nombre en rojo lo añade.

Paso 5 · Arreglar el doble y probar el repositorio

Primero, arregla el doble. Abre Dobles.kt (en shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/). FakePokeApi tiene que cumplir la interfaz nueva, y sus respuestas tienen que parecerse a las de PokeAPI, que ahora traen medidas y estadísticas. Tres retoques: los import nuevos, la función species y la función detail completa. Los import nuevos:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/Dobles.kt
import com.sebastiannarvaez.pokedex.data.network.dto.FlavorTextDto
import com.sebastiannarvaez.pokedex.data.network.dto.GenusDto
import com.sebastiannarvaez.pokedex.data.network.dto.SpeciesDto
import com.sebastiannarvaez.pokedex.data.network.dto.StatSlotDto

La función species, dentro de FakePokeApi, encima de detail:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/Dobles.kt
    override suspend fun species(id: Int): SpeciesDto {
        if (falla) throw IOException("socket cerrado")
        return SpeciesDto(
            id = id,
            isLegendary = false,
            flavorTexts = listOf(
                FlavorTextDto("Texto en ingles.", NamedRefDto("en")),
                // Con salto de linea y salto de pagina, como los de verdad.
                FlavorTextDto("Una rara semilla\nle fue plantada\u000cal nacer.", NamedRefDto("es")),
            ),
            genera = listOf(
                GenusDto("Seed Pokemon", NamedRefDto("en")),
                GenusDto("Pokemon Semilla", NamedRefDto("es")),
            ),
        )
    }

Y la función detail, que ya existía: sustitúyela entera por esta, con lo nuevo marcado (las medidas height y weight, y la lista stats). Dos piezas del doble que ya tenías y que aquí se usan: tipos, un mapa opcional que dice qué tipos devuelve cada número (si el número no está, normal), y falla, un interruptor que hace que species y page lancen un error de red como si se hubiera caído la conexión (llamadasAlDetalle cuenta las veces que se pidió el detalle):

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/Dobles.kt
    override suspend fun detail(id: Int): PokemonDetailDto {
        llamadasAlDetalle++
        return PokemonDetailDto(
            id = id,
            name = "pokemon-$id",
            height = 7,        // nuevo: decímetros, como los da PokeAPI
            weight = 69,       // nuevo: hectogramos
            types = (tipos[id] ?: listOf("normal")).mapIndexed { i, t ->
                TypeSlotDto(slot = i + 1, type = NamedRefDto(t))
            },
            stats = listOf(    // nuevo: dos conocidas y una que no
                StatSlotDto(baseStat = 45, stat = NamedRefDto("hp")),
                StatSlotDto(baseStat = 49, stat = NamedRefDto("attack")),
                // Una que no conocemos: no debe romper la ficha.
                StatSlotDto(baseStat = 10, stat = NamedRefDto("suerte")),
            ),
        )
    }

Fíjate en el texto en español de ese doble: lleva un salto de línea y uno de página dentro, copiados de la forma que tiene el de verdad. Un doble que devuelve datos limpios prueba que el código funciona con datos que nunca va a recibir.

El fichero de tests. PokemonDetailTest.kt va en shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/detail/. Por ahora son tres tests, los que comprueban lo que decidió esta lección: que 7 y 69 se convierten en 70 cm y 6900 g; que el texto llega en español y sin saltos; y que una estadística desconocida no rompe la ficha. Se parecen a los de Probar la paginación y Probar el puente en iOS: el repositorio de verdad, con el doble de la API, y los hilos de la app apuntando al reloj virtual de runTest (el testScheduler, que TestDispatchers recibe para que las corrutinas obedezcan al test). Créalo y escribe:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/detail/PokemonDetailTest.kt
package com.sebastiannarvaez.pokedex.feature.detail
 
import com.sebastiannarvaez.pokedex.data.PokemonRepository
import com.sebastiannarvaez.pokedex.dobles.FakePokeApi
import com.sebastiannarvaez.pokedex.dobles.TestDispatchers
import com.sebastiannarvaez.pokedex.domain.StatKind
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
 
class PokemonDetailTest {
 
    // El repositorio de verdad, con el doble de la API y el reloj virtual de runTest.
    private fun repo(scheduler: TestCoroutineScheduler) =
        PokemonRepository(FakePokeApi(total = 10), TestDispatchers(StandardTestDispatcher(scheduler)))
 
    @Test
    fun lasMedidasSeConviertenAUnidadesDeHumano() = runTest {
        // PokeAPI mide en decimetros y hectogramos, que no usa nadie.
        val detalle = repo(testScheduler).detail(1)
 
        assertEquals(70, detalle.heightCm)
        assertEquals(6900, detalle.weightG)
    }
 
    @Test
    fun elTextoLlegaEnEspanolYSinSaltosDeCartucho() = runTest {
        val detalle = repo(testScheduler).detail(1)
 
        assertEquals("Pokemon Semilla", detalle.genus)
        assertEquals("Una rara semilla le fue plantada al nacer.", detalle.description)
        assertFalse(detalle.description.contains('\n'))
    }
 
    @Test
    fun unaEstadisticaDesconocidaNoRompeLaFicha() = runTest {
        val detalle = repo(testScheduler).detail(1)
 
        assertEquals(listOf(StatKind.HP, StatKind.ATTACK), detalle.stats.map { it.kind })
        assertEquals(45, detalle.stats.first { it.kind == StatKind.HP }.value)
    }
}

Ejecuta los tests de la ficha:

./gradlew :shared:jvmTest --tests '*PokemonDetailTest'

Debe terminar en BUILD SUCCESSFUL, con los tres tests. Este fichero crece en la lección siguiente, con los dos tests del ViewModel.

Punto de control

  • ./gradlew :shared:jvmTest --tests '*PokemonDetailTest' termina en BUILD SUCCESSFUL con los tres tests.
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 3 tests más que al terminar la lista: 30 en la JVM, 29 en Android host y 31 en iOS sin contar los de la carpeta ejercicios. Si hiciste ejercicios en módulos anteriores, tu cifra será mayor.
  • detail(1) devuelve heightCm = 70 y weightG = 6900, no 7 y 69.
  • La descripción que llega a PokemonDetail no contiene ningún salto de línea ni de página.
  • Una estadística con nombre desconocido no aparece en la lista y no rompe la ficha.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Por qué la ficha de un Pokémon son dos peticiones a PokeAPI y no una?

  2. 2.Las dos peticiones van dentro de un coroutineScope con dos async. Si /pokemon-species/{id} falla, ¿qué ocurre?

  3. 3.PokeAPI mide en decímetros y hectogramos. ¿Por qué la conversión a centímetros y gramos vive en el repositorio y no en cada pantalla?

  4. 4.StatKind.deApi(nombre) devuelve StatKind?, y unirDetalle la usa con mapNotNull. ¿Qué se gana con eso?

  5. 5.¿Qué devuelve "a b\nc".replace(Regex("\\s+"), " "), con dos espacios entre la a y la b y un salto de línea antes de la c?

Faltan 5 de 5