Saltar al contenido
K
CalidadPaso a paso30 min Intermedio

Dos idiomas en Android: todas las pantallas

Al terminar: Traducir el resto de las pantallas de Android y comprobar cada idioma con tests.

En Dos idiomas en Android: los recursos copiaste los dos strings.xml, aprendiste a pedir un texto con stringResource y dejaste traducida la tarjeta de la lista. Quedan todas las demás pantallas: Favoritos, las pestañas, la notificación, la búsqueda, la lista, la ficha, Ajustes y la entrada. Al terminar, ninguna pantalla de Android escribe ya un texto a mano, y la app entera cambia de idioma con el teléfono.

No hay nada nuevo que aprender en la mayoría de los cambios: es el mismo gesto repetido, fichero a fichero y compilando cada vez. Hay tres sitios con truco, y son los que merecen atención: el aviso de «Deshacer» de Favoritos (un texto que se pide dentro de una corrutina), la pestaña con la insignia (un texto que va en el lector de pantalla) y la pantalla de entrada (funciones que antes no eran composables).

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android
favorites/FavoritesScreen.ktplural, acción y aviso (paso 1)
navigation/PokedexApp.ktpestañas e insignia (paso 2)
daily/Notificaciones.ktpaso 2
search/PokemonSearchBar.ktpaso 3
list/PokemonListScreen.ktpaso 3
detail/PokemonDetailScreen.ktpaso 4
settings/SettingsScreen.ktpaso 4
auth/AuthScreen.kta mano (paso 5)
auth/CuentaHoja.kt, auth/NuevaPasswordDialog.ktse copian (paso 5)
ui/TypeColors.ktse borra lo viejo (paso 6)
androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android
navigation/InsigniaFavoritosTest.ktnuevo (paso 2)
Qué cambia en esta lección (rutas de la vista Project).

En todos los ficheros, los imports nuevos van junto a los demás (el orden no importa). Si al compilar sale «Conflicting import: imported name 'R' is ambiguous», el fichero ya importaba R: borra la línea repetida.

Paso 1 · Favoritos: el plural, la acción y el aviso

Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt. Imports nuevos: com.sebastiannarvaez.pokedex.android.R, androidx.compose.ui.res.pluralStringResource y androidx.compose.ui.res.stringResource; y etiqueta pasa a etiquetaRes en el import de …android.ui.

