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.
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.
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 quitadoEl 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:
import com.sebastiannarvaez.pokedex.data.SettingsRepositorySegundo, el constructor recibe los ajustes. Sustituye 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»):
// `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.
import com.sebastiannarvaez.pokedex.data.SettingsRepository
import com.sebastiannarvaez.pokedex.data.local.createSettingsStore
import kotlin.io.path.createTempDirectoryUn 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:
// 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:
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.
@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:
// 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):
import kotlinx.coroutines.flow.map/** 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.
/**
* 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:
/** 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() = insigniaEs 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:
@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:iosSimulatorArm64TestPunto de control
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFULcon 76 tests en la JVM, 40 en Android host y 43 en iOS. Los tres nuevos de esta lección (elAjusteDeOrdenLosPoneDelPrimeroAlUltimoy 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.FavoritesViewModelrecibeSettingsRepositoryy suuiStatejunta tres flujos, con el deshacer intacto: los dos tests dequitarymensajeMostradosiguen en verde.textoInsignia(0)esnull,textoInsignia(100)es"99+", yFavoritesViewModel.insigniasale deobserveFavoriteIds().FavoritesIos.kttieneinsigniaForIos.Modules.ktno ha cambiado.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.El usuario activa «Ordenar por número». ¿Qué hace
FavoritesViewModel?2.Cambias el constructor de
FavoritesViewModelpara que recibaSettingsRepository. ¿Qué más hay que tocar?3.¿Por qué el parámetro
settingsdel constructor no llevaprivate val?4.¿Por qué decimos que las preferencias son «estado de toda la app»?
5.Hay 0 favoritos, luego 150. ¿Qué devuelve
textoInsigniay qué consecuencia tiene?6.¿De dónde sale el número de la insignia?