Saltar al contenido
K
Autenticación con SupabasePaso a paso30 min Avanzado

El formulario de entrada en Android

Al terminar: Escribir el formulario de entrada con campos tocados y errores calculados.

Ya hay sesión en el núcleo y los tokens se guardan en el almacén seguro. Falta la puerta, y la puerta tiene dos partes: el formulario donde se escribe el correo y la contraseña (esta lección) y el guardián que decide cuándo se ve (la siguiente). Al terminar tendrás el formulario entero escrito por ti: un selector «Entrar / Crear cuenta», dos campos con su error debajo, un aviso para los fallos del servidor y el botón. Nadie lo llama todavía; lo verás funcionando en La puerta y la cuenta en Android.

La decisión de fondo cabe en una frase: las reglas (qué es un correo válido, cuánto mide la contraseña, cuándo se enseña un error) viven en el núcleo, y el texto de cada error lo elige la pantalla. Los pasos son cuatro: el estado del formulario, su ViewModel, las pruebas y la pantalla.

No se instala nada: Compose, Material 3, Koin y los iconos básicos que usas ya están en el proyecto.

shared
src/commonMain/kotlin/com/sebastiannarvaez/pokedex
feature/auth/AuthState.ktse amplía (paso 1)
feature/auth/AuthViewModel.ktse crea (paso 2)
di/Modules.ktdos líneas (paso 2)
src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/auth/AuthFormStateTest.ktse crea (paso 3)
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.ktse crea (paso 4)
Qué se crea y qué cambia, con las rutas de la vista Project.

El concepto · Qué se guarda y qué se calcula

La pantalla de entrada maneja dos cosas con dos ritmos distintos. Quién eres (la sesión) lo mira toda la app y cambia pocas veces. Lo que estás escribiendo solo le importa a esta pantalla y cambia con cada tecla. Por eso el ViewModel publicará dos flujos: state (la sesión, que ya existe en el repositorio) y form (el formulario).

El formulario sigue el diseño que ya conoces. Guarda cuatro datos: el correo, la contraseña y, por cada campo, si el usuario ya escribió algo en él (el campo «tocado»). Los errores no se guardan: se calculan a partir de lo escrito. Si se guardaran, habría dos datos que pueden contradecirse (el texto y su error), y la pantalla acabaría enseñando un fallo que ya no existe.

Y hay una razón más para que las reglas estén en el núcleo y no en la pantalla: la app de iOS usará las mismas. Si cada interfaz tuviera las suyas, habría dos reglas, y una de las dos dejaría pasar algo que el servidor rechaza.

Paso 1 · El estado del formulario

El archivo. AuthState.kt ya existe: lo creaste en La sesión en el núcleo y guarda AuthState, el estado de la sesión. Ábrelo, es shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/auth/AuthState.kt. Añade el import de UiError arriba, junto al de Session, y escribe AuthFormState y los dos enum al final del fichero, debajo de AuthState:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/auth/AuthState.kt: AuthFormState y los dos enum, línea a línea
// Import nuevo, arriba del fichero:
import com.sebastiannarvaez.pokedex.core.UiError
 
// Al final del fichero, debajo de AuthState:
 
