Saltar al contenido
K
Favoritos con RoomPaso a paso27 min Intermedio

Quitar con deshacer

Al terminar: Quitar un favorito dejando un aviso para deshacerlo, como estado del ViewModel y no como evento, y probarlo con tests.

Al terminar esta lección el núcleo sabrá quitar un favorito sin perderlo del todo: lo borra, deja un mensaje para que la pantalla ofrezca «Deshacer», y si el usuario se arrepiente lo devuelve exactamente a su sitio de antes, en medio de la lista y no arriba del todo. Sigue sin haber pantallas: todo se comprueba con tests. La pantalla de Android que usa esto es la lección Deslizar y deshacer en Android.

Lo difícil no es borrar y volver a insertar, sino dónde vive el aviso. Esa decisión se cuenta primero, con un ejemplo pequeño, y después se escribe el código.

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

shared
src/commonMain/kotlin/.../data/local/FavoriteDao.ktse modifica (paso 1)
src/commonMain/kotlin/.../data/FavoritesRepository.ktse modifica (paso 1)
src/commonMain/kotlin/.../feature/favorites/FavoritesViewModel.ktse reescribe (paso 2)
src/jvmTest/kotlin/.../feature/favorites/FavoritesTest.ktcrece (paso 3)
Lo que se toca hoy en el módulo shared (rutas desde su carpeta).

El concepto · El mensaje es un estado, no un evento

En Android, al deslizar un favorito para quitarlo saldrá abajo un aviso, «Pikachu quitado de favoritos · Deshacer», que dura unos segundos. Hay tres piezas en el núcleo: quitar y guardar la fila entera, un mensaje que la pantalla pueda leer, y restaurar la fila.

La fila entera, y por qué. Si «Deshacer» volviera a llamar a toggle, el favorito volvería con la hora de ahora y saltaría arriba del todo de una lista ordenada por reciente, no a su sitio. Para devolverlo donde estaba hay que guardar la fila tal cual salió de la base, con su addedAt, y volver a insertarla igual.

El mensaje. Que el aviso «salga una vez» parece un evento: algo que ocurre y se acaba, como lo que reparte un SharedFlow (un flujo que entrega cada valor a quien esté escuchando en ese instante y no guarda nada). Pero el ViewModel vive más que la pantalla, y la pantalla se destruye y se recrea (al girar el móvil, por ejemplo). Si el aviso sale justo entonces, nadie lo oye. Míralo con un ejemplo ajeno: un aviso de «Pan quitado» emitido cuando nadie mira, guardado de dos maneras. Pégalo en el Kotlin Playground si quieres verlo:

ejemplo mínimo: un aviso que nadie estaba mirando (con comentarios)
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
 
fun main() = runBlocking {
    // Dos buzones para un aviso. El primero reparte el aviso a quien esté
    // mirando EN ESE MOMENTO; el segundo lo guarda hasta que alguien lo borre.
    val comoEvento = MutableSharedFlow<String>()
    val comoEstado = MutableStateFlow<String?>(null)
 
    // Se quita un producto justo cuando la pantalla está girando: nadie mira.
    comoEvento.emit("Pan quitado")
    comoEstado.value = "Pan quitado"
 
    // Llega la pantalla nueva y mira los dos buzones. first() espera al primer
    // valor que llegue y lo devuelve; withTimeoutOrNull(100) se rinde a los
    // 100 ms y da null si no llegó ninguno.
    val enElEvento = withTimeoutOrNull(100) { comoEvento.first() }
    println("evento: $enElEvento")
    println("estado: ${comoEstado.value}")
 
    // La pantalla enseña el aviso y le dice al buzón que ya lo vio.
    comoEstado.value = null
    println("estado, ya visto: ${comoEstado.value}")
}
evento: null
estado: Pan quitado
estado, ya visto: null

El evento se perdió; el estado esperó a que alguien lo viera y entonces se borró. Eso es un mensaje que se consume: vive en el estado de la pantalla, la pantalla lo enseña y avisa al ViewModel de que ya lo vio. Es lo que pide Google, y la documentación de Kotlin reparte los papeles igual:

