La puerta y la cuenta en Android
Al terminar: Decidir desde la raíz qué ve el usuario según su estado de sesión.
Ya tienes el formulario de entrada escrito, pero nadie lo llama: la app sigue abriendo la lista sin pedir nada. Falta la puerta. Al terminar esta lección, abrir la app sin haber entrado no enseña la lista: enseña el formulario, y la Pokédex no aparece hasta que pasas. Dentro, los Ajustes tendrán una fila «Cuenta» para cerrar sesión.
La decisión de fondo cabe en una frase: el guardián va en la raíz, no dentro de la navegación. Los pasos son cuatro: el guardián, colocarlo alrededor de la app, la hoja de la cuenta y tres pruebas.
No se instala nada: Compose, Material 3 y Koin ya están en el proyecto. Y la puerta es opcional para quien no tenga Supabase: con los dos valores de Supabase vacíos en local.properties la app entra directa a la lista, como hasta ahora.
Paso 1 · El guardián, y por qué en la raíz
El archivo. Raiz.kt contiene un composable que envuelve la app entera y decide qué se dibuja. Es corto y es la decisión de la lección, así que lo escribes tú. Créalo junto a AuthScreen.kt (clic derecho sobre el nodo androidApp → New → File):
src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/Raiz.ktEl concepto. Hay tres estados y solo uno enseña la app: «todavía no sé quién eres» (leyendo el token), «no hay nadie» y «hay alguien». Ponerlo en la raíz y no dentro de la navegación tiene una consecuencia que se agradece: no existe ninguna ruta que alguien pueda alcanzar sin sesión, porque sin sesión la pila de pantallas ni siquiera se compone. No hay nada que proteger una por una, ni una lista de rutas públicas que se olvide de actualizar al añadir la siguiente. El precio es que al cerrar sesión se pierde la pila, y en una app como esta es lo correcto: nadie quiere volver y encontrarse la ficha que estaba mirando otro.
¿Y cuándo sería mejor el cerrojo por ruta? En una app con parte pública y parte privada: un periódico donde las noticias se leen sin cuenta y los comentarios no. Ahí el portero de la entrada cerraría la puerta a todo el mundo, y lo que hace falta es exactamente un cerrojo por puerta. La Pokédex no es ese caso: o entras, o no hay app. Por eso el guardián en la raíz es la respuesta aquí, y no siempre. Tampoco sería igual de bueno dentro de la navegación aunque se acordaran todos los cerrojos: un enlace profundo que apunta a una ficha crearía su entrada (cada pantalla de la pila de Navigation 3 es una entrada), montaría su ViewModel y lanzaría su carga antes de que nadie dijera que no hay sesión. Con el guardián en la raíz, ese ViewModel no llega a existir.
El fichero entero, con los comentarios línea a línea:
package com.sebastiannarvaez.pokedex.android.auth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sebastiannarvaez.pokedex.core.AppConfig
import com.sebastiannarvaez.pokedex.feature.auth.AuthViewModel
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
// El guardián de la app: decide qué se compone según quién eres.
// `contenido` es la app entera (la navegación); aquí solo se decide si se
// dibuja o no. `@Composable () -> Unit` es el tipo de una función que dibuja
// algo y no devuelve nada: se pasa entre llaves al usarla, como el contenido
// de una `Column { … }`.
@Composable
fun RaizConSesion(
contenido: @Composable () -> Unit,
) {
// `koinInject` pide una dependencia del grafo de Koin desde un composable.
val config: AppConfig = koinInject()
// Sin Supabase configurado no hay puerta que guardar: la app va directa.
// `return` sale de la función composable y no se dibuja nada más.
if (!config.haySupabase) {
contenido()
return
}
val viewModel: AuthViewModel = koinViewModel()
// Convierte el StateFlow en un estado de Compose: cada cambio redibuja.
val estado by viewModel.state.collectAsStateWithLifecycle()
// Tres estados, tres ramas, y solo una enseña la app.
when {
// Todavía leyendo el token guardado: ni formulario ni app.
estado.comprobando -> ComprobandoSesion()
// Hay sesión: ahora sí, la app.
estado.haySesion -> contenido()
// No hay nadie: el formulario que acabas de escribir.
else -> AuthScreen(viewModel = viewModel)
}
}ComprobandoSesion y AuthScreen ya existen: están en AuthScreen.kt, de El formulario de entrada en Android. Lo único nuevo de verdad es el guardián, y dos detalles de sintaxis: koinInject() es como koinViewModel() pero para cualquier dependencia (aquí, la configuración), y return dentro de un composable lo termina ahí, sin dibujar nada más.
El comprobando es la rama que se olvida y la que se nota. Provócalo tú: cuando termines la lección y tengas una sesión guardada, borra esa rama, ejecuta y abre la app: verás el formulario un instante antes de que aparezca la lista, porque mientras se lee el token haySesion todavía es false. Vuelve a ponerla.
Paso 2 · Colocarlo alrededor de la app
Colócalo. Falta envolver la app. Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/MainActivity.kt (Navigate → File… y escribe MainActivity). Dentro de setContent hay un PokedexTheme(tema = …) con PokedexApp(…) (la raíz de la navegación). Añade el import de RaizConSesion (o deja que Option+Enter lo añada cuando RaizConSesion salga en rojo) y envuelve esa llamada. Así es la llamada antes y después:
PokedexTheme(tema = preferencias.tema) {
PokedexApp(
destinoEntrante = destinoEntrante.value,
// Cuando la app usa el destino, lo apaga para no repetirlo.
alConsumirDestino = { destinoEntrante.value = null },
)
} PokedexTheme(tema = preferencias.tema) {
// El guardián envuelve la app entera. El enlace entrante se
// guarda en la actividad (destinoEntrante), no aquí dentro: un
// enlace que llega sin sesión no se pierde, espera a que la app
// se componga, que es después de entrar.
RaizConSesion {
PokedexApp(
destinoEntrante = destinoEntrante.value,
// Cuando la app usa el destino, lo apaga para no repetirlo.
alConsumirDestino = { destinoEntrante.value = null },
)
}
}El import nuevo, arriba del fichero:
import com.sebastiannarvaez.pokedex.android.auth.RaizConSesionEse comentario cuenta algo que sale gratis y es fácil de romper: como el enlace entrante vive en el estado de la actividad y no dentro de la navegación, un enlace que llega sin sesión no se pierde.
Paso 3 · La cuenta, en una hoja inferior
El archivo. CuentaHoja.kt es la hoja que sube desde abajo con el correo del usuario y el botón «Cerrar sesión». Cerrar sesión es una acción destructiva de las suaves: no borra nada, pero saca al usuario de la app. Una hoja modal (ModalBottomSheet, la de Ajustes en Android) la pone delante sin cambiar de pantalla y se cierra deslizando, que es lo que espera alguien que la abrió por curiosidad. Es solo maquetación (un círculo con un icono, dos textos y un botón), nada que no hayas escrito ya, así que se copia. Créalo (clic derecho sobre el nodo androidApp → New → File):
src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/CuentaHoja.ktPulsa Copiar fichero, selecciona todo en el fichero vacío (Cmd+A) y pega (Cmd+V); es el botón de Del JSON al Pokémon: DTO y repositorio.
Aunque lo copies, conviene saber cómo está hecho por dentro, porque dos lecciones después lo retocarás. Este es su esqueleto, con los trozos de maquetación sustituidos por …:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CuentaHoja(session: Session, alCerrar: () -> Unit, alSalir: () -> Unit) {
// La hoja que sube desde abajo. `onDismissRequest` es lo que pasa cuando el
// usuario la desliza o toca fuera: aquí, avisar a quien la abrió.
ModalBottomSheet(onDismissRequest = alCerrar, sheetState = rememberModalBottomSheetState()) {
Column(…) {
Row(…) { … } // el círculo con el icono y el correo (o «Tu cuenta»)
Text(…) // «Tus favoritos se guardan en este dispositivo…»
OutlinedButton(onClick = alSalir, …) { Text("Cerrar sesión") }
}
}
}Recibe la sesión para pintar el correo y dos funciones: alCerrar (la hoja se ha cerrado sin más) y alSalir (se ha pulsado «Cerrar sesión»). Ella no sabe cerrar sesión: avisa, y quien la usa decide, que es lo que haces en el paso siguiente con SettingsScreen.
La fila en Ajustes. androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt (de Ajustes en Android) necesita cinco añadidos, numerados en el bloque de abajo: los imports, un parámetro nuevo, la sesión y la variable de la hoja, la fila «Cuenta» y la propia hoja. Ábrelo y haz cada uno en su sitio (si algún nombre sale en rojo, Option+Enter sobre él ofrece el import):
// 1. Imports nuevos, arriba del fichero:
import androidx.compose.material.icons.filled.Person
import com.sebastiannarvaez.pokedex.android.auth.CuentaHoja
import com.sebastiannarvaez.pokedex.feature.auth.AuthViewModel
// 2. En la firma de SettingsScreen, debajo del parámetro `viewModel`:
// Un AuthViewModel propio de esta pantalla. Cada entrada de Navigation 3
// guarda sus ViewModel en un almacén suyo (la caja donde viven mientras la
// pantalla existe), distinto del de la actividad, que es el que usa la
// raíz. Por eso este no es la misma instancia que el de la raíz. Da
// igual: la sesión vive en el repositorio, que es único, y aquí solo se
// lee `state` y se llama a salir().
auth: AuthViewModel = koinViewModel(),
// 3. Dentro de la función, debajo de `val ajustes by ...`:
val sesion by auth.state.collectAsStateWithLifecycle()
// Si la hoja de la cuenta está abierta.
var hojaCuenta by remember { mutableStateOf(false) }// 4. La fila, justo encima de la Fila "Acerca de":
// `?.let` ejecuta el bloque solo si hay sesión: sin ella la fila no existe.
sesion.session?.let { activa ->
Fila(
titulo = "Cuenta",
detalle = activa.email ?: "Sesión iniciada",
icono = { Icon(Icons.Default.Person, contentDescription = null) },
alPulsar = { hojaCuenta = true },
)
}
// 5. La hoja, después del Scaffold y antes de `if (hojaTema) { ... }`:
// La hoja se pinta fuera del Scaffold, sobre la pantalla.
// `takeIf { hojaCuenta }` devuelve la sesión si `hojaCuenta` es true y
// `null` si no; el `?.let` de después solo ejecuta el bloque si no es
// `null`. Resultado: la hoja existe solo con sesión Y con la hoja pedida.
sesion.session?.takeIf { hojaCuenta }?.let { activa ->
CuentaHoja(
session = activa,
alCerrar = { hojaCuenta = false },
alSalir = {
hojaCuenta = false
auth.salir()
},
)
}Fíjate en el comentario del segundo añadido, porque dice lo importante: aquí hay un AuthViewModel distinto del de la raíz (dos instancias del mismo tipo, cada una en su almacén), y da igual. La sesión es estado de toda la app, como el partido en juego de Marcador o los ajustes y los favoritos de la Pokédex: vive en un solo sitio (SessionRepository, un single de Koin) y cada pantalla que la necesita la mira, en vez de pasársela de mano en mano. Los ViewModel son solo ventanas a ese dueño único.
Paso 4 · Pruébalo, tres veces
Esta lección no añade tests: siguen 117, 79 y 82 en la JVM, Android host e iOS, los de El formulario de entrada en Android. Lo que se prueba aquí es lo que se ve. Los dos valores de Supabase están en local.properties, en la raíz del proyecto (los de Configuración y secretos por entorno); cada vez que los cambies, ejecuta de nuevo y Gradle los vuelve a leer.
Primera prueba, sin cuenta. Deja vacíos los dos valores (si ya los habías rellenado, borra lo que va detrás del =) y ejecuta la app. Entra directa a la lista, como antes: haySupabase es false y el guardián se aparta.
Segunda prueba, con valores inventados. Pon SUPABASE_URL=https://abcdefgh.supabase.co y SUPABASE_KEY=clave-de-prueba (no son de nadie) y ejecuta. La app arranca en el formulario y no se puede llegar a la lista. Prueba el selector, escribe «ash» en el correo (sale el error rojo debajo), comprueba que el botón no se enciende hasta que el correo lleva arroba y punto y la contraseña seis caracteres, y abre el teclado: puedes desplazar hasta el botón, y la tecla «Hecho» también envía. Pulsa «Entrar» con datos válidos: sale el aviso «Sin conexión · Revisa tu red e inténtalo otra vez.», que es lo correcto para un servidor que no existe.
Tercera prueba, con tu cuenta de Supabase. Pon los dos valores reales de tu proyecto (los de Preparar Supabase) y ejecuta de nuevo. Crea una cuenta con «Crear cuenta»: pasas a la lista. Ahora cierra la app del todo: en el emulador, abre las apps recientes y desliza la Pokédex hacia arriba (o pulsa Stop en Android Studio), y ábrela otra vez desde el icono. Sigues dentro: la lista aparece sin pedirte nada, porque la sesión estaba guardada en sesion.xml y la leyó el restaurar() del ViewModel. Ve entonces a Ajustes, toca «Cuenta» y «Cerrar sesión»: vuelves al formulario. Y con la contraseña equivocada al entrar, el aviso rojo aparece encima del botón con lo que dice Supabase (Invalid login credentials), no con un «servidor caído».
Punto de control
- Sin los valores de Supabase en
local.properties, la app entra directa a la lista, como antes. - Con valores puestos (inventados o reales), la app arranca en el formulario y no se puede llegar a la lista.
- El botón «Entrar» está desactivado hasta que el correo lleva arroba y punto y la contraseña seis caracteres.
- Con el teclado abierto puedes desplazar hasta el botón, y la tecla «Hecho» también envía.
- Con una URL inventada, un intento de entrar pinta el aviso «Sin conexión» encima del botón, no un diálogo.
- Con tu cuenta: tras crearla, cerrar la app del todo y volver a abrirla, sigues dentro sin escribir nada.
- En Ajustes aparece la fila «Cuenta» y abre la hoja inferior; «Cerrar sesión» devuelve al formulario.
- Los tests siguen en 117, 79 y 82, y
./gradlew :androidApp:assembleDebugcompila.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.¿Qué se gana poniendo el guardián en la raíz en vez de en cada ruta?
2.Con
haySupabaseen false, ¿qué hace la raíz?3.Si borras la rama
comprobandodel guardián, ¿qué ve un usuario que ya tenía sesión guardada?4.La pantalla de Ajustes crea su propio
AuthViewModel, distinto del de la raíz. ¿Por qué no es un problema?5.¿Cuándo sería mejor un cerrojo por ruta que un guardián en la raíz?
6.Un enlace profundo llega a la app sin sesión. ¿Por qué no se pierde?