/** Lo que el usuario está escribiendo ahora mismo en la pantalla de entrada. */
data class AuthFormState(
    val email: String = "",
    val password: String = "",
    // Tocado = el usuario ya escribió algo en el campo. Antes de eso no se
    // enseña el error: nadie quiere ver «Escribe tu correo» en rojo nada más
    // abrir la pantalla.
    val emailTocado: Boolean = false,
    val passwordTocado: Boolean = false,
    // true mientras la petición está en vuelo: bloquea el botón y pinta la ruleta.
    val enviando: Boolean = false,
    // El último fallo del servidor, ya traducido para enseñarlo; null si no hay.
    val error: UiError? = null,
) {
    // Las reglas. Cada una devuelve el MOTIVO (un enum) o null si el campo
    // está bien. No saben nada de textos.
    fun validarEmail(): ErrorEmail? = when {
        email.isBlank() -> ErrorEmail.VACIO
        !email.contains('@') || !email.substringAfter('@').contains('.') -> ErrorEmail.NO_VALIDO
        else -> null
    }
 
    fun validarPassword(): ErrorPassword? = when {
        password.isEmpty() -> ErrorPassword.VACIA
        password.length < MINIMO_PASSWORD -> ErrorPassword.CORTA
        else -> null
    }
 
    // Lo que VE la pantalla: el error solo si el campo está tocado; si no, null.
    // Una propiedad con `get()` se calcula cada vez que se lee: nunca queda
    // desactualizada respecto a lo escrito.
    val errorEmail: ErrorEmail? get() = if (emailTocado) validarEmail() else null
    val errorPassword: ErrorPassword? get() = if (passwordTocado) validarPassword() else null
 
    // Lo que SABE el botón: si el campo vale, esté tocado o no.
    val emailValido: Boolean get() = validarEmail() == null
    val passwordValida: Boolean get() = validarPassword() == null
 
    // El botón se activa solo si todo vale y no hay ya una petición en vuelo.
    val sePuedeEnviar: Boolean get() = emailValido && passwordValida && !enviando
 
    companion object {
        // El mínimo que exige Supabase por defecto.
        const val MINIMO_PASSWORD = 6
    }
}
 
// Un enum por campo: cada caso es UN motivo. La pantalla elige el texto de
// cada uno (y un día podrá traducirlo); las reglas no saben nada de textos.
enum class ErrorEmail { VACIO, NO_VALIDO }
 
enum class ErrorPassword { VACIA, CORTA }

La clave está en tener dos preguntas distintas sobre el mismo campo. errorEmail responde «¿qué le enseño al usuario?» y por eso mira si el campo está tocado. emailValido responde «¿se puede enviar?» y por eso no lo mira: con el formulario recién abierto el botón ya está apagado, aunque todavía no se vea ningún error.

Paso 2 · El ViewModel

El archivo. AuthViewModel.kt es un fichero nuevo en el mismo paquete. Clic derecho sobre el nodo shared → New → File y la ruta desde la carpeta del módulo, src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/auth/AuthViewModel.kt (plan B: touch desde la Terminal de Android Studio, como en El módulo compartido y la prueba de humo). Usa los cuatro métodos de SessionRepository que escribiste en La sesión en el núcleo: restaurar, entrar, registrar y salir.

El concepto nuevo es que escribir marca el campo como tocado: es ahí, y solo ahí, donde un campo pasa de «virgen» a «con su error visible». Y hay una función privada, enviar, que sirve a la vez para entrar y para registrarse: reciben lo mismo y solo cambia la llamada, así que la llamada se pasa como parámetro.

Escríbelo con la explicación en los comentarios:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/auth/AuthViewModel.kt: línea a línea
package com.sebastiannarvaez.pokedex.feature.auth
 
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.pokedex.core.UiError
import com.sebastiannarvaez.pokedex.core.aAppError
import com.sebastiannarvaez.pokedex.core.aUiError
import com.sebastiannarvaez.pokedex.data.SessionRepository
import com.sebastiannarvaez.pokedex.data.network.AuthException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
 
