Saltar al contenido
K
Ajustes con DataStorePaso a paso22 min Intermedio

Ajustes y favoritos para toda la app

Al terminar: Combinar el orden de los ajustes con la lista de favoritos, nombrar el estado de toda la app y calcular el número de la insignia de favoritos.

Los ajustes ya se guardan, pero todavía no mandan en nada. Al terminar esta lección el núcleo de shared hará que el orden de los favoritos dependa del ajuste, sin perder el «Deshacer» que ya tenías, y calculará el número que más tarde irá sobre la pestaña Favoritos (la insignia). Además le pondrás nombre a la idea que une todo esto: el estado de toda la app. Sigue sin haber pantallas nuevas; se comprueba con tests.

No se instala nada: ni librerías ni ajustes del compilador.

shared
src/commonMain/kotlin/.../feature/favorites/FavoritesViewModel.ktse modifica (pasos 1 y 3)
src/jvmTest/kotlin/.../feature/favorites/FavoritesTest.ktcrece (pasos 2 y 3)
src/iosMain/kotlin/.../feature/favorites/FavoritesIos.ktse modifica (paso 3)
Lo que se toca hoy en el módulo shared (rutas desde su carpeta).

Paso 1 · El orden manda en los favoritos

El concepto. Un ajuste que no cambia nada es peor que no tenerlo. El orden de los favoritos lo decide FavoritesViewModel, en shared, así que el cambio es de Kotlin común y sirve a las dos apps. Se hace con combine, el operador que junta el último valor de varios flujos y recalcula cuando cualquiera emite.

Un ejemplo ajeno lo deja claro: una pantalla de biblioteca junta la lista de libros, el ajuste «ordenar por título» y un mensaje de aviso. Cuando cambia cualquiera de los tres, se vuelve a calcular lo que se ve, con el último valor de cada uno.

ejemplo mínimo: un combine de tres flujos (con comentarios)
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
 
fun main() = runBlocking {
    // Tres buzones con su valor actual: la lista, el ajuste y un aviso.
    val libros = MutableStateFlow(listOf("Zeta", "Alfa", "Mu"))
    val porTitulo = MutableStateFlow(false)
    val aviso = MutableStateFlow<String?>(null)
 
    // `combine` recibe los tres flujos y una función con sus tres últimos valores.
    // Cada vez que cualquiera emite, se vuelve a ejecutar y emite un resultado.
    val pantalla = combine(libros, porTitulo, aviso) { lista, ordenar, mensaje ->
        val visible = if (ordenar) lista.sorted() else lista   // el ajuste decide el orden
        "$visible · aviso: $mensaje"
    }
 
    // La «pantalla» imprime cada resultado que le llega.
    val recogedor = launch { pantalla.collect { println(it) } }
    delay(50)                           // un respiro para que imprima el primero
    porTitulo.value = true              // cambia el ajuste: segundo resultado
    delay(50)
    aviso.value = "Libro quitado"       // cambia el aviso: tercer resultado
    delay(50)
    recogedor.cancel()                  // se acabó: la pantalla deja de mirar
}
[Zeta, Alfa, Mu] · aviso: null
[Alfa, Mu, Zeta] · aviso: null
[Alfa, Mu, Zeta] · aviso: Libro quitado

El primer resultado sale con el orden de siempre y sin aviso; el segundo reordena al activar el ajuste, con la misma lista y el mismo aviso; el tercero conserva el orden y añade el aviso. En ningún momento se vuelve a pedir la lista a ninguna parte: se reutiliza su último valor.

Ahora el proyecto. El archivo. Abre shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt. No lo sustituyas entero: conserva todo lo del deshacer (quitado, filaQuitada, quitar, deshacer, mensajeMostrado). Cambian tres cosas. Primero, un import nuevo, junto a los que ya tiene:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt: import nuevo
import com.sebastiannarvaez.pokedex.data.SettingsRepository

