Dos idiomas: el idioma en el núcleo
Al terminar: Que el módulo compartido pida a PokeAPI los textos en el idioma del usuario, con una lista de preferencias y tests que fijan el idioma.
La app está en español porque los textos están escritos en español dentro del código. Esta lección y las tres siguientes la ponen también en inglés, y arreglan de paso algo que llevaba roto desde El detalle en el núcleo: quien tuviera el teléfono en inglés veía la descripción del Pokémon en español igualmente.
El trabajo se reparte en cuatro, en este orden:
- Esta lección toca el módulo compartido (
shared): decide en qué idioma pedir los textos a PokeAPI. - Dos idiomas en Android: los recursos pasa los textos de la interfaz de Android a recursos y los traduce.
- Dos idiomas en Android: todas las pantallas termina el trabajo en el resto de las pantallas.
- Dos idiomas en iOS hace lo mismo con el catálogo de cadenas de iOS.
Las de Android y la de iOS son independientes entre sí, pero todas necesitan esta. Al terminar, el repositorio sabrá en qué idioma buscar el texto de cada ficha, y tendrás dos tests nuevos que lo comprueban sin depender del idioma de tu ordenador.
El concepto · Una lista de idiomas, y un idioma que entra desde fuera
Tu teléfono no tiene «un idioma». Tiene una lista ordenada: quien configura «catalán, español, inglés» espera que se intente primero el catalán, y si no existe, el español, y si no, el inglés. Desde Android 13 hay además un idioma por app: en Ajustes → Sistema → Idiomas → Idiomas de las apps, cada app puede tener el suyo. Tu móvil puede estar en español y la Pokédex en inglés.
Hay dos piezas de Kotlin nuevas, y las dos se ven mejor en pequeño, sin la Pokédex.
Una función como parámetro, con valor por defecto. Hasta ahora has pasado números y textos a una clase. También se le puede pasar una función. El tipo () -> Int se lee «algo que, al llamarlo sin nada dentro, devuelve un Int». Y ::horaDelSistema (con dos puntos delante) es la función misma, sin llamarla, como cuando en Lambdas con listas escribiste :: para pasar una función donde se esperaba una lambda. ¿Para qué sirve? Para que un test pueda decir «la hora (o el idioma) es esta» sin esperar a que el reloj del ordenador coincida. Y una función corta se puede escribir ahí mismo entre llaves: { 18 } es «una función sin nada dentro que devuelve 18».
return dentro de un for. Un return dentro de un bucle no solo sale del bucle: sale de la función entera, con ese valor. Sirve para «recorre las preferencias en orden y quédate con la primera que exista».
Pégalo en el Kotlin Playground, fuera de tu proyecto, y ejecútalo:
// La hora «de verdad» del sistema (aquí, inventada para el ejemplo).
fun horaDelSistema(): Int = 9
// El saludo recibe UNA FUNCIÓN que da la hora, no la hora.
// `() -> Int` se lee «algo que, al llamarlo sin nada, devuelve un Int».
// Por defecto es la del sistema: `::horaDelSistema` es la función misma, sin llamarla.
class Saludo(private val hora: () -> Int = ::horaDelSistema) {
// `hora()` la llama AHORA: si la hora cambia, el saludo también.
fun texto() = if (hora() < 12) "Buenos días" else "Buenas tardes"
}
// Recorre las preferencias EN ORDEN y devuelve el primer plato que la carta tenga.
fun primeroDisponible(preferidos: List<String>, carta: Map<String, String>): String {
for (idioma in preferidos) {
val plato = carta[idioma]
// `return` dentro de un `for` sale de la función entera.
if (plato != null) return plato
}
return "sin carta"
}
fun main() {
println(Saludo().texto()) // la hora del sistema
println(Saludo(hora = { 18 }).texto()) // `{ 18 }` es una función de una línea: devuelve 18
val carta = mapOf("en" to "Soup", "es" to "Sopa")
println(primeroDisponible(listOf("ca", "es", "en"), carta)) // el catalán no está: gana el español
println(primeroDisponible(listOf("ca", "en"), carta)) // sin español, gana el inglés
println(primeroDisponible(listOf("ca"), carta)) // ninguno
}Buenos días
Buenas tardes
Sopa
Soup
sin cartaEsas dos piezas son todo el diseño de la lección: el repositorio recibirá una función que da la lista de idiomas (idiomas: () -> List<String>), y la función que filtra los textos recorrerá la lista en orden, como primeroDisponible.
Paso 1 · El módulo compartido pregunta al sistema
Las cuatro rutas. Son cuatro ficheros, uno por source set (la carpeta del módulo shared que agrupa el código de una plataforma: Qué es Kotlin Multiplatform). Las rutas, desde la carpeta shared:
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/Idioma.kt
src/androidMain/kotlin/com/sebastiannarvaez/pokedex/core/Idioma.android.kt
src/iosMain/kotlin/com/sebastiannarvaez/pokedex/core/Idioma.ios.kt
src/jvmMain/kotlin/com/sebastiannarvaez/pokedex/core/Idioma.jvm.ktPara cada uno, clic derecho sobre la carpeta shared → New → File y esa ruta (si una carpeta del camino no existe, el diálogo la crea al escribir las barras). El sufijo .android.kt, .ios.kt, .jvm.kt es solo una costumbre para reconocerlos: lo que decide qué plataforma compila cada fichero es la carpeta (androidMain, iosMain…). jvmMain es el source set del objetivo jvm(), que el módulo tiene para que sus tests corran en la JVM del ordenador, en segundos y sin emulador.
El archivo común. Es la promesa de expect/actual que viste en expect/actual frente a interfaces: expect declara una función que el código común puede llamar, y cada plataforma la cumple a su manera con su actual. Empieza por Idioma.kt:
package com.sebastiannarvaez.pokedex.core
/**
* Los idiomas que quiere el usuario, en orden de preferencia.
*
* Devuelve una lista y no un idioma porque el sistema tampoco tiene uno: quien
* configura «catalán, español, inglés» espera que se intente en ese orden.
* Siempre termina en `en`, que es el único que PokeAPI garantiza.
*/
expect fun idiomasPreferidos(): List<String>
/**
* El código corto, que es lo que usa PokeAPI: `es`, no `es-ES`.
* Corta por el primer `-` o `_` y pasa a minúsculas: `es-ES` → `es`, `pt_BR` → `pt`.
*/
internal fun String.codigoCorto(): String = substringBefore('-').substringBefore('_').lowercase()Instala androidx.core.ktx en shared
Qué es y por qué ahora. La versión de Android necesita LocaleListCompat, la clase de AndroidX que da la lista entera de idiomas del usuario, en su orden y con el idioma por app incluido (Locale.getDefault(), la clase clásica de Java, que Android trae desde el principio, solo da el primero). Compat quiere decir que funciona igual en versiones antiguas de Android. Esa clase vive en androidx.core, la misma librería que usa androidApp, pero shared es otro módulo y lo que declara un módulo no lo ve el otro.
- Abre
gradle/libs.versions.toml. La librería ya está en[libraries]desde que se creó el proyecto, con su versión en[versions]. Comprueba que ves estas dos líneas (si faltan, añádelas, cada una en su sección):
# en [versions]
coreKtx = "1.19.0"
# en [libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }- Abre
shared/build.gradle.kts. Dentro dekotlin { sourceSets { … } }estáandroidMain.dependencies { … }, con Koin y el motor de Ktor. Añade las dos últimas líneas (el comentario y elimplementation) al final de ese bloque, sin tocar lo que ya hay.implementationy noapi, porque ninguna firma pública desharedexpone tipos de AndroidX (la diferencia, en La primera dependencia):
androidMain.dependencies {
implementation(libs.koin.android)
// Cada plataforma usa su motor: el cliente de Ktor es común, quien
// hace la petición de verdad no lo es.
implementation(libs.ktor.client.okhttp)
// LocaleListCompat: la lista de idiomas, con el idioma por app.
implementation(libs.androidx.core.ktx)
}- Sync Now (si sale en rojo, la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto). Comprueba con la Terminal de Android Studio:
./gradlew :shared:dependencies --configuration androidCompileClasspath | grep "^+--- androidx.core:core-ktx"La orden tiene tres piezas. dependencies imprime el árbol de librerías de shared. androidCompileClasspath es la lista con la que se compila el código de androidMain. Y | grep "…" (la barra pasa lo impreso al filtro) deja solo las líneas que empiezan por +---, es decir, lo que shared declara él mismo: lo que llega por casualidad aparece más adentro, con barras delante. Debe salir una línea con androidx.core:core-ktx: y la versión de tu catálogo (por ejemplo +--- androidx.core:core-ktx:1.19.0, a veces con un (*) al final, que solo significa «ya estaba más arriba»). Si no sale nada, la línea del paso 2 no está.
Las tres implementaciones
Con la promesa hecha, cada plataforma la cumple a su manera. Crea los otros tres ficheros con la misma ruta:
package com.sebastiannarvaez.pokedex.core
import androidx.core.os.LocaleListCompat
actual fun idiomasPreferidos(): List<String> {
// `getAdjustedDefault()` tiene en cuenta el idioma por app de Android 13,
// no solo el del sistema.
val lista = LocaleListCompat.getAdjustedDefault()
// La lista no es una `List` de Kotlin: se recorre por posición, de la 0 a la
// última (`0 until n` son los números 0, 1… n-1). De cada idioma se toma su
// código corto; si alguna posición viniera vacía (`?.`), `mapNotNull` la
// descarta en lugar de dar error.
val codigos = (0 until lista.size()).mapNotNull { lista[it]?.language?.codigoCorto() }
// `+ "en"` y `distinct()`: el inglés siempre cierra la lista, sin repetirse.
return (codigos + "en").distinct()
}package com.sebastiannarvaez.pokedex.core
import platform.Foundation.NSLocale
import platform.Foundation.preferredLanguages
actual fun idiomasPreferidos(): List<String> {
// `preferredLanguages` respeta el orden que el usuario puso en Ajustes, e
// incluye el idioma por app si lo cambió desde ahí. Kotlin lo recibe como
// una lista de «cualquier cosa» (`List<*>`) porque Objective-C no declara
// el tipo de los elementos; `filterIsInstance<String>()` se queda con los
// textos y devuelve ya una `List<String>`.
val codigos = NSLocale.preferredLanguages
.filterIsInstance<String>()
.map { it.codigoCorto() }
return (codigos + "en").distinct()
}package com.sebastiannarvaez.pokedex.core
import java.util.Locale
// La versión de la JVM, la de los tests del módulo compartido.
actual fun idiomasPreferidos(): List<String> =
listOf(Locale.getDefault().language.codigoCorto(), "en").distinct()El repositorio recibe el idioma, no lo busca
Ahora shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRepository.kt. Hay una decisión de diseño: el repositorio no llama a idiomasPreferidos() por dentro, sino que la recibe como parámetro. Es el mismo criterio que con los dispatchers y con el reloj de la sesión: lo que depende del entorno entra desde fuera. Si el repositorio leyera el idioma del sistema por su cuenta, un test que comprueba el texto en español pasaría en tu Mac en español y fallaría en un equipo en inglés, con un mensaje (expected:<[Pokemon Semilla]> but was:<[Seed Pokemon]>) que no menciona el idioma por ninguna parte. Y el caso «teléfono en inglés» no se podría ni escribir como test.
Tres cambios. El import, junto a los demás:
import com.sebastiannarvaez.pokedex.core.idiomasPreferidosEl constructor, con el parámetro nuevo al final (por tener valor por defecto, Modules.kt y los tests que ya existen siguen compilando sin tocarlos):
internal class PokemonRepository(
private val api: PokeApi,
private val dispatchers: AppDispatchers,
// Una función y no una lista: se evalúa en cada ficha, así que si el
// usuario cambia el idioma con la app abierta, la siguiente ficha lo respeta.
private val idiomas: () -> List<String> = ::idiomasPreferidos,
) {Y la función que filtra los textos, que estaba fijada a español. Sustituye entera la función textoEnEspanol (la que está al final del fichero, antes de limpiarSaltos, con su comentario) y las dos llamadas que la usan, dentro de unirDetalle:
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(), genus = especie.enElIdioma(idiomas()) { it.genera.map { g -> g.genus to g.language.name } },
description = especie.enElIdioma(idiomas()) { it.flavorTexts.map { f -> f.text to f.language.name } }
.limpiarSaltos(),/**
* El texto en el primer idioma de la lista que PokeAPI traiga.
*
* `extraer` devuelve parejas (`Pair`): cada texto con el código de su idioma.
* `.first` es el texto (el primer valor de la pareja) y `.second` el idioma (el
* segundo). `firstOrNull { … }` es «el primero que cumpla esto, o `null` si
* ninguno».
*
* Se recorre la lista de preferencias EN ORDEN. Hacerlo al revés (buscar por
* cada texto cuál encaja) daría el primero del array, que es el que PokeAPI
* devuelva primero, no el que el usuario quiere.
*/
private fun SpeciesDto.enElIdioma(
preferidos: List<String>,
extraer: (SpeciesDto) -> List<Pair<String, String>>,
): String {
val textos = extraer(this)
for (idioma in preferidos) {
val texto = textos.firstOrNull { it.second == idioma }
// `return` dentro de un `for` sale de la función entera.
if (texto != null) return texto.first
}
// Si no hay ninguno de la lista, el primero que haya; y si no, vacío.
return textos.firstOrNull()?.first.orEmpty()
}Los tests fijan el idioma
Abre shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/detail/PokemonDetailTest.kt. Su función repo(…) construía el repositorio sin idioma. Ahora lo fija, y por eso desaparece la dependencia de la máquina. Sustituye la función repo entera por esta:
// El idioma se fija en el test y no se hereda de la máquina: así pasa igual
// en tu Mac, en español o en inglés.
private fun repo(
falla: Boolean = false,
scheduler: kotlinx.coroutines.test.TestCoroutineScheduler,
idiomasDelTest: List<String> = listOf("es", "en"),
) = PokemonRepository(
FakePokeApi(total = 10, falla = falla),
TestDispatchers(StandardTestDispatcher(scheduler)),
// Una función que siempre devuelve la lista del test.
idiomas = { idiomasDelTest },
)Y con el idioma desde fuera aparecen dos tests que antes no se podían escribir. Añádelos dentro de la clase, tras elTextoLlegaEnEspanolYSinSaltosDeCartucho:
// La otra mitad de la regla: con el teléfono en inglés, el texto llega en inglés.
@Test
fun conElTelefonoEnInglesElTextoLlegaEnIngles() = runTest {
val detalle = repo(scheduler = testScheduler, idiomasDelTest = listOf("en")).detail(1)
assertEquals("Seed Pokemon", detalle.genus)
// Este texto lo inventa el `FakePokeApi` (fichero `Dobles.kt`), que ya
// trae una ficha en español y otra en inglés.
assertEquals("Texto en ingles.", detalle.description)
}
// Un idioma que PokeAPI no trae (`eu` es el euskera) cae al inglés, que siempre está.
@Test
fun unIdiomaQueNoExisteCaeAlIngles() = runTest {
val detalle = repo(scheduler = testScheduler, idiomasDelTest = listOf("eu", "en")).detail(1)
assertEquals("Seed Pokemon", detalle.genus)
}Provócalo. Cambia a mano el valor por defecto de repo a listOf("en") y ejecuta los tests de abajo: falla elTextoLlegaEnEspanolYSinSaltosDeCartucho (y solo ese) con un mensaje que no menciona el idioma por ninguna parte (expected:<[Pokemon Semilla]> but was:<[Seed Pokemon]>; los corchetes los pone la herramienta). Es el fallo que tendría el repositorio si leyera el idioma de la máquina: solo aparece en un ordenador en inglés. Devuelve el valor a listOf("es", "en").
Los tests de commonTest corren una vez por cada objetivo del módulo. Estas dos órdenes lanzan los de la JVM (jvmTest) y los de Android (testAndroidHostTest: el mismo código compilado como Android, pero ejecutado en la JVM del ordenador, sin emulador):
./gradlew :shared:jvmTest :shared:testAndroidHostTestTermina en BUILD SUCCESSFUL, y con todo en verde Gradle no imprime ninguna cifra. Para verla, abre los informes:
open shared/build/reports/tests/jvmTest/index.html
open shared/build/reports/tests/testAndroidHostTest/index.htmlArriba de cada uno pone el número de tests: 129 en la JVM y 91 en Android, dos más que antes (127 y 89) porque los dos tests nuevos de commonTest cuentan en cada plataforma. Para iOS, ./gradlew :shared:iosSimulatorArm64Test (solo corre en Mac y arranca un simulador) y open shared/build/reports/tests/iosSimulatorArm64Test/index.html: 94, con los dos nuevos. androidApp no cambia en esta lección: sigue en 14.
Punto de control
Idioma.kty sus tresactualexisten en sus cuatro carpetas, y./gradlew :shared:dependencies --configuration androidCompileClasspath | grep "^+--- androidx.core:core-ktx"devuelve una línea con1.19.0../gradlew :shared:jvmTest :shared:testAndroidHostTesttermina enBUILD SUCCESSFULy los informes dicen 129 (JVM) y 91 (Android); el de iOS, 94.- Con un
reposin idioma explícito el repositorio usa::idiomasPreferidos;Modules.ktno cambia. - La app sigue viéndose igual: lo que cambia es el texto que llega de PokeAPI, y se ve de verdad en las lecciones siguientes.
Siguiente: Dos idiomas en Android: los recursos. Después vienen todas las pantallas de Android y Dos idiomas en iOS, que puedes hacer en cualquier orden.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.¿Por qué
idiomasPreferidos()devuelve una lista y no un idioma?2.¿Qué decide que
Idioma.android.ktse compile solo para Android: su sufijo.android.kto la carpetaandroidMaindonde está?3.
PokemonRepositoryrecibeidiomas: () -> List<String>por el constructor en lugar de llamar aidiomasPreferidos()por dentro. ¿Qué se gana?4.
enElIdiomarecorre la lista de preferencias y busca cada idioma entre los textos, en vez de recorrer los textos y mirar si su idioma está en la lista. ¿Por qué?5.Aunque
core-ktxya llegaba asharedpor otra librería, la lección la declara conimplementation. ¿Por qué?6.¿Por qué
idiomases de tipo() -> List<String>(una función) y noList<String>(una lista)?