// `internal constructor`: el repositorio es interno al módulo, así que solo
// Koin (dentro del módulo) puede construir el ViewModel.
class AuthViewModel internal constructor(
    private val repository: SessionRepository,
) : ViewModel() {
 
    // Quién eres. Es el flujo del repositorio, sin copiarlo: la fuente de
    // verdad sigue siendo una sola.
    val state: StateFlow<AuthState> = repository.state
 
    // Lo que estás escribiendo. Variable privada de respaldo para que solo el
    // ViewModel pueda escribir.
    private val _form = MutableStateFlow(AuthFormState())
    val form: StateFlow<AuthFormState> = _form.asStateFlow()
 
    init {
        // Al crearse, lee el token guardado. Sin esta línea `comprobando` se
        // quedaría en true para siempre y la app no pasaría de la ruleta.
        viewModelScope.launch { repository.restaurar() }
    }
 
    // Escribir deja el campo tocado: a partir de ahí su error se ve. También
    // borra el fallo del servidor anterior: si sigue ahí, ya lo han leído.
    fun escribirEmail(valor: String) {
        _form.value = _form.value.copy(email = valor, emailTocado = true, error = null)
    }
 
    fun escribirPassword(valor: String) {
        _form.value = _form.value.copy(password = valor, passwordTocado = true, error = null)
    }
 
    fun entrar() = enviar { repository.entrar(it.email, it.password) }
 
    fun registrar() = enviar { repository.registrar(it.email, it.password) }
 
    fun salir() {
        viewModelScope.launch { repository.salir() }
    }
 
    fun descartarError() {
        _form.value = _form.value.copy(error = null)
    }
 
    // Entrar y registrarse son el mismo trámite con otra llamada: se pasa la
    // acción como lambda `suspend` y el resto se escribe una sola vez.
    private fun enviar(accion: suspend (AuthFormState) -> Unit) {
        val actual = _form.value
        // Doble seguro: el botón ya está desactivado, pero el teclado también
        // puede disparar «enviar» con el formulario a medias.
        if (!actual.sePuedeEnviar) return
        _form.value = actual.copy(enviando = true, error = null)
        viewModelScope.launch {
            try {
                accion(actual)
                // Al entrar, el formulario se vacía: la contraseña no debe
                // quedarse en memoria después de usarla.
                _form.value = AuthFormState()
            } catch (e: Throwable) {
                _form.value = _form.value.copy(enviando = false, error = e.aUiErrorDeAuth())
            }
        }
    }
}
 
// Un 400 de Supabase no es «el servidor falla», es «esa contraseña no es». El
// mapeo general trata cualquier 4xx como error de servidor, y aquí sería mentir.
internal fun Throwable.aUiErrorDeAuth(): UiError = when (this) {
    is AuthException -> UiError(
        titulo = "No se pudo continuar",
        detalle = message ?: "Revisa el correo y la contraseña.",
        sePuedeReintentar = false,
    )
    else -> aAppError().aUiError()
}

Tres piezas de ahí merecen una frase. accion: suspend (AuthFormState) -> Unit es una función que recibe el formulario y puede llamar a código que espera (la red); enviar { repository.entrar(it.email, it.password) } le pasa justo esa función, y it es el formulario que recibe.

La última función merece una frase más. El mapeo de Errores que se pueden contar trata cualquier respuesta 4xx (los códigos 400 a 499, que significan «tu petición está mal») como un error del servidor. Con una contraseña equivocada, Supabase responde 400 con un mensaje claro, y decirle al usuario «el servidor falla» sería mentirle. Por eso AuthException tiene su propio camino y el resto de errores sigue por el mapeo de siempre.

Falta registrar el ViewModel en Koin, como los demás (Koin: un solo grafo en commonMain). Abre shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt y añade el import y la línea viewModelOf junto a las de los otros ViewModel:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt: el import
import com.sebastiannarvaez.pokedex.feature.auth.AuthViewModel
shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt: la línea nueva
    viewModelOf(::AuthViewModel)

Paso 3 · Las pruebas del formulario

Las reglas están en el núcleo, así que se prueban una vez y valen para las dos apps. Son siete tests: los tres primeros comprueban las reglas de validación (un correo sin arroba no vale, uno sin punto tampoco, una contraseña de cinco caracteres no llega), los dos siguientes son los de lo nuevo (los tocados), el sexto es el doble seguro del botón, y el séptimo el del error del servidor que viste arriba.

