Saltar al contenido
K
Ajustes con DataStorePaso a paso30 min Intermedio

Ajustes en Android

Al terminar: Montar la pantalla de ajustes en Android: la pestaña, la fila reutilizable y la hoja para elegir el tema.

Las preferencias ya se guardan y nadie puede cambiarlas. Al terminar esta lección Android tendrá una tercera pestaña, «Ajustes», con una pantalla completa: una fila para elegir el tema (que se abre en una hoja inferior), un interruptor para el orden de favoritos y una fila informativa «Acerca de». La pantalla guarda lo que elijas. El orden ya manda en la lista de favoritos desde Ajustes y favoritos para toda la app (lo calcula el código común), así que el interruptor ya tendrá efecto; lo que todavía no existe es el tema aplicado: elegir «Oscuro» no repintará la app. Eso es la lección siguiente.

Las piezas de Compose que ya usaste (Scaffold, Row, Column, Switch, el estado elevado) aquí solo se usan. Lo nuevo es pequeño y cabe en tres ideas: una fila reutilizable con un hueco y una acción opcional, una propiedad que se le añade a un tipo que ya existe, y una elección entre tres valores en una hoja inferior.

Lo nuevo, con un ejemplo ajeno al proyecto

Tres piezas de Kotlin aparecen en esta lección y no se han visto juntas (la segunda ya te sonará: la usaste en la lección anterior). Antes de usarlas en la app, un ejemplo con una caja registradora de tienda, que se puede probar en el Playground de Kotlin (play.kotlinlang.org).

Un parámetro que es una función, y que puede faltar. Un parámetro puede ser una función: () -> Unit se lee «una función que no recibe nada y no devuelve nada». Con un ? detrás, (() -> Unit)?, se lee «una función de esas, o nada»: el ? permite null. Y = null hace que, si quien llama no la pasa, el parámetro valga «nada». Así la misma función sirve para quien quiere una acción y para quien no.

Una propiedad que se añade a un tipo que ya existe. Ya la viste en la lección anterior (insigniaForIos): val Turno.etiqueta escribe una propiedad nueva sobre Turno sin abrir el fichero donde Turno se definió. Es una extensión.

ejemplo mínimo: una acción opcional y una propiedad de extensión (con comentarios)
// Un enumerado: un tipo con un número fijo de valores posibles.
enum class Turno { MANANA, TARDE }
 
// Propiedad de extensión: `Turno` ya existe; aquí le añadimos `etiqueta`.
// `get() =` dice cómo se calcula cada vez que alguien la lee.
// El `when` cubre todos los valores, así que no necesita `else`.
val Turno.etiqueta: String
    get() = when (this) {
        Turno.MANANA -> "Turno de mañana"
        Turno.TARDE -> "Turno de tarde"
    }
 
// `alCobrar` es una función opcional: puede ser una función o `null`.
// `= null`: si quien llama no la pasa, vale `null`.
fun cobrar(producto: String, precio: Int, alCobrar: (() -> Unit)? = null) {
    println("Cobrado: $producto, $precio pesos")
    if (alCobrar != null) {   // solo si nos dieron una función...
        alCobrar()            // ...se ejecuta
    }
}
 
fun main() {
    println(Turno.TARDE.etiqueta)
    cobrar("pan", 2000)                                              // sin acción: solo cobra
    cobrar("leche", 4500, alCobrar = { println("Sonó la campanita") })  // con acción
}

Lo que imprime:

Turno de tarde
Cobrado: pan, 2000 pesos
Cobrado: leche, 4500 pesos
Sonó la campanita

La tercera pieza es de Compose y se explica donde se usa: un parámetro de tipo @Composable () -> Unit es un hueco que rellena quien llama, igual que decides qué va en topBar de un Scaffold.

Paso 1 · La tercera pestaña, con la pantalla vacía

androidApp
src/main/kotlin/com/sebastiannarvaez/pokedex/android
settings/SettingsScreen.ktse crea
navigation/NavKeys.ktse modifica
navigation/PokedexApp.ktse modifica
Una pantalla nueva y dos ficheros de navegación que cambian (módulo androidApp).