Segundo, el constructor recibe los ajustes. Sustituye la cabecera de la clase:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt: la cabecera de la clase
class FavoritesViewModel internal constructor(
    private val repository: FavoritesRepository,
    // Sin `private val`: un parámetro así solo existe mientras se construye el
    // objeto. Sirve para dar valor a `uiState`, pero las funciones de la clase no lo ven después.
    settings: SettingsRepository,
) : ViewModel() {

Y tercero, uiState pasa de juntar dos flujos a juntar tres (sustituye el uiState que dejó «Quitar con deshacer»):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt: uiState
    // `combine` junta TRES flujos: la lista guardada, las preferencias y el
    // mensaje de «Deshacer». Si cambia cualquiera, se recalcula el estado
    // con el último valor de cada uno.
    val uiState: StateFlow<FavoritesUiState> =
        combine(repository.observeFavorites(), settings.settings, quitado) { favoritos, ajustes, mensaje ->
            FavoritesUiState(
                // Por número si el ajuste lo pide; si no, tal como llega (lo más reciente primero).
                favoritos = if (ajustes.favoritosPorNumero) favoritos.sortedBy { it.id } else favoritos,
                cargando = false,
                quitado = mensaje,   // el mensaje del módulo anterior, igual que antes
            )
        }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FavoritesUiState())

toggle, quitar, deshacer, mensajeMostrado, vaciar y favoriteIds no cambian. sortedBy { it.id } devuelve una lista nueva ordenada por el número de cada Pokémon.

Koin sigue cuadrando sin tocar Modules.kt: viewModelOf(::FavoritesViewModel) mira el constructor y entrega por sí mismo el SettingsRepository que registraste en la lección anterior.

Aquí sí se combina, y no contradice Favoritos en el núcleo: lo que allí no se podía combinar era un PagingData, porque una vez creado no se modifica: transformarlo produce una copia nueva, y la lista la trata como si fuera una recarga entera. Esto es una List corriente dentro de un StateFlow: reordenarla es reordenar una lista en memoria.

Paso 2 · Los tests de favoritos, al día

Si lanzas ahora los tests, FavoritesTest.kt ya no compila: sus dos tests del deshacer crean FavoritesViewModel(repo) y el constructor pide un segundo parámetro, los ajustes (No value passed for parameter 'settings'). Koin entrega por sí mismo el repositorio nuevo, pero el test construye el ViewModel a mano.

El archivo. Abre shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesTest.kt. Tres import nuevos, junto a los que ya tiene (SettingsRepository, createSettingsStore y kotlin.io.path.createTempDirectory: los dos últimos ya los usaste en SettingsTest). Con assertNull, que más abajo se usa, no hay nada que hacer: ya está importado desde Quitar con deshacer.

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesTest.kt: imports nuevos
import com.sebastiannarvaez.pokedex.data.SettingsRepository
import com.sebastiannarvaez.pokedex.data.local.createSettingsStore
import kotlin.io.path.createTempDirectory

Un ayudante que fabrica unos ajustes nuevos, sin nada guardado, sobre un fichero temporal nuevo (lo mismo que hacía repo() en SettingsTest). Ponlo debajo de la función repo(…) de la clase:

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesTest.kt: ayudante nuevo, debajo de repo
    // Los ajustes de siempre (orden por fecha), en un fichero temporal nuevo para cada test.
    private fun ajustes() = SettingsRepository(
        createSettingsStore(createTempDirectory("prefs").resolve("pokedex.preferences_pb").toString()),
    )

Y en los dos tests del deshacer, quitarDejaElMensajeYDeshacerLoDevuelveASuSitio y mensajeMostradoLoConsumeYElFavoritoSigueQuitado, cambia la línea que crea el ViewModel:

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesTest.kt: en los dos tests del deshacer
        val vm = FavoritesViewModel(repo, ajustes())