Clic derecho sobre el nodo shared → New → File y la ruta src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/auth/AuthFormStateTest.kt, y escribe:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/auth/AuthFormStateTest.kt: línea a línea
package com.sebastiannarvaez.pokedex.feature.auth
 
import com.sebastiannarvaez.pokedex.data.network.AuthException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
 
class AuthFormStateTest {
 
    // Las tres reglas de validación.
    @Test
    fun `un correo sin arroba no vale`() {
        assertFalse(AuthFormState(email = "ash.test", password = "pikachu").emailValido)
    }
 
    @Test
    fun `un correo sin punto tras la arroba no vale`() {
        assertFalse(AuthFormState(email = "ash@pueblo", password = "pikachu").emailValido)
    }
 
    @Test
    fun `una contrasena de cinco no llega al minimo de Supabase`() {
        assertFalse(AuthFormState(email = "ash@pueblo.test", password = "12345").passwordValida)
        assertTrue(AuthFormState(email = "ash@pueblo.test", password = "123456").passwordValida)
    }
 
    // Lo nuevo: tocado o sin tocar.
    @Test
    fun `al abrir no se ve ningun error y no se puede enviar`() {
        val vacio = AuthFormState()
 
        assertNull(vacio.errorEmail)                         // sin tocar: no se enseña
        assertNull(vacio.errorPassword)
        assertEquals(ErrorEmail.VACIO, vacio.validarEmail()) // pero la regla sí lo sabe
        assertFalse(vacio.sePuedeEnviar)
    }
 
    @Test
    fun `con el campo tocado el error se ve y dice el motivo`() {
        val tocado = AuthFormState(emailTocado = true, passwordTocado = true)
 
        assertEquals(ErrorEmail.VACIO, tocado.errorEmail)
        assertEquals(ErrorEmail.NO_VALIDO, tocado.copy(email = "ash").errorEmail)
        assertEquals(ErrorPassword.VACIA, tocado.errorPassword)
        assertEquals(ErrorPassword.CORTA, tocado.copy(password = "12345").errorPassword)
        val bien = tocado.copy(email = "ash@pueblo.test", password = "123456")
        assertNull(bien.errorEmail)
        assertNull(bien.errorPassword)
    }
 
    // El doble seguro del botón.
    @Test
    fun `mientras se envia no se puede volver a enviar`() {
        val valido = AuthFormState(email = "ash@pueblo.test", password = "pikachu")
 
        assertTrue(valido.sePuedeEnviar)
        assertFalse(valido.copy(enviando = true).sePuedeEnviar)
    }
 
    // Un 400 de Supabase es «esa contraseña no es», no «el servidor falla».
    @Test
    fun `el error de credenciales conserva el mensaje del servidor`() {
        val error = AuthException(400, "Invalid login credentials").aUiErrorDeAuth()
 
        assertEquals("Invalid login credentials", error.detalle)
        assertFalse(error.sePuedeReintentar)
    }
}

Los nombres de los tests van entre acentos graves y con espacios: Kotlin lo permite y el informe de pruebas se lee como una frase (sin tildes ni eñes en el nombre, como en los demás tests del proyecto).

Compruébalo en la Terminal de Android Studio, en la carpeta de tu proyecto:

./gradlew :shared:jvmTest --tests '*AuthFormStateTest*'

Debe terminar con BUILD SUCCESSFUL. Para verlo con tus ojos, abre open shared/build/reports/tests/jvmTest/index.html: el recuadro tests dice 7, porque el filtro deja fuera el resto.

Paso 4 · La pantalla, por piezas

El archivo. AuthScreen.kt es el formulario: título, un selector «Entrar / Crear cuenta», dos campos, el aviso de error y el botón. Entrar y registrarse son la misma pantalla: son dos campos iguales y un botón, y separarlas en dos destinos obligaría a navegar de una a otra perdiendo lo escrito. Créalo con clic derecho sobre el nodo androidApp → New → File y la ruta src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt (Android Studio crea la carpeta auth que falta).