El archivo. SettingsScreen.kt es la pantalla de la pestaña. Créala con el método de La lista en Android: clic derecho sobre el nodo androidApp → New → File y escribe la ruta desde la carpeta del módulo, src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt (Android Studio crea la carpeta settings que falta). Plan B, desde la Terminal, en la carpeta de tu proyecto (touch no crea carpetas, por eso va antes mkdir -p):

mkdir -p androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings
touch androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt

Empieza por el esqueleto: el package, los import de toda la pantalla (los que aún no uses saldrán en gris hasta los pasos 2 y 3) y una pantalla con título y nada más.

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt
package com.sebastiannarvaez.pokedex.android.settings
 
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sebastiannarvaez.pokedex.feature.settings.SettingsViewModel
import com.sebastiannarvaez.pokedex.feature.settings.Tema
import org.koin.compose.viewmodel.koinViewModel
 
@OptIn(ExperimentalMaterial3Api::class)   // TopAppBar es experimental en Material 3
@Composable
fun SettingsScreen(modifier: Modifier = Modifier) {
    Scaffold(
        modifier = modifier.fillMaxSize(),
        topBar = { TopAppBar(title = { Text("Ajustes", fontWeight = FontWeight.Bold) }) },
    ) { relleno ->
        // Las filas, en el paso 2.
        Column(modifier = Modifier.padding(relleno).fillMaxSize()) {}
    }
}

La clave de navegación. Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/NavKeys.kt y añade la clave (junto a FavoritosKey, con su @Serializable por la misma razón que las otras) y amplía la lista de pestañas:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/NavKeys.kt: clave nueva
@Serializable
data object AjustesKey : NavKey
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/NavKeys.kt: la lista de pestañas, completa
/** Las pestañas de primer nivel, que nunca se apilan entre sí. */
val PESTANAS: List<NavKey> = listOf(ListaKey, FavoritosKey, AjustesKey)

La barra y la entrada. Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt. Son tres cambios, y no se toca nada más:

  1. Dos import nuevos, junto a los que ya tiene (también te los propone Android Studio con Option+Enter sobre los nombres en rojo):
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt: imports nuevos
import androidx.compose.material.icons.filled.Settings
import com.sebastiannarvaez.pokedex.android.settings.SettingsScreen
  1. Un elemento nuevo en la barra, justo debajo del de Favoritos y dentro del mismo NavigationBar. Los iconos de Material vienen en variantes: Icons.Default es otro nombre de Icons.Filled (el dibujo relleno) y Icons.Outlined es el mismo dibujo solo con contorno; por eso el import de arriba termina en filled.Settings:
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt: elemento nuevo de la barra, debajo del de Favoritos
                    NavigationBarItem(
                        selected = actual == AjustesKey,
                        onClick = { irAPestana(pila, AjustesKey) },
                        icon = { Icon(Icons.Default.Settings, contentDescription = null) },
                        label = { Text("Ajustes") },
                    )
  1. Una entrada en el entryProvider, debajo de entry<FavoritosKey> { … }:
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt: entrada nueva del entryProvider
            entry<AjustesKey> { SettingsScreen() }

Nada más cambia en PokedexApp.kt: ni irAPestana, ni AnimatedVisibility, ni entryDecorators.

Ejecútala (configuración androidApp, como en Ejecutar en Android). Hay tres pestañas en la barra y la tercera abre una pantalla con el título «Ajustes» y nada debajo.

Paso 2 · Las filas: una fila reutilizable

El concepto. Android no trae un contenedor de ajustes ya hecho, así que la lista se compone a mano. Todas las filas son iguales por dentro (título y detalle a la izquierda, algo a la derecha), así que se escribe una sola fila, Fila, y se usa tres veces. Lo que cambia entre filas (un icono, un Switch) entra como un hueco @Composable; y lo que hace la fila al pulsarla entra como la función opcional del ejemplo de arriba: la fila de «Acerca de» no tiene nada que hacer, así que no la recibe y no es pulsable.

Para decidir si la fila es pulsable se usa .then(…): Modifier.then(otro) añade a la cadena un modificador elegido en ese momento, y Modifier a secas no añade nada.