(Un canal, Channel, es una cola por la que una corrutina manda valores a otra: cada valor lo recibe uno solo, y si nadie espera, el valor queda en la cola o se pierde, según cómo se configure. Por eso tampoco sirve para este aviso.)

Paso 1 · El DAO y el repositorio

FavoriteDao.kt. Una consulta nueva, debajo de remove. Devuelve la fila o null si no existe (el ? del tipo marca que puede ser null, como en Nulabilidad):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/FavoriteDao.kt
    /** La fila entera, o null si no esta. La usa «deshacer» para devolverla tal cual. */
    @Query("SELECT * FROM favorite WHERE pokemonId = :pokemonId")
    suspend fun find(pokemonId: Int): FavoriteEntity?

No cambia el esquema: es una consulta, no una columna, así que no hay migración. FavoritesRepository.kt. Dos funciones nuevas, justo antes de clear:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/FavoritesRepository.kt
    /**
     * Quita un favorito y devuelve su fila tal como estaba, con su fecha, para
     * poder ponerla igual si el usuario se arrepiente. null si no estaba.
     */
    suspend fun quitar(id: Int): FavoriteEntity? = withContext(dispatchers.io) {
        dao.find(id)?.also { dao.remove(id) }
    }
 
    /** Vuelve a poner una fila quitada. Con su fecha de antes, recupera su sitio en la lista. */
    suspend fun restaurar(fila: FavoriteEntity) = withContext(dispatchers.io) { dao.add(fila) }

quitar es distinto de toggle en algo que importa: si le llegara dos veces el mismo número, la segunda no encuentra nada (null) y no hace nada. toggle, en cambio, volvería a marcar el favorito. Por eso deslizar usará quitar.

Paso 2 · El ViewModel

Sustituye todo el contenido de FavoritesViewModel.kt por esto. Cambia el estado (un campo nuevo, quitado), el uiState (que ahora junta la lista con el mensaje) y se añaden tres gestos: quitar, deshacer y mensajeMostrado. Desaparece el import de map, que ya no se usa:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt
package com.sebastiannarvaez.pokedex.feature.favorites
 
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.pokedex.data.FavoritesRepository
import com.sebastiannarvaez.pokedex.data.local.FavoriteEntity
import com.sebastiannarvaez.pokedex.domain.FavoritePokemon
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
 
data class FavoritesUiState(
    val favoritos: List<FavoritePokemon> = emptyList(),
    val cargando: Boolean = true,
    /**
     * El favorito que se acaba de quitar, mientras la pantalla ofrece
     * deshacerlo. Es un mensaje que se consume: la pantalla lo enseña y avisa
     * con `deshacer()` o con `mensajeMostrado()`. null: no hay nada que enseñar.
     */
    val quitado: FavoritePokemon? = null,
) {
    val vacia: Boolean get() = !cargando && favoritos.isEmpty()
}
 
class FavoritesViewModel internal constructor(
    private val repository: FavoritesRepository,
) : ViewModel() {
 
    // El mensaje vive en el ViewModel, no en la base: es de esta pantalla.
    private val quitado = MutableStateFlow<FavoritePokemon?>(null)
    // La fila tal como estaba, para devolverla con su fecha. No sale del ViewModel.
    private var filaQuitada: FavoriteEntity? = null
 
    val uiState: StateFlow<FavoritesUiState> =
        combine(repository.observeFavorites(), quitado) { favoritos, q ->
            FavoritesUiState(favoritos = favoritos, cargando = false, quitado = q)
        }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FavoritesUiState())
 
    /** Los identificadores, para que la lista pinte el corazon sin recargarse. */
    val favoriteIds: StateFlow<Set<Int>> = repository.observeFavoriteIds()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet())
 
    fun toggle(id: Int, name: String) {
        viewModelScope.launch { repository.toggle(id, name) }
    }
 
    /** Deslizar una fila: la quita y deja el mensaje con su «Deshacer». */
    fun quitar(favorito: FavoritePokemon) {
        viewModelScope.launch {
            filaQuitada = repository.quitar(favorito.id) ?: return@launch
            quitado.value = favorito
        }
    }
 
    /** La accion del mensaje: lo devuelve a su sitio y consume el mensaje. */
    fun deshacer() {
        val fila = filaQuitada ?: return
        filaQuitada = null
        quitado.value = null
        viewModelScope.launch { repository.restaurar(fila) }
    }
 
    /** La pantalla ya lo enseño (y se fue sin deshacer): el mensaje se consume. */
    fun mensajeMostrado() {
        filaQuitada = null
        quitado.value = null
    }
 
    fun vaciar() {
        viewModelScope.launch { repository.clear() }
    }
}