El fichero es largo, así que se escribe por piezas: primero los import, luego el esqueleto con cuatro huecos señalados con comentarios, y después cada hueco por separado.

La línea package y los import. Van arriba del fichero. Los import son todos los nombres que usan las piezas siguientes; si alguno aparece en gris mientras no lo uses, es normal:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt: package e import
package com.sebastiannarvaez.pokedex.android.auth
 
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sebastiannarvaez.pokedex.core.UiError
import com.sebastiannarvaez.pokedex.feature.auth.AuthFormState
import com.sebastiannarvaez.pokedex.feature.auth.AuthViewModel
import com.sebastiannarvaez.pokedex.feature.auth.ErrorEmail
import com.sebastiannarvaez.pokedex.feature.auth.ErrorPassword
import org.koin.compose.viewmodel.koinViewModel

El esqueleto, y el orden de los modificadores

Debajo de los import, escribe la función. Los cuatro comentarios (A) a (D) son los huecos que rellenarás después. Tal como está, el esqueleto compila: los huecos son solo comentarios. Cuando rellenes (B), (C) y (D) saldrán en rojo textoDe y AvisoDeError, porque no existen hasta las «piezas de debajo» del final; es normal, no es un fallo tuyo:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt: el esqueleto
// SegmentedButton todavía está marcado como experimental en Material 3: sin
// esta anotación, el compilador se niega a usarlo.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AuthScreen(
    modifier: Modifier = Modifier,
    viewModel: AuthViewModel = koinViewModel(),
) {
    // El formulario como estado de Compose: cada cambio redibuja la pantalla.
    val formulario by viewModel.form.collectAsStateWithLifecycle()
    // Entrar o registrarse: un estado local, porque nadie más necesita saberlo.
    var registrando by remember { mutableStateOf(false) }
    // El mando del teclado en pantalla; null en una vista previa.
    val teclado = LocalSoftwareKeyboardController.current
 
    // Lo que hacen el botón y la tecla «Hecho»: bajar el teclado y enviar.
    val enviar = {
        teclado?.hide()
        if (registrando) viewModel.registrar() else viewModel.entrar()
    }
 
    Surface(modifier = modifier.fillMaxSize()) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                // Los modificadores se aplican en orden, y el relleno tiene que
                // ir ANTES que el desplazamiento: así encoge la zona que se
                // desplaza, que termina encima del teclado (y de las barras del
                // sistema), y se puede llegar a todo lo de abajo. Después de
                // `verticalScroll` solo alargaría el contenido, y con el teclado
                // abierto el botón quedaría tapado.
                .imePadding()
                .statusBarsPadding()
                .navigationBarsPadding()
                .verticalScroll(rememberScrollState())
                .padding(horizontal = 32.dp),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            Spacer(Modifier.height(48.dp))
            Text(
                "Pokédex",
                style = MaterialTheme.typography.displaySmall,
                fontWeight = FontWeight.Bold,
                color = MaterialTheme.colorScheme.primary,
            )
            Text(
                "Entra para guardar tus favoritos y recibir el Pokémon del día.",
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                textAlign = TextAlign.Center,
                modifier = Modifier.padding(top = 8.dp, bottom = 32.dp),
            )
 
            // (A) el selector
            // (B) el correo
            // (C) la contraseña
            // (D) el error y el botón
 
            Spacer(Modifier.height(48.dp))
        }
    }
}

La parte importante son esas cuatro líneas de relleno. Cada Modifier envuelve al anterior, como capas de una cebolla, así que el orden cambia el resultado. imePadding() pide un relleno por abajo tan alto como el teclado abierto (el teclado es el tercer inset de La barra de búsqueda en Android, junto a las dos barras de Navegación en Android con Navigation 3). Las dos órdenes posibles dan esto:

  • imePadding() antes de verticalScroll: la columna mide solo lo que queda encima del teclado, y la zona que se desplaza termina ahí. Lo de abajo se alcanza desplazando.
  • imePadding() después de verticalScroll: el relleno se suma al contenido y lo alarga, pero la zona visible sigue siendo la pantalla entera. Con el teclado abierto, el botón queda debajo de él.