Sustituye la función SettingsScreen entera por esta (los import del esqueleto ya cubren todo) y añade debajo Fila:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt: la pantalla con sus filas
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
    modifier: Modifier = Modifier,
    viewModel: SettingsViewModel = koinViewModel(),   // el de `shared`, pedido a Koin
) {
    // Las preferencias como estado de Compose: la pantalla se repinta cuando cambian.
    val ajustes by viewModel.settings.collectAsStateWithLifecycle()
    var hojaTema by remember { mutableStateOf(false) }   // ¿está abierta la hoja del tema? (paso 3)
 
    Scaffold(
        modifier = modifier.fillMaxSize(),
        topBar = { TopAppBar(title = { Text("Ajustes", fontWeight = FontWeight.Bold) }) },
    ) { relleno ->
        Column(modifier = Modifier.padding(relleno).fillMaxSize()) {
            Fila(
                titulo = "Tema",
                detalle = ajustes.tema.name,                       // provisional: el nombre tal cual
                // `Icons.Outlined`: el icono solo con contorno (ver `Icons.Default` en el paso 1).
                icono = { Icon(Icons.Outlined.Settings, contentDescription = null) },
                alPulsar = { hojaTema = true },                    // abrirá la hoja (paso 3)
            )
            Fila(
                titulo = "Ordenar favoritos por número",
                detalle = if (ajustes.favoritosPorNumero) "Del 1 al último" else "Lo más reciente primero",
                icono = {
                    Switch(
                        checked = ajustes.favoritosPorNumero,
                        onCheckedChange = viewModel::cambiarOrden,   // llama al ViewModel con el valor nuevo
                    )
                },
                alPulsar = { viewModel.cambiarOrden(!ajustes.favoritosPorNumero) },
            )
            Fila(
                titulo = "Acerca de",
                detalle = "Pokédex · datos de PokeAPI",
                icono = { Icon(Icons.Default.Info, contentDescription = null) },
                // sin `alPulsar`: es una fila informativa
            )
        }
    }
}
 
// Una fila de ajustes: título y detalle a la izquierda, y a la derecha lo que
// le pases en `icono` (un icono, un Switch...). `private`: solo se usa aquí.
@Composable
private fun Fila(
    titulo: String,
    detalle: String,
    icono: @Composable () -> Unit,        // el hueco: quien llama decide qué se dibuja
    alPulsar: (() -> Unit)? = null,       // la acción es opcional
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            // `.then(otro)` encadena otro modificador a continuación. Aquí: pulsable
            // solo si hay algo que hacer al pulsar; si no, un `Modifier` vacío.
            .then(if (alPulsar != null) Modifier.clickable(onClick = alPulsar) else Modifier)
            .padding(horizontal = 24.dp, vertical = 16.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(20.dp),
    ) {
        // `weight(1f)`: los dos textos ocupan todo el ancho que sobra (`1f` es el número
        // 1,0: «una parte», y como la fila no le da parte a nadie más, se queda con el resto).
        Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
            Text(titulo, style = MaterialTheme.typography.bodyLarge)
            Text(
                detalle,
                style = MaterialTheme.typography.bodySmall,
                // `onSurfaceVariant`: el gris suave del tema para textos secundarios.
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
        }
        icono()
    }
}

Tres piezas que ya conoces de lecciones anteriores:

Dos detalles de diseño. El detalle describe el efecto, no repite el título: «Lo más reciente primero» dice qué va a ver quien lo lea, mientras que «Desactivado» no dice nada. Y la fila entera es pulsable además del interruptor, porque acertar en una fila entera es más fácil que acertar en el interruptor con el pulgar.

Ejecútala. Pulsa el interruptor: se mueve y el texto cambia a «Del 1 al último». Se guarda, y la lista de Favoritos ya cambia de orden con él (lo calcula el código común de la lección anterior): ciérrala del todo (abre Recientes con el botón cuadrado o el gesto de deslizar hacia arriba desde abajo, y desliza la tarjeta de la app hacia arriba) y ábrela, y el interruptor sigue como lo dejaste. «Tema» sale con el nombre del valor (SISTEMA) y no abre nada aún.

Paso 3 · El tema se elige en una hoja inferior

El concepto. Elegir uno entre tres valores es una elección corta, y la forma de Android de pedirla sin sacar a quien usa la app de la pantalla es una hoja inferior modal, el ModalBottomSheet que ya usaste en Pestañas en Android.