El título, con el contador. El título de la barra superior pasa de un Text a un Column con el número de favoritos debajo. En TopAppBar(, sustituye la línea title = { Text("Favoritos", fontWeight = FontWeight.Bold) }, por:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt: el título, con el contador
                title = {
                    Column {
                        Text(stringResource(R.string.pestana_favoritos), fontWeight = FontWeight.Bold)
                        if (estado.favoritos.isNotEmpty()) {
                            // El número va DOS veces: una elige la forma del
                            // plural y otra rellena el %d.
                            Text(
                                pluralStringResource(
                                    R.plurals.favoritos_contados,
                                    estado.favoritos.size,
                                    estado.favoritos.size,
                                ),
                                style = MaterialTheme.typography.labelMedium,
                                color = MaterialTheme.colorScheme.onSurfaceVariant,
                            )
                        }
                    }
                },

La acción de accesibilidad. La acción «Quitar de favoritos» de Accesibilidad: el lector de pantalla tiene el texto escrito a mano. El texto no se puede pedir donde está: semantics { } no es composable, así que hay que resolverlo antes del bloque. En FilaDeslizable, justo antes de SwipeToDismissBox(:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt: la etiqueta, resuelta antes del bloque
    // `semantics { }` no es composable y `stringResource` sí: se resuelve fuera.
    val etiquetaQuitar = stringResource(R.string.quitar_de_favoritos)

y en la llamada a Fila(…), CustomAccessibilityAction("Quitar de favoritos") { pasa a CustomAccessibilityAction(etiquetaQuitar) {. El test FilaDeslizableTest sigue pasando: Robolectric corre en español, y en español la etiqueta sigue siendo «Quitar de favoritos».

El aviso de «Deshacer». Al deslizar una fila, abajo sale «Bulbasaur quitado de favoritos» con el botón «Deshacer» (Deslizar y deshacer en Android). Esos dos textos están escritos a mano dentro de un LaunchedEffect, y ahí hay otra trampa del mismo tipo que las de antes.

Dentro del bloque de un LaunchedEffect no hay contexto de composable, así que stringResource no compila ahí (el mismo error del paso 2 de la lección anterior). La salida es la que ya conoces, resolver antes. Los textos se piden fuera del efecto y el efecto solo los usa. Sustituye el principio del LaunchedEffect:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt: el aviso de deshacer, antes → después
// antes
    LaunchedEffect(estado.quitado) {
        val quitado = estado.quitado ?: return@LaunchedEffect
        val resultado = avisos.showSnackbar(
            message = "${quitado.name.replaceFirstChar { it.uppercase() }} quitado de favoritos",
            actionLabel = "Deshacer",
 
// después
    // `LaunchedEffect` lanza una corrutina, y dentro no hay contexto
    // @Composable: los textos se resuelven ANTES, fuera del bloque.
    val quitado = estado.quitado
    val mensaje = quitado?.let {
        // `c -> …` es el nombre que se da a la letra que recibe la lambda
        // (antes era `it`, pero `it` ya es el Pokémon de `quitado?.let { … }`)
        stringResource(R.string.favorito_quitado, it.name.replaceFirstChar { c -> c.uppercase() })
    }
    val deshacer = stringResource(R.string.deshacer)
    LaunchedEffect(quitado) {
        if (quitado == null || mensaje == null) return@LaunchedEffect
        val resultado = avisos.showSnackbar(
            message = mensaje,
            actionLabel = deshacer,

(Lo que sigue en el bloque, la duración y el if (resultado == …), se queda igual.) La clave del efecto pasa de estado.quitado a quitado, la misma cosa en una variable. quitado?.let { … } pide el texto solo si hay algo quitado; sin nada quitado, mensaje es null. return@LaunchedEffect significa «sal de este bloque ya»: con nada quitado, el efecto termina sin mostrar el aviso.

El resto de textos de este fichero siguen el patrón de la tabla:

AntesDespués
Icon(Icons.Default.MoreVert, contentDescription = "Más opciones")… contentDescription = stringResource(R.string.mas_opciones))
"Favoritos" (título de la hoja inferior)stringResource(R.string.pestana_favoritos)
Text("Quitar todos", color = …)Text(stringResource(R.string.favoritos_quitar_todos), color = …)
"Los favoritos se guardan solo en este dispositivo."stringResource(R.string.favoritos_solo_local)
"N.º ${favorito.id.toString().padStart(4, '0')}"stringResource(R.string.numero_pokemon, favorito.id)
tipo.etiqueta, (en el Text)stringResource(tipo.etiquetaRes),
Text("Todavía no hay favoritos", …)Text(stringResource(R.string.favoritos_vacio), …)
"Toca el corazón de cualquier Pokémon para guardarlo aquí."stringResource(R.string.favoritos_vacio_detalle)

Compila (./gradlew :androidApp:assembleDebug).

Paso 2 · Las pestañas, la insignia y la notificación

En androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt, añade los imports R y stringResource, y cambia los tres label de la barra inferior (el resto del fichero se queda tal cual):

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt: las etiquetas de las pestañas, antes → después
// antes
label = { Text("Pokédex") },   label = { Text("Favoritos") },   label = { Text("Ajustes") },
 
// después
label = { Text(stringResource(R.string.pestana_pokedex)) },
label = { Text(stringResource(R.string.pestana_favoritos)) },
label = { Text(stringResource(R.string.pestana_ajustes)) },

La insignia, que TalkBack lee. En Accesibilidad: el lector de pantalla pusiste el estado de la pestaña como "$it favoritos", un texto escrito a mano. También se traduce, y como habla de una cantidad («1 favorito», «3 favoritos») es un plural, igual que el contador del título del paso 1. Ya está en los dos ficheros que copiaste como <plurals name="insignia_favoritos"> (%1$s favorito / %1$s favoritos; en inglés, %1$s favourite / %1$s favourites). Añade el import androidx.compose.ui.res.pluralStringResource a PokedexApp.kt y cambia la línea:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt: la insignia, antes → después
// antes
val estadoInsignia = insignia?.let { "$it favoritos" }
 
// después
// «99+» no es un número: toIntOrNull() da null y se cuenta como 100, en plural.
val estadoInsignia = insignia?.let {
    pluralStringResource(R.plurals.insignia_favoritos, it.toIntOrNull() ?: 100, it)
}

Va en el mismo sitio, dentro de NavigationBar { … } y fuera del bloque semantics { }: es otra vez resolver antes. Aquí el plural recibe dos cosas distintas (en el paso 1 era el mismo número dos veces): el número que elige la forma (1 → «favorito», cualquier otro → «favoritos») y el texto que se escribe en el hueco. La insignia es un texto («3», «99+»), por eso el hueco es %1$s y no %d. toIntOrNull() convierte el texto «3» en el número 3 y, si el texto no es un número («99+»), da null; el ?: 100 dice «entonces cuéntalo como 100», que pide la forma «favoritos».

Un test para la insignia. Este cambio no se ve en pantalla, solo lo oye quien usa TalkBack, así que lo fija un test. En la vista Project, clic derecho sobre la carpeta androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android (la de los tests, donde ya están auth y list) → New → File, y escribe navigation/InsigniaFavoritosTest.kt: la barra crea la carpeta navigation, que en los tests aún no existe.

androidApp/src/test/kotlin/com/sebastiannarvaez/pokedex/android/navigation/InsigniaFavoritosTest.kt
package com.sebastiannarvaez.pokedex.android.navigation
 
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.test.junit4.createComposeRule
import com.sebastiannarvaez.pokedex.android.R
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
 
@RunWith(RobolectricTestRunner::class)
class InsigniaFavoritosTest {
 
    @get:Rule
    val compose = createComposeRule()
 
    // Lo que TalkBack dice de la insignia: con uno, en singular.
    @Test
    fun `con un favorito se dice en singular`() {
        var uno = ""
        var tres = ""
        // pluralStringResource solo funciona dentro de Compose: por eso se pide aquí dentro.
        compose.setContent {
            uno = pluralStringResource(R.plurals.insignia_favoritos, 1, "1")
            tres = pluralStringResource(R.plurals.insignia_favoritos, 3, "3")
        }
 
        assertEquals("1 favorito", uno)
        assertEquals("3 favoritos", tres)
    }
}

Tiene la forma de los tests de Pruebas de interfaz en Android: Robolectric, la regla de Compose y un setContent. Por el qualifiers=es de la lección anterior, los textos salen en español.

La notificación. La del Pokémon del día (daily/Notificaciones.kt, en androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/Notificaciones.kt) no es una pantalla: no hay stringResource fuera de Compose. Se usa el Context, el objeto de Android que representa «la app en este teléfono» (de él salen los recursos y el idioma), que es lo que stringResource usa por dentro. El nombre del canal lo ve el usuario en los ajustes del sistema, así que también se traduce. Este fichero ya importa R (lo usa para el icono): no lo repitas. Cuatro literales cambian. Hay tres claves distintas para tres cosas distintas: dia_canal es el nombre del canal, dia_notificacion_titulo el título de la notificación y dia_titulo el de la fila de Ajustes (paso 4); aunque dia_titulo y dia_canal valgan lo mismo hoy, cada una dice para qué sirve.

AntesDespués
"Pokémon del día", (nombre del canal)context.getString(R.string.dia_canal),
description = "Un Pokémon distinto cada día"description = context.getString(R.string.dia_canal_descripcion)
.setContentTitle("El Pokémon de hoy").setContentTitle(context.getString(R.string.dia_notificacion_titulo))
.setContentText("Descubre quién es el número $id").setContentText(context.getString(R.string.dia_notificacion_texto, id))

Compila.

Paso 3 · La búsqueda y la lista

Dos ficheros con el mismo patrón. En los dos, imports R y stringResource. Compila al terminar los dos.

PokemonSearchBar.kt (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/search/PokemonSearchBar.kt):

AntesDespués
placeholder = { Text("Buscar Pokémon") },placeholder = { Text(stringResource(R.string.buscar_pokemon)) },
contentDescription = "Borrar la búsqueda")contentDescription = stringResource(R.string.borrar_busqueda))
Aviso("Escribe un nombre para empezar")Aviso(stringResource(R.string.busqueda_vacia))
Aviso("Ningún Pokémon se llama así")Aviso(stringResource(R.string.busqueda_sin_resultados))
"N.º ${ref.id.toString().padStart(4, '0')}",stringResource(R.string.numero_pokemon, ref.id),

PokemonListScreen.kt (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt). Solo estas seis líneas; el resto del fichero (incluido el estado de la rejilla que añadiste en La lista sin red en Android) no se toca. Las dos últimas son la franja que avisa de que se está enseñando la copia guardada:

AntesDespués
title = { Text("Pokédex", fontWeight = FontWeight.Bold) },title = { Text(stringResource(R.string.pestana_pokedex), fontWeight = FontWeight.Bold) },
titulo = "No hay nada que enseñar",titulo = stringResource(R.string.lista_vacia_titulo),
detalle = "PokeAPI no devolvió ningún Pokémon.",detalle = stringResource(R.string.lista_vacia_detalle),
{ Text("Reintentar") } (el botón del error){ Text(stringResource(R.string.reintentar)) }
"Sin conexión, mostrando lo guardado", (la franja)stringResource(R.string.lista_sin_conexion),
{ Text("Reintentar", fontWeight = FontWeight.Bold) } (el botón de la franja){ Text(stringResource(R.string.reintentar), fontWeight = FontWeight.Bold) }

Paso 4 · La ficha y los ajustes

PokemonDetailScreen.kt (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/detail/PokemonDetailScreen.kt). Imports: R, stringResource y androidx.annotation.StringRes; etiqueta pasa a etiquetaRes en el import de …android.ui.

AntesDespués
{ Text("Reintentar") }{ Text(stringResource(R.string.reintentar)) }
contentDescription = "Volver")contentDescription = stringResource(R.string.volver))
contentDescription = "Compartir")contentDescription = stringResource(R.string.compartir))
text = "N.º ${detalle.id.toString().padStart(4, '0')}",text = stringResource(R.string.numero_pokemon, detalle.id),
"Legendario",stringResource(R.string.ficha_legendario),
Text(tipo.etiqueta, style = …)Text(stringResource(tipo.etiquetaRes), style = …)
Medida("Altura", …Medida(stringResource(R.string.ficha_altura), …
Medida("Peso", …Medida(stringResource(R.string.ficha_peso), …
Text("Estadísticas base", …Text(stringResource(R.string.ficha_estadisticas), …
stat.kind.etiqueta,stringResource(stat.kind.etiquetaRes),

Y al final del fichero, la propiedad privada de las estadísticas hace lo mismo que la de los tipos. Sustitúyela entera:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/detail/PokemonDetailScreen.kt: antes → después
// antes
private val StatKind.etiqueta: String
    get() = when (this) {
        StatKind.HP -> "PS"
        StatKind.ATTACK -> "Ataque"
        StatKind.DEFENSE -> "Defensa"
        StatKind.SPECIAL_ATTACK -> "At. especial"
        StatKind.SPECIAL_DEFENSE -> "Def. especial"
        StatKind.SPEED -> "Velocidad"
    }
 
// después
@get:StringRes
private val StatKind.etiquetaRes: Int
    get() = when (this) {
        StatKind.HP -> R.string.stat_hp
        StatKind.ATTACK -> R.string.stat_attack
        StatKind.DEFENSE -> R.string.stat_defense
        StatKind.SPECIAL_ATTACK -> R.string.stat_special_attack
        StatKind.SPECIAL_DEFENSE -> R.string.stat_special_defense
        StatKind.SPEED -> R.string.stat_speed
    }

(«PS» son los puntos de salud, y el fichero de recursos ya lo trae así; en inglés es «HP».) Compila.

SettingsScreen.kt (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt). Imports: R, stringResource y androidx.annotation.StringRes. Trae una propiedad igual que PokemonType.etiquetaRes, esta vez para el tema: Tema.etiquetaRes. La fila del orden, que arreglaste en Accesibilidad: el lector de pantalla, solo cambia en sus textos; su modifier = Modifier.toggleable(…) no se toca. Los cambios:

AntesDespués
title = { Text("Ajustes", fontWeight = FontWeight.Bold) }title = { Text(stringResource(R.string.pestana_ajustes), fontWeight = FontWeight.Bold) }
titulo = "Tema", (la fila)titulo = stringResource(R.string.ajustes_tema),
detalle = ajustes.tema.etiqueta,detalle = stringResource(ajustes.tema.etiquetaRes),
titulo = "Ordenar favoritos por número",titulo = stringResource(R.string.ajustes_orden),
"Del 1 al último" y "Lo más reciente primero"stringResource(R.string.ajustes_orden_numero) y stringResource(R.string.ajustes_orden_reciente)
titulo = "Pokémon del día",titulo = stringResource(R.string.dia_titulo),
"Una notificación al día", "Toca para activar las notificaciones", "Las notificaciones están desactivadas"stringResource(R.string.dia_activo), stringResource(R.string.dia_pedir), stringResource(R.string.dia_denegado)
titulo = "Cuenta",titulo = stringResource(R.string.ajustes_cuenta),
activa.email ?: "Sesión iniciada"activa.email ?: stringResource(R.string.auth_sesion_iniciada)
titulo = "Acerca de",titulo = stringResource(R.string.ajustes_acerca_de),
detalle = "Pokédex · datos de PokeAPI",detalle = stringResource(R.string.ajustes_acerca_de_detalle),
"Tema", (título de la hoja inferior)stringResource(R.string.ajustes_tema),
Text(opcion.etiqueta)Text(stringResource(opcion.etiquetaRes))
"«Lo que diga el sistema» sigue el ajuste de Android.",stringResource(R.string.ajustes_tema_nota),

Y al final del fichero, la propiedad Tema.etiqueta se sustituye entera:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt: Tema.etiquetaRes, en lugar de Tema.etiqueta
@get:StringRes
val Tema.etiquetaRes: Int
    get() = when (this) {
        Tema.SISTEMA -> R.string.ajustes_tema_sistema
        Tema.CLARO -> R.string.ajustes_tema_claro
        Tema.OSCURO -> R.string.ajustes_tema_oscuro
    }

Compila.

Paso 5 · La entrada y la cuenta

AuthScreen.kt, a mano. Es el fichero que escribiste y ampliaste para los tests, así que no se copia: se cambian sus textos. Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt, añade los imports R y stringResource, y cambia estos literales (el resto del fichero, incluidas las etiquetas de prueba, no se toca):

AntesDespués
"Pokédex", (el título grande)stringResource(R.string.pestana_pokedex),
"Entra para guardar tus favoritos y recibir el Pokémon del día.",stringResource(R.string.auth_bienvenida),
{ Text("Entrar") } (el selector){ Text(stringResource(R.string.auth_entrar)) }
{ Text("Crear cuenta") } (el selector){ Text(stringResource(R.string.auth_crear_cuenta)) }
label = { Text("Correo") },label = { Text(stringResource(R.string.auth_correo)) },
label = { Text("Contraseña") },label = { Text(stringResource(R.string.auth_password)) },
Text(if (verPassword) "Ocultar" else "Ver")Text(if (verPassword) stringResource(R.string.auth_password_ocultar) else stringResource(R.string.auth_password_ver))
Text("Al menos ${AuthFormState.MINIMO_PASSWORD} caracteres") (la ayuda bajo la contraseña, dentro del else if (registrando))Text(stringResource(R.string.auth_password_minimo, AuthFormState.MINIMO_PASSWORD))
Text(if (registrando) "Crear cuenta" else "Entrar") (el botón)Text(if (registrando) stringResource(R.string.auth_crear_cuenta) else stringResource(R.string.auth_entrar))
Text("¿Olvidaste tu contraseña?")Text(stringResource(R.string.auth_olvidaste))
"Escribe tu correo arriba y te mandamos un enlace.",stringResource(R.string.auth_olvidaste_ayuda),
Text("Entendido")Text(stringResource(R.string.auth_entendido))
title = { Text("Revisa tu correo") },title = { Text(stringResource(R.string.auth_revisa_correo)) },
el texto de dos líneas que empieza por "Si hay una cuenta con ese correo…stringResource(R.string.auth_revisa_correo_detalle),

Al final del fichero están las dos funciones que traducen los errores del formulario a texto. Tienen la trampa de este paso: ahora llaman a stringResource, así que tienen que ser @Composable. Ojo con la diferencia respecto a etiquetaRes de la lección anterior: etiquetaRes es una propiedad, que no puede ser composable, y por eso devolvía un identificador; estas son funciones, que sí pueden marcarse @Composable, y las llaman las pantallas (que son composables). Sustitúyelas:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt: textoDe, antes → después
// antes
private fun textoDe(error: ErrorEmail): String = when (error) {
    ErrorEmail.VACIO -> "Escribe tu correo"
    ErrorEmail.NO_VALIDO -> "Escribe un correo completo, como nombre@correo.com"
}
 
private fun textoDe(error: ErrorPassword): String = when (error) {
    ErrorPassword.VACIA -> "Escribe tu contraseña"
    ErrorPassword.CORTA -> "Al menos ${AuthFormState.MINIMO_PASSWORD} caracteres"
}
 
// después
@Composable
private fun textoDe(error: ErrorEmail): String = when (error) {
    ErrorEmail.VACIO -> stringResource(R.string.auth_error_correo_vacio)
    ErrorEmail.NO_VALIDO -> stringResource(R.string.auth_error_correo_no_valido)
}
 
@Composable
private fun textoDe(error: ErrorPassword): String = when (error) {
    ErrorPassword.VACIA -> stringResource(R.string.auth_error_password_vacia)
    ErrorPassword.CORTA -> stringResource(R.string.auth_password_minimo, AuthFormState.MINIMO_PASSWORD)
}

(Si AuthScreen.kt ya tiene @Composable importado, no hay que añadir nada más; si no, el import es androidx.compose.runtime.Composable.) Compila.

Las otras dos, ya hechas. La hoja de la cuenta y el diálogo de la contraseña nueva solo cambian en esto, texto a recurso, con la misma forma que acabas de ver; no aportan nada nuevo y son largas, así que se copian. Si en alguna habías tocado algo por tu cuenta, ese cambio se pierde.

CuentaHoja.kt

NuevaPasswordDialog.kt

Paso 6 · Quita lo viejo

Ya nada pinta tipo.etiqueta. Borra la propiedad vieja de TypeColors.kt: la de PokemonType.etiqueta, con su comentario /** El nombre en castellano… */. Compila:

./gradlew :androidApp:assembleDebug

Termina en BUILD SUCCESSFUL. Si el compilador marca algún etiqueta sin resolver, es un sitio que aún pintaba tipo.etiqueta: cámbialo por stringResource(tipo.etiquetaRes).

Y la prueba de que no se te ha escapado ninguno, en la Terminal, desde la carpeta del proyecto. grep -rn busca un patrón en todos los ficheros de una carpeta y enseña fichero y línea; el patrón es Text(" seguido de una letra mayúscula (o ¿, ¡):

grep -rn 'Text("[A-ZÁÉÍÓÚ¿¡]' androidApp/src/main

No debe salir ninguna línea. Si sale alguna de un fichero que esta lección no recorrió (Raiz.kt, MainActivity.kt o una @Preview), es una vista previa o una prueba con texto de ejemplo: puedes dejarla. Si es de una pantalla, cámbiala con el patrón de siempre. (Un Text(" con una variable o con stringResource dentro no empieza por una letra mayúscula, y no coincide.)

Paso 7 · Comprobarlo en inglés sin tocar el sistema

Cambiar el idioma de todo el emulador es lento. Android 13 y posteriores permiten cambiar el de una sola app. Con la app instalada y el emulador arrancado, en la Terminal (si adb no se encuentra, usa su ruta completa, ~/Library/Android/sdk/platform-tools/adb; el programa se presentó en Ejecutar en Android):

# el idioma de una sola app
adb shell cmd locale set-app-locales com.sebastiannarvaez.pokedex --locales en-US
# Para volver al del sistema:
adb shell cmd locale set-app-locales com.sebastiannarvaez.pokedex --locales ""

(Si el identificador de tu app es otro, es el applicationId de androidApp/build.gradle.kts.) Abre la app y recórrela:

  • Las pestañas dicen «Favourites» y «Settings». Ajustes está entero en inglés.
  • La ficha de Bulbasaur dice «Height», «Weight», «Base stats», «HP» y «Attack».
  • Marca dos favoritos y entra en la pestaña: el contador dice «2 favourites»; con uno, «1 favourite». Desliza una fila: abajo sale «Bulbasaur removed from favourites · Undo», el aviso del paso 1.
  • Y la prueba de que el cambio llegó al módulo compartido y no solo a las etiquetas: la descripción de la ficha dice «A strange seed was planted on its back at birth». Ese texto viene de PokeAPI, no de ningún strings.xml.

El idioma por app también se cambia a mano en los ajustes del sistema, pero solo si la app declara qué idiomas ofrece, y esta no lo hace (no hace falta para que se traduzca): por eso se usa adb.

Lo que seguirás viendo en español son los textos que escribe el módulo compartido por su cuenta: los avisos de error de la red («Sin conexión», «La respuesta tardó demasiado»…) y el «No se pudo continuar» de la entrada. Son textos de shared, no de ninguna pantalla, y quedan fuera de esta lección.

Los tests y la revisión. Los tests de Compose buscan textos en español, y ahora salen de los recursos; el qualifiers=es de robolectric.properties (lección anterior) es lo que los mantiene verdes: sin él, Robolectric simularía un teléfono en inglés y no encontrarían «Entrar»:

./gradlew :androidApp:testDebugUnitTest :androidApp:lintDebug

Los tests pasan (17: los 16 de la lección anterior y el de la insignia) y la revisión de Android (lint) termina sin errores: sin translatable="false" en app_name, aquí saldría "app_name" is not translated in "en".

Punto de control

  • values/ y values-en/ tienen las mismas claves, salvo app_name (el diff de la lección anterior sigue sacando solo esa).
  • grep -rn 'Text("[A-ZÁÉÍÓÚ¿¡]' androidApp/src/main no saca ninguna línea: lo que se pinta pasa por stringResource.
  • ./gradlew :androidApp:testDebugUnitTest termina en BUILD SUCCESSFUL y el informe (androidApp/build/reports/tests/testDebugUnitTest/index.html) cuenta 17 (los 16 de antes y InsigniaFavoritosTest). Las tres tareas del núcleo, sin filtro, siguen en 129, 91 y 94.
  • ./gradlew :androidApp:lintDebug termina sin errores.
  • Con la app en inglés: pestañas «Favourites» y «Settings», «2 favourites» con dos marcados, el aviso «removed from favourites · Undo» al deslizar, y la descripción de la ficha en inglés.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Por qué el texto del aviso «quitado de favoritos» se pide con stringResource fuera del LaunchedEffect?

  2. 2.Las dos funciones textoDe de AuthScreen.kt pasan a ser @Composable. ¿Por qué?

  3. 3.La notificación del Pokémon del día usa context.getString(R.string.dia_notificacion_titulo) y no stringResource. ¿Por qué?

  4. 4.La insignia de Favoritos puede valer «99+». ¿Qué dice TalkBack con pluralStringResource(R.plurals.insignia_favoritos, it.toIntOrNull() ?: 100, it)?

  5. 5.Terminas la lección y grep -rn 'Text("[A-ZÁÉÍÓÚ¿¡]' androidApp/src/main no saca nada. ¿Qué demuestra?

  6. 6.Con la app en inglés, los avisos «Sin conexión» o «La respuesta tardó demasiado» siguen en español. ¿De dónde salen?

Faltan 6 de 6