Lo mismo vale para statusBarsPadding() y navigationBarsPadding(), las dos barras del sistema.

Dos líneas del principio, por si acaso: LocalSoftwareKeyboardController.current es «el mando del teclado en pantalla» (Compose reparte estos objetos del entorno con nombres que empiezan por Local…, y .current pide el de ahora), y teclado?.hide() lo esconde; el ?. es el de siempre, porque en una vista previa no hay teclado y vale null.

El resto del esqueleto no trae sorpresas: Surface pinta el fondo, Column apila, y Arrangement.Center y Alignment.CenterHorizontally centran. Quedan los cuatro huecos.

(A) El selector

El selector. SingleChoiceSegmentedButtonRow es lo que Material 3 ofrece para elegir entre pocas opciones excluyentes: botones pegados, uno solo marcado. iOS resuelve lo mismo con un Picker segmentado: se parecen a la vista y se escriben distinto, que es el motivo de no compartir la interfaz. Sustituye el comentario // (A) el selector por esto:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt: (A) el selector
            SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
                SegmentedButton(
                    selected = !registrando,
                    onClick = { registrando = false },
                    shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
                ) { Text("Entrar") }
                SegmentedButton(
                    selected = registrando,
                    onClick = { registrando = true },
                    shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
                ) { Text("Crear cuenta") }
            }
 
            Spacer(Modifier.height(24.dp))

itemShape(index, count) redondea solo las esquinas de fuera: el primer botón por la izquierda, el último por la derecha.

(B) El correo

Sustituye // (B) el correo por el campo del correo:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt: (B) el correo
            OutlinedTextField(
                value = formulario.email,
                onValueChange = viewModel::escribirEmail,
                label = { Text("Correo") },
                leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
                singleLine = true,
                // El teclado de correo trae la arroba a mano y quita la
                // mayúscula automática, que es la causa número uno de «mi
                // correo no existe».
                keyboardOptions = KeyboardOptions(
                    keyboardType = KeyboardType.Email,
                    imeAction = ImeAction.Next,
                ),
                // El error, debajo del campo y solo si ya está tocado: lo decide
                // el núcleo (`errorEmail` es null hasta que se escribe algo).
                isError = formulario.errorEmail != null,
                supportingText = { formulario.errorEmail?.let { Text(textoDe(it)) } },
                modifier = Modifier.fillMaxWidth(),
            )
 
            Spacer(Modifier.height(12.dp))

Lo que importa: value lee del ViewModel y onValueChange le devuelve cada tecla (viewModel::escribirEmail es una referencia a esa función). ImeAction.Next hace que la tecla de acción del teclado salte al campo siguiente. Y las dos líneas del error son las de Formularios: la pantalla, el teclado y las pruebas: el campo se pone en rojo si hay error, y el texto de debajo sale de textoDe, que escribirás al final.

(C) La contraseña