Primero, el nombre legible de cada tema. Es la propiedad de extensión del ejemplo de arriba: añade etiqueta a Tema sin tocar Settings.kt, y va en la app, al final del mismo fichero, y no en shared: el texto es cosa de cada interfaz (iOS escribirá el suyo).

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt: nombre legible del tema, al final del fichero
// El nombre legible de cada tema. Vive en la app y no en `shared`: el texto
// es cosa de cada interfaz.
val Tema.etiqueta: String
    get() = when (this) {
        Tema.SISTEMA -> "Lo que diga el sistema"
        Tema.CLARO -> "Claro"
        Tema.OSCURO -> "Oscuro"
    }

Con eso, tres cambios más en SettingsScreen:

  1. En la fila «Tema», sustituye la línea entera detalle = ajustes.tema.name,, con su comentario, por:
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt: la fila Tema, línea que cambia
                detalle = ajustes.tema.etiqueta,
  1. Arriba, justo debajo de var hojaTema …, el estado interno de la hoja (si está abierta del todo, a medias, cerrándose…):
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt: estado de la hoja, debajo de hojaTema
    val estadoHoja = rememberModalBottomSheetState()
  1. La hoja, dentro de SettingsScreen, después del Scaffold y antes de la llave que cierra la función:
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt: la hoja del tema, después del Scaffold
    if (hojaTema) {
        ModalBottomSheet(onDismissRequest = { hojaTema = false }, sheetState = estadoHoja) {
            Text(
                "Tema",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
            )
            // Una fila por cada valor del enumerado: añadir un Tema al módulo
            // compartido lo hace aparecer aquí sin tocar esta pantalla.
            Tema.entries.forEach { opcion ->
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .clickable {
                            viewModel.cambiarTema(opcion)   // guarda la elección...
                            hojaTema = false                 // ...y cierra la hoja
                        }
                        .padding(horizontal = 24.dp, vertical = 16.dp),
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.spacedBy(16.dp),
                ) {
                    Icon(
                        Icons.Default.Check,
                        contentDescription = null,
                        // La marca se ve solo en el tema elegido: en los demás es
                        // transparente, para que el texto no se mueva.
                        tint = if (opcion == ajustes.tema) {
                            MaterialTheme.colorScheme.primary
                        } else {
                            Color.Transparent
                        },
                        modifier = Modifier.size(20.dp),
                    )
                    Text(opcion.etiqueta)
                }
            }
            Text(
                "«Lo que diga el sistema» sigue el ajuste de Android.",
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 32.dp),
            )
        }
    }

Las opciones salen de Tema.entries, no de una lista escrita a mano. El import de Color ya está en el esqueleto del paso 1.

Ejecútala. Pulsa «Tema»: sube la hoja con tres opciones y la marca en la elegida. Elige «Oscuro»: la hoja se cierra y la fila dice «Oscuro»… pero la app no se oscurece: el tema se guarda y nadie lo aplica todavía. Es la lección siguiente.

Punto de control

  • La barra inferior tiene tres pestañas y SettingsScreen se abre en la tercera.
  • «Tema» abre una hoja con tres opciones y la marca en la elegida; la fila pasa a decir «Claro», «Oscuro» o «Lo que diga el sistema».
  • El interruptor de orden cambia su texto y sigue como lo dejaste después de cerrar y abrir la app.
  • «Acerca de» no responde al toque (no tiene acción).
  • Todavía no se oscurece la app al elegir «Oscuro»: eso se aplica en la lección siguiente (el orden de favoritos sí funciona ya).
  • Esta lección no añade tests: ./gradlew :shared:jvmTest sigue dando 76 (la cifra de tests se lee como en las lecciones anteriores; la de Android host sigue en 40 y la de iOS en 43).

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿De dónde salen las tres opciones de la hoja inferior del tema?

  2. 2.¿Por qué la fila del interruptor es pulsable además del propio Switch?

  3. 3.En Fila, el parámetro icono: @Composable () -> Unit es un hueco. ¿Qué significa?

  4. 4.¿Qué hace que la fila «Acerca de» no responda al toque, si usa la misma Fila que las otras?

  5. 5.¿Por qué Tema.etiqueta se escribe en la app de Android y no en shared?

Faltan 5 de 5