Los ajustes mandan en toda la app, en Android
Al terminar: Aplicar el tema desde la raíz y mostrar el número de favoritos en la pestaña con BadgedBox.
La pantalla de ajustes guarda lo que eliges, pero la app no hace caso: elegir «Oscuro» no oscurece nada. Al terminar esta lección el tema mandará sobre toda la app de Android, incluida la barra inferior, y la pestaña Favoritos tendrá un globito con el número de favoritos.
Las dos cosas son la misma idea: un ajuste se lee donde afecta, no donde se cambia. El tema se cambia en la pantalla de ajustes, pero se lee en la raíz de la app, que es lo único que alcanza a todas las pantallas. El número de favoritos se lee en la barra, aunque lo cambie la lista.
El concepto · El tema se lee en la raíz
MaterialTheme aporta colores a lo que tiene debajo, y lo hace con un CompositionLocal: un valor que un composable deja disponible a todo el árbol que cuelga de él, sin pasarlo como parámetro, y que cualquiera de ahí abajo lee con .current (como MaterialTheme.colorScheme).
Si el tema se leyera dentro de SettingsScreen, cambiarlo solo repintaría esa pantalla: la lista, los favoritos y la barra seguirían claros, y cada pantalla nueva tendría que leer la preferencia por su cuenta. Por eso se lee donde alcanza a todo: en la raíz.
El tema es estado de toda la app: hay un solo dueño, el almacén de DataStore que Koin crea una sola vez (single), y dos lectores. La raíz lo lee para pintar y la pantalla de ajustes lo lee y lo cambia. Por eso habrá dos SettingsViewModel (uno en MainActivity, otro en la pantalla) y no pasa nada: los dos miran el mismo almacén.
Tres estados (SISTEMA, CLARO, OSCURO) hay que reducirlos a una pregunta de sí o no para elegir los colores: ¿oscuro o claro? Solo en SISTEMA hace falta preguntarle al teléfono. Un ejemplo ajeno al proyecto, con un modo de «día y noche»:
enum class Modo { SISTEMA, DIA, NOCHE }
// Los tres modos se reducen a una pregunta de sí o no: ¿es de noche?
// Solo en SISTEMA se pregunta a quien sabe la hora real (el parámetro).
fun esDeNoche(modo: Modo, sistemaEsDeNoche: Boolean): Boolean = when (modo) {
Modo.SISTEMA -> sistemaEsDeNoche // manda el sistema
Modo.DIA -> false // manda quien elige: siempre claro
Modo.NOCHE -> true // manda quien elige: siempre oscuro
}
fun main() {
println(esDeNoche(Modo.SISTEMA, sistemaEsDeNoche = true))
println(esDeNoche(Modo.DIA, sistemaEsDeNoche = true))
println(esDeNoche(Modo.NOCHE, sistemaEsDeNoche = false))
}Lo que imprime:
true
false
truePégalo en el Playground de Kotlin (play.kotlinlang.org) y ejecútalo: no usa nada de Android. Es exactamente lo que hará PokedexTheme, con isSystemInDarkTheme() en el lugar del parámetro.
Paso 1 · PokedexTheme recibe el tema
Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/ui/PokedexTheme.kt, el que escribiste en La lista en Android. Hoy recibe un booleano, oscuro. Pasa a recibir el Tema de tres estados y lo reduce al booleano que el resto de la función ya usa. Añade el import com.sebastiannarvaez.pokedex.feature.settings.Tema junto a los demás import del fichero y sustituye la cabecera de la función. Antes:
@Composable
fun PokedexTheme(
oscuro: Boolean = isSystemInDarkTheme(),
contenido: @Composable () -> Unit,
) {
val contexto = LocalContext.currentDespués (la parte de en medio, la que elige el esquema con val esquema = when {, no cambia):
@Composable
fun PokedexTheme(
tema: Tema = Tema.SISTEMA,
contenido: @Composable () -> Unit,
) {
// Los tres temas se reducen a «¿oscuro o claro?».
val oscuro = when (tema) {
Tema.SISTEMA -> isSystemInDarkTheme() // solo aquí se pregunta al sistema
Tema.CLARO -> false
Tema.OSCURO -> true
}
val contexto = LocalContext.current // ya estaba: el «contexto» es lo que Android sabe de tu app, y el color dinámico lo pideisSystemInDarkTheme() no desaparece: pasa a consultarse solo cuando quien usa la app ha dicho que decida el sistema.
Queda un detalle que solo se ve al probar: con «Oscuro» elegido y el móvil en modo claro, los iconos de la barra de arriba (la hora, la batería) salen oscuros sobre fondo oscuro, porque Android los colorea según el modo del móvil y no según el de tu app. Se corrige en el mismo fichero, justo antes de la última línea (MaterialTheme(colorScheme = esquema, content = contenido)). Primero los import nuevos, junto a los que ya tiene:
import android.graphics.Color.TRANSPARENT
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.LocalActivity
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.DisposableEffectY el bloque, encima de MaterialTheme(…):
// Los iconos de las barras del sistema (la hora, la batería) siguen al tema
// de la app y no al del móvil. Sin esto, con «Oscuro» y el móvil en claro,
// salen oscuros sobre fondo oscuro. En una vista previa no hay actividad.
val actividad = LocalActivity.current as? ComponentActivity
DisposableEffect(actividad, oscuro) {
actividad?.enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(TRANSPARENT, TRANSPARENT) { oscuro },
navigationBarStyle = SystemBarStyle.auto(TRANSPARENT, TRANSPARENT) { oscuro },
)
onDispose {}
}Qué hace, línea a línea: LocalActivity.current es la pantalla de Android en la que está dibujada la app (as? ComponentActivity la convierte al tipo que sabe de barras, y da null en una vista previa, donde no hay ninguna). DisposableEffect(actividad, oscuro) es un trozo de código que Compose ejecuta cada vez que cambia la actividad o el valor de oscuro, y onDispose {} es lo que haría al irse (aquí, nada). enableEdgeToEdge ya la llamó MainActivity al arrancar; volver a llamarla con SystemBarStyle.auto(TRANSPARENT, TRANSPARENT) { oscuro } le dice «barras transparentes, y los iconos claros si oscuro es verdadero y oscuros si no». Las dos barras, la de arriba y la de abajo.
Un aviso: el parámetro ya no se llama oscuro, se llama tema. Si alguna vista previa (@Preview) o llamada tuya escribía PokedexTheme(oscuro = true), saldrá en rojo: cámbiala a PokedexTheme(tema = Tema.OSCURO) (y añade el import de Tema en ese fichero) o bórrale el argumento.
Paso 2 · MainActivity lee el tema
MainActivity es la raíz: es la que pone PokedexTheme por encima de todo. Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/MainActivity.kt y añade estos import, junto a los que ya tiene:
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sebastiannarvaez.pokedex.feature.settings.SettingsViewModel
import org.koin.compose.viewmodel.koinViewModelDespués sustituye el PokedexTheme { PokedexApp() } de dentro de setContent:
// El tema se lee AQUÍ, en la raíz, y no dentro de la pantalla de ajustes.
val settingsVm: SettingsViewModel = koinViewModel()
val preferencias by settingsVm.settings.collectAsStateWithLifecycle()
PokedexTheme(tema = preferencias.tema) {
PokedexApp()
}koinViewModel() y collectAsStateWithLifecycle() son los mismos del paso 2 de Ajustes en Android: el primero pide el ViewModel a Koin y el segundo convierte el flujo en estado de Compose. Aquí el estado es un Settings entero y preferencias.tema es la parte que le toca al tema. El import de getValue y la palabra by van juntos: by te deja usar preferencias directamente como un Settings, sin escribir .value cada vez, y el import es lo que le enseña a Compose a leerlo así.
Ejecútala. Ajustes → Tema → Oscuro: toda la app se oscurece, incluida la barra inferior y la lista. Con «Lo que diga el sistema», el tema de Android manda: cambia el modo oscuro del sistema y la app lo sigue sin reabrirla. En el emulador se cambia desde los ajustes rápidos (desliza dos veces hacia abajo desde arriba y pulsa «Tema oscuro») o en Ajustes → Pantalla → Tema oscuro; o desde la Terminal, con adb shell cmd uimode night yes (oscuro) y adb shell cmd uimode night no (claro). Cierra la app y ábrela: el tema elegido sigue puesto.
Paso 3 · La insignia de Favoritos en la barra
El concepto. Una insignia es el globito con un número que sale sobre un icono. En Material se hace con BadgedBox: dibuja el icono y, encima, lo que le pongas en el parámetro badge (un Badge con el texto). La decisión de qué texto ya la tomó el código común en Ajustes y favoritos para toda la app: FavoritesViewModel.insignia es un String? que vale null sin favoritos (no hay globo), el número hasta 99 y «99+» desde 100. Aquí solo se pinta.
El número sale del mismo repositorio que la lista de favoritos, así que pestaña y pantalla nunca discrepan. Es el segundo ejemplo de estado de toda la app: un dueño (el repositorio de favoritos, single en Koin) y varios lectores (la rejilla, la pantalla Favoritos y, ahora, la barra).
Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt. Añade los import nuevos junto a los demás import del fichero (los Icons y Text ya están; getValue y collectAsStateWithLifecycle son los mismos del paso anterior, para el by y para convertir el flujo en estado de Compose):
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sebastiannarvaez.pokedex.feature.favorites.FavoritesViewModel
import org.koin.compose.viewmodel.koinViewModelDebajo de val actual = pila.lastOrNull(), dentro de PokedexApp, pide el ViewModel y recoge su insignia:
// El número de la pestaña: otro lector del mismo repositorio de favoritos.
val favoritos: FavoritesViewModel = koinViewModel()
val insignia by favoritos.insignia.collectAsStateWithLifecycle()Y cambia el icon del NavigationBarItem de Favoritos. Antes tenía solo el corazón:
icon = { Icon(Icons.Default.Favorite, contentDescription = null) },Ahora es esto (sustituye esa línea):
icon = {
// BadgedBox pone el globo encima del icono; sin favoritos, sin globo.
BadgedBox(badge = { insignia?.let { Badge { Text(it) } } }) {
Icon(Icons.Default.Favorite, contentDescription = null)
}
},Línea a línea: insignia?.let { … } ejecuta el bloque solo si insignia no es null, así que sin favoritos no se dibuja el Badge; con texto, Badge { Text(it) } pinta el globo y dentro el texto (it es el valor que llegó, por ejemplo «3»). El Icon es el corazón que ya tenía la pestaña.
Este FavoritesViewModel es otra instancia distinta de la de la pantalla Favoritos. La razón: koinViewModel() guarda el ViewModel que crea en el dueño más cercano a quien lo pide. PokedexApp cuelga de la actividad, así que el de la barra es de la actividad; la pantalla Favoritos cuelga de su propia entrada de la pila, así que tiene el suyo. Cada dueño guarda su copia. No es un problema, y es la misma idea que los dos SettingsViewModel: los dos leen el mismo repositorio, que es uno solo.
Paso 4 · Comprueba
Ejecuta la app.
- Ajustes → Tema → Oscuro: toda la app se oscurece (lista, favoritos, barra). Con «Lo que diga el sistema», sigue al modo oscuro de Android.
- Marca tres Pokémon con el corazón (por ejemplo el 4, el 1 y el 2, en ese orden). El globo de la pestaña Favoritos cuenta todos tus favoritos: si no tenías ninguno antes, muestra un 3; si ya tenías, tu cifra de antes más 3. Si desmarcas uno, baja en 1; si quitas todos, el globo desaparece.
- En Favoritos salen del último marcado al primero, y los que ya tenías quedan debajo: primero 2, 1, 4. Ve a Ajustes, activa «Ordenar favoritos por número» y vuelve a Favoritos: ahora todos van del número menor al mayor (el 1, el 2 y el 4 en ese orden), al momento. Ese orden lo calcula el ViewModel que dejaste preparado en Ajustes y favoritos para toda la app.
- Cierra la app del todo y ábrela: el tema y el orden siguen como los dejaste.
Para dejar el proyecto verificado, en la Terminal, en la carpeta de tu proyecto, lanza los tests de las tres plataformas (cada comando cuenta los de una): ./gradlew :shared:jvmTest (en la JVM, tu ordenador), :shared:testAndroidHostTest (Android host: los de Android que también corren en tu ordenador) y :shared:iosSimulatorArm64Test (iOS). Esta lección no añade ninguno: las cifras siguen siendo 76, 40 y 43, como al final de la lección anterior.
Resumen: el tema se lee en la raíz porque ahí lo alcanza todo; el número de favoritos se lee en la barra porque es ahí donde se pinta; y en los dos casos el dueño del dato es uno solo y los demás solo lo leen.
Punto de control
- Elegir «Oscuro» repinta la app entera, incluida la barra y la lista, no solo la pantalla de ajustes.
- Con «Lo que diga el sistema», cambiar el tema de Android cambia el de la app sin reabrirla.
- La pestaña Favoritos muestra el número de favoritos y sin favoritos no muestra globo.
- Activar «Ordenar favoritos por número» reordena la lista de favoritos al momento.
- El tema y el orden elegidos siguen puestos después de cerrar y abrir la app.
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFULcon 76 tests en la JVM, 40 en Android host y 43 en iOS (esta lección no añade ninguno).
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Por qué el tema se lee en
MainActivityy no dentro deSettingsScreen?2.Con el tema en
SISTEMA, ¿qué papel le queda aisSystemInDarkTheme()?3.La raíz y la pantalla de ajustes tienen cada una su
SettingsViewModel. ¿Es un problema?4.Hay 0 favoritos. ¿Qué se ve sobre el icono de la pestaña Favoritos?
5.¿Por qué el
FavoritesViewModelde la barra y el de la pantalla Favoritos no se contradicen, si son dos instancias?