Sustituye // (C) la contraseña por el campo de la contraseña:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt: (C) la contraseña
            // «Ver / Ocultar»: un interruptor local, solo de esta pantalla.
            var verPassword by remember { mutableStateOf(false) }
            OutlinedTextField(
                value = formulario.password,
                onValueChange = viewModel::escribirPassword,
                label = { Text("Contraseña") },
                leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
                trailingIcon = {
                    TextButton(onClick = { verPassword = !verPassword }) {
                        Text(if (verPassword) "Ocultar" else "Ver")
                    }
                },
                // Puntos en vez de letras, pero el texto guardado es el real.
                visualTransformation = if (verPassword) {
                    VisualTransformation.None
                } else {
                    PasswordVisualTransformation()
                },
                singleLine = true,
                keyboardOptions = KeyboardOptions(
                    keyboardType = KeyboardType.Password,
                    imeAction = ImeAction.Done,
                ),
                // «Hecho» envía, pero solo si el formulario vale: es el mismo
                // criterio que activa el botón.
                keyboardActions = KeyboardActions(onDone = { if (formulario.sePuedeEnviar) enviar() }),
                isError = formulario.errorPassword != null,
                // Debajo del campo: el error si lo hay; si no, al registrarse,
                // el mínimo de caracteres.
                supportingText = {
                    val error = formulario.errorPassword
                    if (error != null) {
                        Text(textoDe(error))
                    } else if (registrando) {
                        Text("Al menos ${AuthFormState.MINIMO_PASSWORD} caracteres")
                    }
                },
                modifier = Modifier.fillMaxWidth(),
            )

El «Ver / Ocultar» y el PasswordVisualTransformation son los de La forma más nueva: TextFieldState y SecureTextField, escritos con value y onValueChange. Aquí se usa la forma clásica, y no TextFieldState, porque el texto vive en el ViewModel del núcleo y iOS lo leerá igual. El mismo sitio, supportingText, sirve para dos cosas: el error si lo hay, y una ayuda («Al menos 6 caracteres») al registrarse.

(D) El error del servidor y el botón

Sustituye // (D) el error y el botón por el aviso y el botón:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt: (D) el error y el botón
            // El error del servidor aparece y desaparece con animación, en el
            // sitio donde se mira: justo encima del botón que se acaba de pulsar.
            AnimatedVisibility(visible = formulario.error != null) {
                formulario.error?.let { AvisoDeError(it) }
            }
 
            Spacer(Modifier.height(24.dp))
 
            Button(
                onClick = enviar,
                enabled = formulario.sePuedeEnviar,
                modifier = Modifier.fillMaxWidth().height(52.dp),
            ) {
                if (formulario.enviando) {
                    CircularProgressIndicator(
                        modifier = Modifier.size(20.dp),
                        strokeWidth = 2.dp,
                        color = MaterialTheme.colorScheme.onPrimary,
                    )
                } else {
                    Text(if (registrando) "Crear cuenta" else "Entrar")
                }
            }

AnimatedVisibility hace aparecer o desaparecer lo que contiene con una animación según el valor de visible. Hay dos clases de error y cada una tiene su sitio: el de cada campo (el correo no vale) va debajo del campo y lo decide el núcleo con los enum; el del servidor (la contraseña no es esa) va encima del botón. El botón se apaga con enabled = false y enseña una ruleta mientras la petición está en vuelo.

Las piezas de debajo

Por último, tras la llave que cierra AuthScreen, cuatro funciones pequeñas:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/AuthScreen.kt: lo que queda, debajo de AuthScreen
// El enum se traduce a texto AQUÍ, en la pantalla: las reglas no saben de textos.
// Dos funciones con el mismo nombre y distinto tipo de parámetro: Kotlin elige
// la que corresponde mirando el tipo.
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"
}
 
@Composable
private fun AvisoDeError(error: UiError) {
    Surface(
        color = MaterialTheme.colorScheme.errorContainer,
        contentColor = MaterialTheme.colorScheme.onErrorContainer,
        shape = MaterialTheme.shapes.medium,
        modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(error.titulo, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
            Text(error.detalle, style = MaterialTheme.typography.bodySmall)
        }
    }
}
 
/** Lo que se ve mientras se lee el token guardado. Dura un parpadeo. */
@Composable
fun ComprobandoSesion(modifier: Modifier = Modifier) {
    Surface(modifier = modifier.fillMaxSize()) {
        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
        }
    }
}

Los dos when de textoDe no llevan else: son exhaustivos sobre un enum, y si mañana se añade un caso, el compilador avisa de que falta su texto. ComprobandoSesion es la ruleta que se ve mientras se lee el token guardado, y no se usa aquí: la usará el guardián en la lección siguiente.