Lo nuevo, pieza a pieza:

  • quitado (privado) es el buzón del mensaje, y uiState lo junta con la lista en un FavoritesUiState nuevo: la pantalla lee un solo estado. filaQuitada guarda la fila entera y no sale del ViewModel: la pantalla solo necesita saber a quién se quitó.
  • filaQuitada = repository.quitar(favorito.id) ?: return@launch: si quitar devuelve null (no estaba), ?: sale de la corrutina sin hacer nada y no se deja ningún mensaje. return@launch sale solo de ese bloque (Lambdas y funciones de orden superior).
  • deshacer() devuelve la fila a la base y consume el mensaje. En su primera línea, filaQuitada ?: return (sin etiqueta, porque aquí no hay lambda: es la propia función) sale de deshacer() si no hay fila guardada, es decir, si el aviso ya se consumió y el gesto llega tarde: entonces no hace nada.
  • mensajeMostrado() es el otro final: la pantalla enseñó el aviso, nadie pulsó «Deshacer» y el mensaje se consume. El favorito sigue quitado.
  • toggle sigue ahí: el corazón de la rejilla lo usa.

Paso 3 · Dos tests que lo cuentan

Dos retoques en FavoritesTest.kt y después un ayudante y dos tests. Primero, estos tests sí crean un FavoritesViewModel, y viewModelScope usa el dispatcher principal, que en la JVM no existe hasta que se lo pones:

Arriba del fichero, seis import más: los del hilo principal (kotlinx.coroutines.Dispatchers, kotlinx.coroutines.test.resetMain, kotlinx.coroutines.test.setMain y kotlin.test.BeforeTest) y los de lo de abajo (app.cash.turbine.ReceiveTurbine y kotlin.test.assertNull). En la clase, sustituye limpiar y añade lo que va encima:

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesTest.kt
    @BeforeTest
    fun instalarElHiloPrincipal() = Dispatchers.setMain(StandardTestDispatcher())
 
    @AfterTest
    fun limpiar() {
        Dispatchers.resetMain()
        db.close()
    }

Y al final de la clase, después de elFavoritoSirveSinRed, el ayudante y los dos tests:

El ayudante existe porque Room avisa de los cambios cuando le da tiempo. Tras quitar, el estado llega primero con el mensaje y la lista todavía entera, y un instante después con la lista ya sin la fila. Un test que mire solo el siguiente estado lo vería a medias; hasta sigue leyendo estados hasta el que se busca.

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesTest.kt
    /** Room avisa cuando le da tiempo: se esperan estados hasta el que se busca. */
    private suspend fun ReceiveTurbine<FavoritesUiState>.hasta(condicion: (FavoritesUiState) -> Boolean): FavoritesUiState {
        var estado = awaitItem()
        // Mientras el estado no cumpla la condición, pide el siguiente.
        while (!condicion(estado)) {
            estado = awaitItem()
        }
        return estado
    }
 
    @Test
    fun quitarDejaElMensajeYDeshacerLoDevuelveASuSitio() = runTest {
        val repo = repo(testScheduler)
        reloj = 1_000; repo.toggle(1, "bulbasaur")
        reloj = 2_000; repo.toggle(4, "charmander")
        reloj = 3_000; repo.toggle(25, "pikachu")
        val vm = FavoritesViewModel(repo)
 
        vm.uiState.test {
            val charmander = hasta { !it.cargando }.favoritos[1]
            reloj = 9_000 // si deshacer usara la hora de ahora, charmander subiria arriba
 
            vm.quitar(charmander)
            // Room avisa de la fila borrada un instante despues: se espera a las dos cosas.
            val conMensaje = hasta { it.quitado != null && it.favoritos.size == 2 }
            assertEquals(charmander, conMensaje.quitado)
            assertEquals(listOf("pikachu", "bulbasaur"), conMensaje.favoritos.map { it.name })
 
            vm.deshacer()
            val deshecho = hasta { it.quitado == null && it.favoritos.size == 3 }
            assertEquals(listOf("pikachu", "charmander", "bulbasaur"), deshecho.favoritos.map { it.name })
            cancelAndIgnoreRemainingEvents()
        }
    }
 
    @Test
    fun mensajeMostradoLoConsumeYElFavoritoSigueQuitado() = runTest {
        val repo = repo(testScheduler)
        repo.toggle(25, "pikachu")
        val vm = FavoritesViewModel(repo)
 
        vm.uiState.test {
            val pikachu = hasta { !it.cargando }.favoritos.single()
            vm.quitar(pikachu)
            hasta { it.quitado != null && it.vacia }
 
            vm.mensajeMostrado()
            val consumido = hasta { it.quitado == null }
            assertTrue(consumido.vacia)
            // Ya no hay nada que deshacer: el gesto llega tarde y no hace nada.
            vm.deshacer()
            expectNoEvents()
            cancelAndIgnoreRemainingEvents()
        }
        // quitar devuelve null si la fila ya no esta: asi se comprueba que sigue quitada.
        assertNull(repo.quitar(25))
    }

