Recuperar la contraseña
Al terminar: Pedir el correo de recuperación y dejar la app lista para recibir su enlace.
Quedan dos cosas que toda app con cuentas necesita: recuperar la contraseña y eliminar la cuenta (esta última la exigen las dos tiendas). Esta lección empieza con la primera, y solo con su primera mitad: pedir el correo con el enlace para poner una contraseña nueva. Abrir ese enlace y cambiar la contraseña es la lección siguiente, El enlace del correo y la contraseña nueva; y eliminar la cuenta, la que viene después, Eliminar la cuenta.
Al terminar, las dos apps tienen «¿Olvidaste tu contraseña?» bajo el formulario de entrada, y al pulsarlo con un correo válido sale el aviso «Revisa tu correo». No se instala nada.
Toca el núcleo (pasos 1 y 2), un paréntesis sobre lo que rompe en Swift (paso 3) y las dos pantallas (paso 4). El paso 5 lo prueba.
Paso 1 · Pedir el correo, en el núcleo
El concepto. Cuando alguien olvida la contraseña no puede demostrar quién es con ella. Lo que se hace es pedirle a Supabase que mande un correo a esa dirección, con un enlace dentro. Que el usuario pueda abrir ese correo es la prueba de que la cuenta es suya. Desde la app, pedirlo es una sola petición.
El archivo. SupabaseAuthApi.kt ya existe: es el cliente de Supabase sin sesión que escribiste en Autenticación en el núcleo: la API. Le falta una operación, con un parámetro que decide si el enlace sirve de algo. Son tres añadidos en ese fichero: la operación en la interfaz, el cuerpo de la petición y la implementación.
// 1. Dentro de la interfaz SupabaseAuthApi, debajo de `salir`:
// Manda el correo con el enlace para poner una contraseña nueva.
suspend fun recuperar(email: String, redirectTo: String)
// 2. Debajo de `RefrescoDto`, junto a los otros cuerpos de petición:
// El cuerpo de la petición de recuperación: solo el correo.
@Serializable
private data class EmailDto(val email: String)
// 3. Dentro de KtorSupabaseAuthApi, encima de `sesionOError`:
override suspend fun recuperar(email: String, redirectTo: String) {
val respuesta = client.post("recover") {
// `redirect_to` decide a dónde vuelve el usuario tras pulsar el
// enlace. Sin él, Supabase usa el «Site URL» del panel, que por
// defecto es localhost y en un móvil no abre nada.
parameter("redirect_to", redirectTo)
contentType(ContentType.Application.Json)
setBody(EmailDto(email))
}
// Mismo patrón que el resto: un error del servidor se convierte en
// AuthException con el mensaje que mandó Supabase.
if (!respuesta.status.isSuccess()) {
val error = runCatching { respuesta.body<AuthErrorDto>() }.getOrNull()
throw AuthException(respuesta.status.value, error?.mensaje)
}
}Los imports que usa (parameter, post, setBody, isSuccess, body…) ya están en el fichero por entrar y sesionOError: no hay que añadir ninguno.
Es la segunda vez que aparece «si no es éxito, lee AuthErrorDto y lanza AuthException»: la primera es sesionOError, en este mismo fichero, justo debajo. No se junta en una función común porque sesionOError devuelve una sesión y esto no devuelve nada, y son tres líneas. En la lección siguiente habrá una tercera copia, en otro fichero (SupabaseAccountApi.kt, el de la cuenta ya dentro), y ahí sí compensa una función, porque estará dentro de una misma clase y se usará dos veces.
El doble de pruebas. Al ampliar la interfaz, FakeAuthApi (el Supabase de mentira que creaste en La sesión en el núcleo y que desde Refrescar el token sin duplicados vive en Dobles.kt) deja de cumplirla, y Android Studio lo marca en rojo. Ábrelo: está en Dobles.kt (Navigate → File… y Dobles; en commonTest, carpeta dobles), al final del fichero. Añade dentro de FakeAuthApi, encima de private fun sesion. private set deja que cualquiera lea la propiedad pero solo la propia clase la cambie (Propiedades y visibilidad): los tests leen el contador, y solo recuperar lo sube.
// Cuántas veces se pidió el correo y a qué dirección: lo que los tests miran.
var recuperaciones = 0
private set
var ultimaRedireccion: String? = null
private set
override suspend fun recuperar(email: String, redirectTo: String) {
recuperaciones++
ultimaRedireccion = redirectTo
}El repositorio de sesión, que es quien habla con la API. En SessionRepository.kt, encima de tokenValido:
// Pide el correo con el enlace para poner una contraseña nueva.
suspend fun recuperar(email: String, redirectTo: String) = withContext(dispatchers.io) {
api.recuperar(email.trim(), redirectTo)
}Paso 2 · El formulario recuerda que el correo salió
Dos cosas más en el núcleo, las dos en lo que ya escribiste en El formulario de entrada en Android: el estado del formulario necesita un campo nuevo, correoEnviado, y el ViewModel una función para pedir el correo.
En AuthState.kt, dentro de AuthFormState, añade el campo debajo de error:
// true cuando el correo de recuperación ya salió: la pantalla enseña el aviso.
val correoEnviado: Boolean = false,En AuthViewModel.kt hay cuatro cambios: un import, la función descartarError, que ya tenías y se modifica, y dos piezas nuevas, recuperar() y la dirección a la que vuelve el enlace. Las piezas nuevas van encima de private fun enviar.
Destination.ESQUEMA es la constante "pokedex" que declaraste en el companion de Destination en El mapa de pantallas compartido: la primera parte de cualquier dirección de la app, como pokedex://pokemon/25. La dirección de este enlace es pokedex://auth/callback.
// Import nuevo, arriba del fichero:
import com.sebastiannarvaez.pokedex.navigation.Destination
// `descartarError` pasa a ser (se cambia):
// Cierra el error o el aviso de «revisa tu correo». Apagar `correoEnviado`
// es lo que cierra el aviso: si se quedara en `true`, el diálogo de
// Android reaparecería al instante y la alerta de iOS no volvería a salir
// la próxima vez (el valor no cambiaría, y nada se enteraría).
fun descartarError() {
_form.value = _form.value.copy(error = null, correoEnviado = false)
}
// Nuevo, encima de `private fun enviar`:
// Pide el correo de recuperación.
// `correoEnviado` se pone pase lo que pase con la respuesta, salvo error
// de red. Es deliberado: decir «ese correo no existe» le cuenta a cualquiera
// quién tiene cuenta aquí. Supabase también responde igual en los dos casos.
fun recuperar() {
val actual = _form.value
// Sin correo válido o con una petición en vuelo, no se hace nada.
if (!actual.emailValido || actual.enviando) return
_form.value = actual.copy(enviando = true, error = null)
viewModelScope.launch {
// `try` es una expresión: vale lo que valga la última línea del
// bloque que se ejecute, y ese valor se asigna directamente.
_form.value = try {
repository.recuperar(actual.email, REDIRECCION)
actual.copy(enviando = false, correoEnviado = true)
} catch (e: Throwable) {
actual.copy(enviando = false, error = e.aUiErrorDeAuth())
}
}
}
// La dirección a la que vuelve el enlace: `pokedex://auth/callback`.
// `private companion object`: un sitio para constantes de la clase que
// nadie de fuera puede ver. La ruta se repite aquí a mano; la lección
// siguiente le da nombre. Es `val` y no `const val` porque se arma con una
// plantilla de texto: se calcula una sola vez, y da igual.
private companion object {
val REDIRECCION = "${Destination.ESQUEMA}://auth/callback"
}Comprueba que todo compila y que los tests de antes siguen en verde, con ./gradlew :shared:jvmTest en la Terminal de Android Studio (en la carpeta de tu proyecto).
Paso 3 · Lo que rompe en Swift
Añadir correoEnviado compiló en Kotlin y rompe Swift. Antes de provocarlo, la pieza que lo arregla, con un ejemplo ajeno al proyecto: un constructor secundario. Es para leer, o para probar en el Playground de Kotlin (play.kotlinlang.org).
Un constructor es el código que se ejecuta al crear un objeto: Pedido("fresa") llama al constructor de Pedido. El que se escribe en la cabecera de la clase es el principal. Un secundario es otra forma de crear lo mismo, que se apoya en el principal con : this(…).
// Un pedido de helado con tres datos y valores por defecto.
data class Pedido(
val sabor: String,
val bolas: Int = 1,
val cucurucho: Boolean = true,
) {
// Constructor secundario: otra forma de crear el pedido. No lleva datos y
// delega (`: this(...)`) en el principal con el sabor "vainilla".
constructor() : this(sabor = "vainilla")
}
fun main() {
println(Pedido("fresa")) // el principal; usa los valores por defecto
println(Pedido("limón", 3)) // el principal, con dos datos
println(Pedido()) // el secundario, sin ningún dato
}Sale Pedido(sabor=fresa, bolas=1, cucurucho=true), Pedido(sabor=limón, bolas=3, cucurucho=true) y Pedido(sabor=vainilla, bolas=1, cucurucho=true). Ahora, el problema real.
Provócalo tú. Primero vuelve a compilar el framework que Swift lee, porque el núcleo ha cambiado (Cmd+B en Xcode no lo hace; es el mismo comando de El formulario de entrada en iOS), en la Terminal, en la carpeta de tu proyecto:
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64Después abre iosApp en Xcode y compila (Cmd+B). Falla en AuthView.swift, en la línea de @State private var formulario = AuthFormState(…) que escribiste en El formulario de entrada en iOS:
error: missing argument for parameter 'correoEnviado' in call(Xcode añade debajo una nota con el constructor que esperaba.) Los argumentos por defecto de Kotlin no cruzan a Objective-C, el idioma por el que Swift habla con Kotlin (Las funciones que cruzan a Swift): allí solo llega el constructor con todos los parámetros, y a la llamada de Swift le falta un argumento.
El arreglo deja además ese fichero inmune al próximo campo que se añada: un constructor secundario vacío, que sí cruza. En AuthState.kt, dentro de AuthFormState, justo debajo de la llave de apertura del cuerpo (la que abre las funciones, después del paréntesis que cierra la lista de campos), antes de fun validarEmail(), que es lo primero que tiene:
// El constructor vacío, para Swift. Los argumentos por defecto de Kotlin
// no cruzan a Objective-C: allí solo llega el constructor con todos los
// parámetros. Esta segunda forma de crear la clase delega en la principal.
constructor() : this(email = "")Y en AuthView.swift, la línea de Swift pasa a usarlo:
// `AuthFormState()` y no la lista entera de campos: los argumentos por
// defecto de Kotlin no cruzan, así que el núcleo declara un constructor
// vacío a propósito. Sin él, cada campo nuevo rompería este fichero.
@State private var formulario = AuthFormState()Vuelve a compilar el framework con el mismo comando de arriba y compila otra vez en Xcode (Cmd+B): el error ha desaparecido.
Paso 4 · «¿Olvidaste tu contraseña?», en las dos pantallas
Las dos pantallas del formulario ganan un botón (solo al entrar: quien se registra no tiene contraseña que olvidar) y un aviso de «revisa tu correo». Son añadidos pequeños a lo que ya escribiste, y cada plataforma lo hace a su manera.
Android
El concepto. Un diálogo es una ventana pequeña que aparece encima de la pantalla y pide atención hasta que la cierras. En Compose se llama AlertDialog. Es como el aviso que te da un empleado en la puerta de una tienda: te frena un momento, te dice algo y te deja seguir cuando contestas. Se dibuja en cuanto el código lo compone, y deja de dibujarse cuando el código deja de componerlo: no se «abre» ni se «cierra» con una orden, depende de un if.
// Una pantalla de sala de chat. El diálogo vive mientras `saliendo` sea true.
@Composable
fun SalaDeChat() {
var saliendo by remember { mutableStateOf(false) }
Button(onClick = { saliendo = true }) { Text("Salir de la sala") }
if (saliendo) { // el diálogo existe solo con este if
AlertDialog(
onDismissRequest = { saliendo = false }, // tocar fuera o pulsar «atrás»
title = { Text("¿Salir de la sala?") }, // el título, arriba
text = { Text("Dejarás de ver los mensajes nuevos.") },
confirmButton = { // el botón principal
TextButton(onClick = { saliendo = false }) { Text("Salir") }
},
dismissButton = { // el botón secundario (opcional)
TextButton(onClick = { saliendo = false }) { Text("Quedarme") }
},
)
}
}Es para leer, no para escribirlo en el proyecto. Mientras no se pulsa «Salir de la sala» solo se ve el botón; al pulsarlo, saliendo pasa a true, Compose vuelve a componer, el if se cumple y aparece el diálogo. Cualquier salida (tocar fuera, «atrás», un botón) vuelve a poner saliendo en false y el diálogo desaparece. Las piezas nuevas: onDismissRequest es lo que se ejecuta cuando el usuario intenta cerrarlo sin elegir; confirmButton y dismissButton son los botones, que reciben un composable (un «hueco» que rellenas con un TextButton, por ejemplo).
Ahora, el cambio en AuthScreen.kt (el que escribiste en El formulario de entrada en Android). Primero, un import nuevo; Option+Enter sobre AlertDialog en rojo lo añade:
import androidx.compose.material3.AlertDialogDespués, el botón. Va debajo del Button de enviar (el de «Entrar» o «Crear cuenta»), dentro de la misma Column, y encima del último Spacer:
// Recuperar solo tiene sentido al entrar: quien se está
// registrando no tiene contraseña que olvidar.
if (!registrando) {
TextButton(
onClick = {
teclado?.hide() // `teclado` es el que ya usas en `enviar`
viewModel.recuperar()
},
// Sin un correo válido no hay a dónde mandar nada.
enabled = formulario.emailValido && !formulario.enviando,
modifier = Modifier.padding(top = 8.dp),
) {
Text("¿Olvidaste tu contraseña?")
}
// La pista solo sale mientras el botón está apagado.
if (!formulario.emailValido) {
Text(
"Escribe tu correo arriba y te mandamos un enlace.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}Y el aviso. Va al final del composable, después de la llave que cierra el Surface y antes de la llave que cierra AuthScreen. Fíjate en lo que no dice:
// El aviso no dice si el correo existe. Decirlo le contaría a cualquiera
// quién tiene cuenta aquí, y Supabase responde igual en los dos casos.
if (formulario.correoEnviado) {
AlertDialog(
// Tocar fuera y «Entendido» hacen lo mismo: apagar `correoEnviado`
// en el núcleo, y con eso el `if` deja de cumplirse.
onDismissRequest = viewModel::descartarError,
confirmButton = {
TextButton(onClick = viewModel::descartarError) { Text("Entendido") }
},
title = { Text("Revisa tu correo") },
text = {
Text(
"Si hay una cuenta con ese correo, le hemos mandado un enlace " +
"para poner una contraseña nueva.",
)
},
)
}viewModel::descartarError pasa la función como valor, igual que viewModel::escribirEmail en los campos. Si descartarError no apagara correoEnviado (el cambio del paso 2), el if seguiría cumpliéndose y el diálogo no se podría cerrar.
iOS
Lo mismo con las piezas de SwiftUI. El aviso es una .alert, igual que la de los errores que escribiste en El formulario de entrada en iOS: un aviso del sistema que aparece en el centro y obliga a contestar, y que se muestra cuando un Bool pasa a true. El botón usa el foco que viste en Formularios en SwiftUI: Form, foco y guardar: poner campo = nil quita el foco y baja el teclado.
Cuatro añadidos en AuthView.swift, cada uno en su sitio:
// 1. Estado nuevo, debajo de `@State private var mostrandoError = false`:
@State private var mostrandoCorreoEnviado = false
// 2. El botón, debajo del `Button` de enviar: detrás de su último modificador,
// `.disabled(!formulario.sePuedeEnviar)`, y dentro de la misma pila:
// Recuperar solo tiene sentido al entrar: quien se registra no
// tiene contraseña que olvidar.
if !registrando {
Button("¿Olvidaste tu contraseña?") {
campo = nil // baja el teclado
viewModel.recuperar()
}
.font(.footnote)
// Sin un correo válido no hay a dónde mandar nada.
.disabled(!formulario.emailValido || formulario.enviando)
}
// 3. La alerta, debajo de la `.alert` de los errores:
// El aviso no dice si el correo existe. Decirlo le contaría a cualquiera
// quién tiene cuenta aquí, y Supabase responde igual en los dos casos.
.alert("Revisa tu correo", isPresented: $mostrandoCorreoEnviado) {
// Apaga `correoEnviado` en el núcleo: si se quedara en verdadero, la
// segunda petición no lo cambiaría y la alerta no volvería a salir.
Button("Entendido", role: .cancel) { viewModel.descartarError() }
} message: {
Text("Si hay una cuenta con ese correo, le hemos mandado un enlace para poner una contraseña nueva.")
}
// 4. Debajo del `.onChange(of: formulario.error)`:
// Abre la alerta cuando `correoEnviado` pasa a verdadero.
.onChange(of: formulario.correoEnviado) { _, enviado in
mostrandoCorreoEnviado = enviado
}.onChange(of:) ejecuta su bloque cada vez que el valor cambia, y aquí abre la alerta cuando correoEnviado pasa a true (lo viste en Campos de texto en SwiftUI, con otro valor). La palabra clave es «cambia»: por eso «Entendido» llama a descartarError(). Si correoEnviado se quedara en true, la segunda petición lo pondría en true otra vez, no cambiaría nada, el .onChange no saltaría y la alerta no volvería a salir.
Vuelve a compilar el framework (el comando de arriba, porque el núcleo cambió en los pasos 1 y 2), compila (Cmd+B) y ejecuta en el simulador: en el formulario aparece «¿Olvidaste tu contraseña?».
Paso 5 · Pruébalo
Con las claves de Supabase puestas (si no, la app entra directa, sin formulario; mira Configuración y secretos por entorno):
- En el formulario de Entrar, sin escribir nada, «¿Olvidaste tu contraseña?» está apagado (en Android, con la pista «Escribe tu correo arriba y te mandamos un enlace»; en iOS no hay pista, el botón sale gris).
- Escribe un correo cualquiera con forma de correo (
ash@pueblo-paleta.test): el botón se enciende. Pulsa «¿Olvidaste tu contraseña?». Sale «Revisa tu correo», exista o no esa cuenta. Es lo que se quería. - Pulsa «Entendido». El aviso se cierra.
- Pídelo otra vez: tiene que volver a salir.
- Cambia a «Crear cuenta»: el botón de recuperar ha desaparecido.
Si el correo es uno tuyo de verdad, te llega un mensaje con un enlace. Al pulsarlo todavía no se abre ninguna pantalla útil: eso es la lección siguiente.
Para dejar los tests verificados, en la Terminal, en la carpeta de tu proyecto: ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test, y abre los informes como en Ajustes en el núcleo (open shared/build/reports/tests/jvmTest/index.html, y lo mismo con testAndroidHostTest e iosSimulatorArm64Test). Esta lección no añade ninguno: siguen en 117, 79 y 82.
Punto de control
- «¿Olvidaste tu contraseña?» sale solo en «Entrar», apagado hasta que el correo parece un correo, en las dos apps.
- Con un correo válido, sale «Revisa tu correo», exista o no la cuenta, y «Entendido» lo cierra.
- Pedirlo una segunda vez enseña el aviso otra vez (también en iOS).
- Cmd+B en Xcode termina sin errores, con
AuthFormState()enAuthView.swift. - Los informes de tests dan 117 (JVM), 79 (Android host) y 82 (iOS): sin tests nuevos.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.El aviso de «revisa tu correo» aparece exista o no la cuenta. ¿Por qué?
2.Se añade un campo con valor por defecto a una
data classde Kotlin y Swift deja de compilar con «missing argument». ¿Por qué, y cuál es el arreglo?3.En Android, «Entendido» llama a
descartarError, pero esa función solo borraerrory dejacorreoEnviadoentrue. ¿Qué pasa?4.La petición de recuperación lleva
redirect_to. Si esa dirección no está en la lista de Redirect URLs del panel, ¿qué ocurre?5.¿Cuándo existe en pantalla un
AlertDialogde Compose?