Paso 5 · Compílalo, y míralo si quieres

Los siete tests ya los pasaste en el paso 3. Ahora, que el proyecto entero compila con la pantalla nueva. En la Terminal de Android Studio:

./gradlew :androidApp:assembleDebug

Debe terminar con BUILD SUCCESSFUL. Si algún nombre salió en rojo mientras escribías, Option+Enter sobre él ofrece el import.

Y, para sumar los tests de todo el proyecto, las tres plataformas sin filtro:

./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test
open shared/build/reports/tests/jvmTest/index.html
open shared/build/reports/tests/testAndroidHostTest/index.html
open shared/build/reports/tests/iosSimulatorArm64Test/index.html

El recuadro tests de cada informe debe decir 117 (JVM), 79 (Android host) y 82 (iOS). Los siete tests de esta lección son de commonTest, así que corren en las tres plataformas y suben cada cifra en 7 (de 110, 72 y 75). La última tarea solo corre en un Mac.

Opcional: míralo ya. Hasta la lección siguiente nadie llama a AuthScreen, pero puedes verla un momento:

  1. En local.properties (raíz del proyecto) pon valores inventados si todavía no tienes los tuyos: SUPABASE_URL=https://abcdefgh.supabase.co y SUPABASE_KEY=clave-de-prueba.
  2. Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/MainActivity.kt. Dentro de PokedexTheme { … }, convierte la llamada a PokedexApp(…) en comentario (selecciónala y pulsa Cmd+/).
  3. Escribe AuthScreen() justo debajo (Option+Enter para el import) y ejecuta la app.
  4. Prueba el selector: cambia entre «Entrar» y «Crear cuenta». Escribe «ash» en el correo: sale el error rojo debajo.
  5. Comprueba que el botón no se enciende hasta que el correo lleva arroba y punto y la contraseña tiene seis caracteres, y que con el teclado abierto puedes desplazar hasta el botón.
  6. Pulsa «Entrar» con datos válidos: con los valores inventados sale el aviso «Sin conexión · Revisa tu red e inténtalo otra vez.»; con los de un proyecto real, la contraseña equivocada saca el mensaje de Supabase.
  7. Deshaz el cambio de MainActivity.kt antes de seguir (Cmd+Z, o quita AuthScreen() y descomenta PokedexApp): la lección siguiente cambia ese mismo bloque.

Punto de control

  • ./gradlew :shared:jvmTest --tests '*AuthFormStateTest*' termina en BUILD SUCCESSFUL y el informe dice 7 tests.
  • Las tres tareas sin filtro (:shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test) terminan en BUILD SUCCESSFUL con 117, 79 y 82 tests. Si añadiste ejercicios con tests, tus cifras serán mayores.
  • ./gradlew :androidApp:assembleDebug compila: AuthScreen.kt existe en android/auth/, junto a la ruleta ComprobandoSesion.
  • Si lo miraste: el error de un campo aparece solo después de escribir en él, el botón se apaga con el formulario inválido, y MainActivity.kt quedó como estaba.

Comprueba lo que entendiste

7 preguntas · necesitas 5 para completar la lección

  1. 1.¿Por qué la validación del formulario vive en el núcleo y no en la pantalla?

  2. 2.El formulario no guarda los errores de los campos, los calcula. ¿Por qué?

  3. 3.Abres la pantalla de entrada. ¿Por qué no sale «Escribe tu correo» en rojo?

  4. 4.El botón está apagado y no se ve ningún error en pantalla. ¿Cómo es posible?

  5. 5.¿Por qué imePadding() va antes de verticalScroll(...) en la columna del formulario?

  6. 6.Un 400 de Supabase al entrar, ¿por qué no se traduce con el mapeo general de errores?

  7. 7.¿Quién decide el texto «Escribe un correo completo, como nombre@correo.com»?

Faltan 7 de 7