Dos idiomas en Android: los recursos
Al terminar: Pasar los textos de Android a recursos y traducirlos al inglés.
La interfaz de Android tiene los textos escritos dentro del código (Text("Ajustes")). Hay que sacarlos a recursos, que es donde Android espera los textos de una app, y añadir la traducción al inglés. Son muchos cambios, así que van en dos lecciones: esta crea los recursos, aprende la técnica con las etiquetas de los tipos y la tarjeta de la lista, y escribe los tests que miran la app en los dos idiomas; la siguiente, Dos idiomas en Android: todas las pantallas, recorre el resto de pantallas. Parte de Dos idiomas: el idioma en el núcleo, que dejó el módulo compartido pidiendo a PokeAPI el idioma del usuario. La otra plataforma, la de iPhone, está en Dos idiomas en iOS y no depende de esta.
El cambio va fichero a fichero y compilando cada vez: el proyecto nunca queda en rojo más de un paso.
El concepto · Un texto que no es un texto
Hasta ahora, cada pantalla escribe sus palabras dentro del código: Text("Ajustes"). Eso funciona con un solo idioma. Con dos, la pantalla no puede decidir qué palabra poner, porque no sabe en qué idioma está el teléfono. La solución es separar qué texto (una clave, como pestana_ajustes) de qué palabras (un fichero por idioma). El código pide la clave y Android entrega las palabras del idioma que toca.
Un ejemplo ajeno al proyecto, con todas las piezas:
// 1. Un fichero por idioma, con la MISMA clave en los dos:
// res/values/strings.xml → <string name="saludo">Hola</string> (el idioma por defecto)
// res/values-en/strings.xml → <string name="saludo">Hello</string> (el inglés)
// 2. Android genera una clase llamada R, con un número por cada clave.
// R.string.saludo NO es el texto: es un número que lo identifica.
@Composable
fun Saludo() {
// 3. stringResource cambia ese número por el texto del idioma del teléfono.
// Es una función @Composable: lee el «contexto» de la pantalla (los datos del
// teléfono que la rodean, entre ellos el idioma), así que solo se puede
// llamar desde otro composable.
Text(stringResource(R.string.saludo))
}Tres cosas nuevas en ese ejemplo, que verás una y otra vez: la clase R (la genera Android a partir de la carpeta res; en este proyecto se importa de com.sebastiannarvaez.pokedex.android.R), la función stringResource y los huecos de los textos con datos. Un hueco es un trozo del texto que se rellena al pedirlo: %d es un hueco para un número entero y %1$s el hueco del primer texto (%2$s, el del segundo). Con <string name="saludo_a">Hola, %1$s</string>, stringResource(R.string.saludo_a, "Ash") da «Hola, Ash».
Paso 1 · Los textos, en recursos
Los calificadores. Las carpetas de res/ pueden llevar un sufijo que dice cuándo se usan: values-en/ para inglés, values-night/ para el tema oscuro. Android elige la carpeta que mejor encaja con el dispositivo y, si no hay ninguna, cae a la que no tiene sufijo. Aquí values/ (sin sufijo) es el español, el idioma del proyecto, y values-en/ es el inglés.
Los dos ficheros son largos y no enseñan nada nuevo línea a línea: son una entrada por texto de la app, todos los de las dos lecciones. Por eso muchas claves no las usa nada todavía: las irás estrenando en esta lección y en la siguiente. Cópialos:
(Ya existe un strings.xml con app_name: se sustituye entero.) Para el inglés, la carpeta no existe: clic derecho sobre androidApp → New → File y escribe src/main/res/values-en/strings.xml. En la vista Project quedan en dos carpetas contiguas, res/values/ y res/values-en/, con el mismo nombre de fichero.
Fíjate en tres entradas, las que enseñan algo. La primera es el nombre de la app:
<string name="app_name" translatable="false">Pokédex</string>translatable="false" le dice a Android Studio que este texto no se traduce a propósito: la app se llama Pokédex en todos los idiomas. Por eso app_name está solo en values/ y no en values-en/. Sin esa marca, la revisión automática de Android (lint) la daría por olvidada.
La segunda es el plural:
<plurals name="favoritos_contados">
<item quantity="one">%d favorito</item>
<item quantity="other">%d favoritos</item>
</plurals>Es un plural de verdad, no un «(s)». El español y el inglés solo tienen one y other, así que parece innecesario; pero el polaco o el ruso tienen más formas (few, many) y el código no cambia el día que se traduzca. En el código se usa pluralStringResource, y hay una trampa:
El número va dos veces: una para elegir la forma y otra para rellenar el %d. Olvidar la segunda deja un «%d favoritos» en pantalla. La usarás en la lección siguiente.
La tercera es la que más se olvida: qué pasa si falta una clave en values-en/. Android no falla, no deja un hueco ni enseña el nombre de la clave: cae a values/ y enseña el texto en español en medio de una interfaz en inglés. Como red de seguridad es excelente (una traducción incompleta da una app mezclada, no una app rota); como control de calidad es malísimo, porque nadie se entera de que falta. Por eso el punto de control de esta lección compara las claves de los dos ficheros, y por eso app_name es la única ausencia deliberada.
Compila para comprobar que los recursos están bien formados (todavía nada los usa):
./gradlew :androidApp:assembleDebugTermina en BUILD SUCCESSFUL.
Paso 2 · Un texto traducible no es una propiedad normal
stringResource es una función @Composable, y eso tiene una consecuencia. Abre TypeColors.kt (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/ui/TypeColors.kt): el nombre de cada tipo está en una propiedad normal, PokemonType.etiqueta, que devuelve un String. (Que se escriba PokemonType.etiqueta quiere decir que es una propiedad que se le añade al tipo PokemonType desde fuera, sin tocar su código; se usa como tipo.etiqueta.)
Añade primero, junto a los demás imports de TypeColors.kt, los dos que necesita:
import androidx.annotation.StringRes
import com.sebastiannarvaez.pokedex.android.RProvócalo. Añade al final del fichero esta propiedad de prueba y compila:
val PokemonType.prueba: String
// el nombre largo evita añadir otro import solo para esta prueba
get() = androidx.compose.ui.res.stringResource(R.string.tipo_fire)El compilador responde:
e: Functions which invoke @Composable functions must be marked with the @Composable annotation
e: @Composable invocations can only happen from the context of a @Composable functionBorra la propiedad de prueba. La salida no es marcar la propiedad como @Composable. La propiedad devuelve el identificador del recurso (un Int, R.string.tipo_fire) y quien pinta el texto lo resuelve. Añade, al final del fichero, debajo de la propiedad etiqueta que ya existe (se queda por ahora, para que cada pantalla compile mientras la migras), la propiedad nueva. Su get() = when (this) { … } es la receta que se ejecuta cada vez que alguien la lee: según el tipo, devuelve uno u otro identificador.
/**
* El nombre del tipo, como identificador de recurso.
*
* Devuelve el identificador y no el texto porque `stringResource` es
* `@Composable`: quien pinta resuelve, esto solo dice cuál.
*/
@get:StringRes // el compilador comprueba que el Int sea un recurso de texto
val PokemonType.etiquetaRes: Int
get() = when (this) {
PokemonType.NORMAL -> R.string.tipo_normal
PokemonType.FIGHTING -> R.string.tipo_fighting
PokemonType.FLYING -> R.string.tipo_flying
PokemonType.POISON -> R.string.tipo_poison
PokemonType.GROUND -> R.string.tipo_ground
PokemonType.ROCK -> R.string.tipo_rock
PokemonType.BUG -> R.string.tipo_bug
PokemonType.GHOST -> R.string.tipo_ghost
PokemonType.STEEL -> R.string.tipo_steel
PokemonType.FIRE -> R.string.tipo_fire
PokemonType.WATER -> R.string.tipo_water
PokemonType.GRASS -> R.string.tipo_grass
PokemonType.ELECTRIC -> R.string.tipo_electric
PokemonType.PSYCHIC -> R.string.tipo_psychic
PokemonType.ICE -> R.string.tipo_ice
PokemonType.DRAGON -> R.string.tipo_dragon
PokemonType.DARK -> R.string.tipo_dark
PokemonType.FAIRY -> R.string.tipo_fairy
}Compila (./gradlew :androidApp:assembleDebug). Desde ahora, donde se pintaba tipo.etiqueta se pinta stringResource(tipo.etiquetaRes). El mismo patrón sirve para el nombre de cada estadística y para el tema de Ajustes, en la lección siguiente.
Paso 3 · La tarjeta: una frase no se traduce por trozos
Abre PokemonCard.kt (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCard.kt). Imports: añade com.sebastiannarvaez.pokedex.android.R y androidx.compose.ui.res.stringResource, y cambia etiqueta por etiquetaRes en el import de …ui.etiqueta.
El texto que lee TalkBack se armaba concatenando (", tipo ", " y "). Eso no se traduce: en español el tipo va detrás de «tipo» y en inglés va delante de «type». Una frase con huecos numerados sí puede reordenarse. La descripción ya está en los strings.xml que copiaste (%1$s y %2$s son los huecos 1 y 2):
<string name="tarjeta_descripcion">%1$s, tipo %2$s</string> <!-- values -->
<string name="tarjeta_descripcion">%1$s, %2$s type</string> <!-- values-en -->En el código se arma antes del Card(…): justo después de la línea val tono = … de PokemonCard, añade esto:
// Se arma ANTES del bloque `semantics`, que no es composable.
val nombre = pokemon.name.replaceFirstChar { it.uppercase() }
val separador = stringResource(R.string.separador_tipos)
// Los textos se resuelven FUERA de `joinToString`: dentro de su lambda
// no se puede llamar a nada `@Composable` (el aviso de abajo lo explica).
val etiquetas = pokemon.types.map { stringResource(it.etiquetaRes) }
val descripcion = if (etiquetas.isEmpty()) {
nombre
} else {
stringResource(R.string.tarjeta_descripcion, nombre, etiquetas.joinToString(separador))
}Y el .semantics { … } de la tarjeta se reduce:
// antes
.semantics {
contentDescription = buildString {
append(pokemon.name.replaceFirstChar { it.uppercase() })
if (pokemon.types.isNotEmpty()) {
append(", tipo ")
append(pokemon.types.joinToString(" y ") { it.etiqueta })
}
}
},
// después
.semantics { contentDescription = descripcion },Quedan tres textos sueltos en el mismo fichero. Cambia estos tres, sin más:
| Antes | Después |
|---|---|
text = "N.º ${pokemon.id.toString().padStart(4, '0')}" | text = stringResource(R.string.numero_pokemon, pokemon.id) |
contentDescription = if (esFavorito) "Quitar de favoritos" else "Añadir a favoritos" | contentDescription = if (esFavorito) stringResource(R.string.quitar_de_favoritos) else stringResource(R.string.anadir_a_favoritos) |
TypeChip(tipo.etiqueta, tipo.color) | TypeChip(stringResource(tipo.etiquetaRes), tipo.color) |
(El formato del número también es un recurso: N.º %04d en español y No. %04d en inglés; %04d es un número entero con ceros delante hasta llegar a cuatro cifras.) Compila: las demás pantallas siguen con tipo.etiqueta, y compila porque las dos propiedades existen a la vez.
Paso 4 · Los tests dicen en qué idioma miran
Los tests de Compose de Pruebas de interfaz en Android buscan textos («Entrar», «Ajustes»…), y en la lección siguiente esos textos saldrán de los recursos. Robolectric (el programa que simula un móvil dentro de los tests) no mira el idioma de tu ordenador: si no se le dice otra cosa, simula un teléfono en inglés de Estados Unidos. Cuando existe values-en/, los tests buscarían «Entrar» y encontrarían «Sign in». No es que los tests estén mal: es que no dicen en qué idioma miran. Fíjalo ya. Abre robolectric.properties (androidApp/src/test/resources/robolectric.properties, el que creaste en esa lección) y añade al final:
# El idioma con el que corren los tests: el del proyecto.
# Para comprobar la traducción, un test concreto lo cambia con
# `@Config(qualifiers = "en")`.
qualifiers=es(qualifiers=es es el mismo calificador que el sufijo de una carpeta, pero dicho al simulador. No hay values-es/: el español está en values/, y Android cae allí cuando el idioma pedido es el español y no existe una carpeta propia.)
Ahora un test nuevo que mire los dos idiomas con la descripción de la tarjeta. Clic derecho sobre androidApp → New → File (el método de Pruebas de interfaz en Android) y la ruta src/test/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCardIdiomaTest.kt:
package com.sebastiannarvaez.pokedex.android.list
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToString
import com.sebastiannarvaez.pokedex.domain.Pokemon
import com.sebastiannarvaez.pokedex.domain.PokemonType
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
class PokemonCardIdiomaTest {
@get:Rule
val compose = createComposeRule()
private fun pintarBulbasaur() = compose.setContent {
PokemonCard(
pokemon = Pokemon(id = 1, name = "bulbasaur", types = listOf(PokemonType.GRASS, PokemonType.POISON)),
esFavorito = false,
alPulsar = {},
alMarcar = {},
)
}
// Sin anotación vale `qualifiers=es` del fichero de propiedades.
@Test
fun `en espanol el tipo va detras de la palabra tipo`() {
pintarBulbasaur()
// Para ver qué hay de verdad en pantalla, imprime el árbol de semántica.
println(compose.onRoot().printToString())
compose.onNodeWithContentDescription("Bulbasaur, tipo Planta y Veneno").assertExists()
}
// La anotación manda sobre el fichero, solo para este test.
@Config(qualifiers = "en")
@Test
fun `en ingles el tipo va delante de la palabra type`() {
pintarBulbasaur()
compose.onNodeWithContentDescription("Bulbasaur, Grass and Poison type").assertExists()
}
}./gradlew :androidApp:testDebugUnitTestPasan los dos. Ahora, provócalo: el separador de la lista de tipos es " y ", con un espacio a cada lado, y en el fichero que copiaste va entre comillas. Quítaselas en los dos ficheros (values y values-en):
<string name="separador_tipos"> y </string> <string name="separador_tipos"> and </string>Vuelve a lanzar ./gradlew :androidApp:testDebugUnitTest. Fallan los dos. El mensaje solo dice que no encuentra ningún nodo con esa descripción, pero el println del primero imprime el árbol (sale en la salida del test: abre el informe androidApp/build/reports/tests/testDebugUnitTest/index.html con doble clic en Finder, entra en la clase del test, y en el test que falla busca «Standard output»; busca ahí «ContentDescription»):
ContentDescription = '[Bulbasaur, tipo PlantayVeneno]'«PlantayVeneno», sin espacios. Android recorta los espacios del principio y del final del texto de un strings.xml. Para conservarlos, el texto va entre comillas. Devuélveselas a los dos:
<string name="separador_tipos">" y "</string> <string name="separador_tipos">" and "</string>Vuelve a lanzar los tests: pasan los dos. Un test que mira los dos idiomas encontró un fallo que a simple vista no se ve, porque en pantalla el separador no se usa: solo lo lee TalkBack. Deja el println: es la forma de ver el árbol la próxima vez que un texto no aparezca.
Con estos dos, androidApp suma 16 tests: los 9 de la pantalla de entrada, los 4 de la lista, el de la fila deslizable de Accesibilidad: el lector de pantalla y estos dos.
Punto de control
values/yvalues-en/tienen las mismas claves, salvoapp_name, que solo está en el primero. Se comprueba en la Terminal, desde la carpeta del proyecto, condiff <(grep -o 'name="[^"]*"' androidApp/src/main/res/values/strings.xml | sort) <(grep -o 'name="[^"]*"' androidApp/src/main/res/values-en/strings.xml | sort). Cada pieza:grep -o 'name="[^"]*"'saca de un fichero solo los trozosname="…"(uno por clave),| sortlos ordena,<( … )hace quedifftrate esa lista como si fuera un fichero, ydiffimprime las líneas que no están en las dos listas. Debe salir una sola:< name="app_name"(el<dice que solo está en la primera lista)../gradlew :androidApp:testDebugUnitTesttermina enBUILD SUCCESSFUL. Con todo en verde Gradle no cuenta los tests: ábrelos con doble clic enandroidApp/build/reports/tests/testDebugUnitTest/index.html(en Finder) y verás 16, los dos dePokemonCardIdiomaTestincluidos.- Las tres tareas del núcleo, sin filtro (
:shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test), siguen en 129, 91 y 94 tests: esta lección no tocashared. - El separador de los dos ficheros lleva comillas (
" y "," and "). - Todavía quedan pantallas con sus textos escritos a mano (los de Favoritos, Ajustes, la ficha…): son el trabajo de la lección siguiente.
Comprueba lo que entendiste
7 preguntas · necesitas 5 para completar la lección
1.
pluralStringResource(R.plurals.favoritos_contados, n, n)lleva el número dos veces. ¿Por qué?2.¿Por qué las etiquetas de tipo devuelven un identificador de recurso y no la cadena?
3.¿Por qué la descripción accesible de la tarjeta se arma con un recurso de dos huecos y no concatenando?
4.Un
stringResourcedentro de la lambda dejoinToStringno compila, pero dentro de la demapsí. ¿Por qué?5.Un separador
<string name="sep"> y </string>enstrings.xmltermina pintando «PlantayVeneno». ¿Por qué, y cómo se arregla?6.Falta una clave en
values-en/strings.xml. ¿Qué enseña la app en inglés?7.
app_nameestá envalues/strings.xmlcontranslatable="false"y no existe envalues-en/. ¿Qué quiere decir esa marca?