El primero marca tres favoritos con horas 1000, 2000 y 3000, quita el del medio con el reloj ya en 9000 y comprueba que al deshacer vuelve al medio, no arriba. El segundo comprueba que consumir el mensaje deja la lista vacía, que un deshacer() tardío no emite nada (expectNoEvents() falla si llega cualquier estado más) y que el favorito sigue quitado. Ejecútalos:

./gradlew :shared:jvmTest --tests "*FavoritesTest*"

Provócalo. En restaurar, cambia dao.add(fila) por dao.add(fila.copy(addedAt = ahora())) (copy hace una copia de la fila cambiando solo ese campo: la misma fila, pero con la hora de ahora) y repite. Falla quitarDejaElMensajeYDeshacerLoDevuelveASuSitio. En la Terminal solo verás el nombre de la excepción (AssertionError) y la línea del test; el mensaje entero está en el informe de tests, el fichero index.html cuya ruta imprime Gradle en la última línea (shared/build/reports/tests/jvmTest/index.html; ábrelo en el navegador, entra en la clase FavoritesTest y después en el test que falla). Si lanzas el test con el triángulo verde de Android Studio, el panel de tests de abajo lo enseña directamente. El mensaje es este:

expected:<[pikachu, charmander, bulbasaur]> but was:<[charmander, pikachu, bulbasaur]>

Charmander subió arriba: es exactamente el salto que el test vigila, y el reloj por parámetro es lo que permite verlo sin esperar. Deshaz el cambio y vuelve a ejecutar hasta ver los seis en verde.

Punto de control

  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 51 tests en la JVM, 40 en Android host y 42 en iOS. Los seis de FavoritesTest (cuatro de la lección anterior y dos de esta) son de la JVM y suben la cifra de 49 a 51; si añadiste ejercicios con tests, la tuya será mayor.
  • FavoritesUiState tiene el campo quitado y FavoritesViewModel los gestos quitar, deshacer y mensajeMostrado.
  • Con restaurar usando la hora de ahora, quitarDejaElMensajeYDeshacerLoDevuelveASuSitio falla; con la fila tal cual, pasa.
  • Las dos apps compilan y arrancan igual. Todavía no hay ningún corazón que tocar.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.El aviso «Quitado de favoritos · Deshacer» vive en FavoritesUiState.quitado y no en un SharedFlow. ¿Por qué?

  2. 2.¿Por qué quitar devuelve la fila entera (FavoriteEntity) en vez de solo borrarla y que «Deshacer» llame a toggle?

  3. 3.En quitar(favorito) aparece repository.quitar(favorito.id) ?: return@launch. ¿Qué hace esa línea cuando el favorito ya no estaba en la base?

  4. 4.En deshacer(), la primera línea es val fila = filaQuitada ?: return. ¿Qué pasa si el aviso ya se consumió y el gesto llega tarde?

  5. 5.¿Para qué sirve el ayudante hasta { … } de FavoritesTest?

Faltan 5 de 5