Con eso vuelve a compilar y todo lo del deshacer sigue pasando. Falta un test que compruebe lo nuevo: hoy nadie prueba que el ajuste ordena. Añade este al final de la clase. Marca tres favoritos en momentos distintos, mira el orden por fecha, activa el ajuste y mira que se reordena sola. Dos detalles: reloj es el reloj falso de los tests de favoritos (una variable de la clase que fija «qué hora es» al guardar, de Quitar con deshacer), y el ; separa dos instrucciones escritas en la misma línea: primero se fija la hora y luego se marca el favorito.

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesTest.kt: test nuevo, al final de la clase
    @Test
    fun elAjusteDeOrdenLosPoneDelPrimeroAlUltimo() = runTest {
        val repo = repo(testScheduler)
        val ajustes = ajustes()
        reloj = 1_000; repo.toggle(4, "charmander")
        reloj = 2_000; repo.toggle(1, "bulbasaur")
        reloj = 3_000; repo.toggle(2, "ivysaur")
        val vm = FavoritesViewModel(repo, ajustes)
 
        vm.uiState.test {
            // Sin tocar el ajuste: lo más reciente primero (2, 1, 4).
            val porFecha = hasta { !it.cargando && it.favoritos.size == 3 }
            assertEquals(listOf(2, 1, 4), porFecha.favoritos.map { it.id })
 
            // Con el ajuste, se reordena en memoria sin volver a consultar la base.
            ajustes.cambiarOrden(porNumero = true)
            val porNumero = hasta { it.favoritos.first().id == 1 }
            assertEquals(listOf(1, 2, 4), porNumero.favoritos.map { it.id })
            cancelAndIgnoreRemainingEvents()
        }
    }

hasta es el ayudante que escribiste en Quitar con deshacer: sigue leyendo estados hasta el que se busca, porque Room avisa de los cambios cuando le da tiempo. Lanza ./gradlew :shared:jvmTest: 74 tests, uno más que antes.

Estado de toda la app

Los ajustes y los favoritos tienen algo en común, y conviene ponerle nombre.

Las preferencias son lo mismo: estado de toda la app. Hay una sola copia (el almacén y SettingsRepository, single en Koin, que es el @Singleton de Hilt), un solo dueño que las cambia (el repositorio, con edit) y varias pantallas que las leen a la vez: la raíz de la app para el tema, la lista de favoritos para el orden y la pantalla de ajustes. Los favoritos ya lo eran sin nombre: la rejilla, la pestaña Favoritos y, desde el paso 3, la insignia de la pestaña lee el mismo FavoritesRepository. En iOS, @AppStorage (de Ajustes que sobreviven: UserDefaults y @AppStorage) hacía este papel solo en Swift; DataStore lo hace en el código común, para las dos apps. Y lo será también la sesión del usuario, en La sesión en el núcleo.

Paso 3 · La insignia: el número lo calcula el núcleo

El concepto. Una insignia es el globo con un número que algunas apps pintan sobre un icono, como los correos sin leer. En la pestaña Favoritos mostrará cuántos favoritos hay: ese es el número de la insignia. La regla de qué texto sale (nada con 0, el número hasta 99 y «99+» desde 100, para que quepa en el círculo) se decide una sola vez, en shared, para que Android e iOS cuenten igual y cada plataforma solo pinte el texto.

Esa regla es un when sin argumento: cada rama es una condición y gana la primera que se cumple. Un ejemplo ajeno, la etiqueta de un carrito de la compra:

ejemplo mínimo: when con condiciones (con comentarios)
// El tipo `String?` significa «texto, o nada» (null).
fun etiquetaCarrito(cuantos: Int): String? = when {
    cuantos <= 0 -> null          // vacío: sin etiqueta
    cuantos > 9 -> "9+"           // muchos: no cabe el número entero
    else -> cuantos.toString()    // el resto: el número como texto
}
 
fun main() {
    println(etiquetaCarrito(0))
    println(etiquetaCarrito(3))
    println(etiquetaCarrito(9))
    println(etiquetaCarrito(10))
    println(etiquetaCarrito(250))
}
null
3
9
9+
9+

El archivo. Sigue en shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt. Un import nuevo y una función suelta al principio del fichero, encima de FavoritesUiState (nivel superior: no es de ninguna clase):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt: import nuevo
import kotlinx.coroutines.flow.map
shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt: función nueva, encima de FavoritesUiState
/** El texto de la insignia: nada con 0, el número hasta 99 y «99+» desde 100 (vive en común: Android e iOS cuentan igual). */
fun textoInsignia(cuantos: Int): String? = when {
    cuantos <= 0 -> null
    cuantos > 99 -> "99+"
    else -> cuantos.toString()
}

(El bloque que empieza por /** es un comentario KDoc: documenta lo que viene justo debajo y lo muestran los editores al pasar el ratón.)

Y dentro de la clase, justo debajo de uiState, la propiedad que usarán las pantallas. Sale del mismo conjunto de ids que ya pinta los corazones (observeFavoriteIds()): no hace falta consulta nueva ni tocar el DAO.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt: propiedad nueva, dentro de la clase
    /**
     * La insignia de la pestaña: sale del mismo conjunto de ids que los corazones,
     * sin otra consulta. `map` convierte cada conjunto en su texto; null: sin insignia.
     */
    val insignia: StateFlow<String?> = repository.observeFavoriteIds()
        .map { textoInsignia(it.size) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)

Swift no sabe leer un StateFlow de Kotlin tal cual: iOS necesita su envoltorio, igual que uiState y favoriteIds en Favoritos en iOS. Abre shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesIos.kt y añade al final:

shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesIos.kt: propiedad nueva, al final
/** La insignia de la pestaña Favoritos: `String?` en Swift (nil, "7" o "99+"). */
@NativeCoroutinesState   // genera `insigniaForIos`, el valor que lee Swift
val FavoritesViewModel.insigniaForIos: StateFlow<String?>
    get() = insignia

Es una propiedad de extensión: añade insigniaForIos al tipo FavoritesViewModel sin tocar la clase, igual que uiStateForIos en Favoritos en iOS.

Los import de NativeCoroutinesState y StateFlow ya están en ese fichero. Cómo se lee desde Swift es cosa de Ajustes en iOS.

Y dos tests, al final de la clase de FavoritesTest.kt. El primero comprueba la regla; el segundo, que la insignia sigue a los favoritos al marcar y desmarcar:

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesTest.kt: dos tests nuevos, al final de la clase
    @Test
    fun laInsigniaNoSaleConCeroYSeCortaEn99() {
        assertNull(textoInsignia(0), "sin favoritos, sin insignia")
        assertEquals("1", textoInsignia(1))
        assertEquals("99", textoInsignia(99))
        assertEquals("99+", textoInsignia(100), "a partir de 100 no cabe: 99+")
    }
 
    @Test
    fun laInsigniaSigueALosFavoritos() = runTest {
        val repo = repo(testScheduler)
        val vm = FavoritesViewModel(repo, ajustes())
 
        vm.insignia.test {
            assertNull(awaitItem(), "al empezar no hay favoritos")
            repo.toggle(25, "pikachu")
            assertEquals("1", awaitItem())
            repo.toggle(4, "charmander")
            assertEquals("2", awaitItem())
            repo.toggle(25, "pikachu")      // desmarcar también cuenta
            assertEquals("1", awaitItem())
            cancelAndIgnoreRemainingEvents()
        }
    }

El primer test no necesita runTest: textoInsignia es una función normal, no suspend. Tampoco hace falta ningún import para llamarla: está en el mismo paquete (feature.favorites) que FavoritesTest, y las cosas de un mismo paquete se ven sin importarlas. Lánzalo todo:

./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test

Punto de control

  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 76 tests en la JVM, 40 en Android host y 43 en iOS. Los tres nuevos de esta lección (elAjusteDeOrdenLosPoneDelPrimeroAlUltimo y los dos de la insignia) son de la JVM y suben la cifra de 73 a 76; si añadiste ejercicios con tests, la tuya será mayor.
  • FavoritesViewModel recibe SettingsRepository y su uiState junta tres flujos, con el deshacer intacto: los dos tests de quitar y mensajeMostrado siguen en verde.
  • textoInsignia(0) es null, textoInsignia(100) es "99+", y FavoritesViewModel.insignia sale de observeFavoriteIds().
  • FavoritesIos.kt tiene insigniaForIos.
  • Modules.kt no ha cambiado.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.El usuario activa «Ordenar por número». ¿Qué hace FavoritesViewModel?

  2. 2.Cambias el constructor de FavoritesViewModel para que reciba SettingsRepository. ¿Qué más hay que tocar?

  3. 3.¿Por qué el parámetro settings del constructor no lleva private val?

  4. 4.¿Por qué decimos que las preferencias son «estado de toda la app»?

  5. 5.Hay 0 favoritos, luego 150. ¿Qué devuelve textoInsignia y qué consecuencia tiene?

  6. 6.¿De dónde sale el número de la insignia?

Faltan 6 de 6