Cómo leer y actualizar datos con Room

1. Antes de comenzar

En los codelabs anteriores, aprendiste a usar una biblioteca de persistencias Room, una capa de abstracción sobre una base de datos SQLite, para almacenar datos de app. En este codelab, agregarás más funciones a la app de Inventory (inventario) y aprenderás a leer, mostrar, actualizar y borrar datos de la base de datos SQLite mediante Room. Usarás una LazyColumn para mostrar los datos de la base de datos y actualizarlos automáticamente cuando se modifiquen los datos subyacentes en la base de datos.

Requisitos previos

  • Saber crear la base de datos SQLite e interactuar con ella mediante la biblioteca Room
  • Saber crear una entidad, un DAO y clases de bases de datos
  • Saber usar un objeto de acceso a datos (DAO) para asignar funciones de Kotlin a consultas en SQL
  • Saber mostrar elementos de lista en una LazyColumn.
  • Haber completado el codelab anterior de esta unidad, Cómo conservar datos con Room

Qué aprenderás

  • Cómo leer y mostrar entidades de una base de datos SQLite
  • Cómo actualizar y borrar entidades de una base de datos SQLite usando la biblioteca de Room

Qué compilarás

  • Una app de Inventory que muestra una lista de elementos de inventario y puede actualizar, editar y borrar elementos de la base de datos de la app mediante Room

Requisitos

  • Una computadora con Android Studio

2. Descripción general de la app de partida

En este codelab, se usa el código de solución de la app de Inventory del codelab anterior, Cómo conservar datos con Room, como código de partida. La app de partida ya guarda datos con la biblioteca de persistencias Room. El usuario puede usar la pantalla Add Item para agregar datos a la base de datos de la app.

Pantalla Add Item con campos de texto vacíos

Inventario vacío de la pantalla del teléfono

En este codelab, extenderás la app para leer y mostrar los datos, y actualizar y borrar entidades en la base de datos usando la biblioteca Room.

Descarga el código de partida para este codelab

Para comenzar, descarga el código de partida:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-inventory-app.git
$ cd basic-android-kotlin-compose-training-inventory-app
$ git checkout room

También puedes descargar el repositorio como un archivo ZIP, descomprimirlo y abrirlo en Android Studio.

Descargar ZIP

Si deseas ver el código de partida para este codelab, míralo en GitHub.

3. Actualiza el estado de la IU

En esta tarea, agregarás una LazyColumn a la app para mostrar los datos almacenados en la base de datos.

Pantalla de teléfono con elementos del inventario

Explicación de la función de componibilidad de HomeScreen

  • Abre el archivo ui/home/HomeScreen.kt y observa el elemento componible HomeScreen().
@Composable
fun HomeScreen(
    navigateToItemEntry: () -> Unit,
    navigateToItemUpdate: (Int) -> Unit,
    modifier: Modifier = Modifier,
) {
    val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()

    Scaffold(
        topBar = {
            // Top app with app title
        },
        floatingActionButton = {
            FloatingActionButton(
                // onClick details
            ) {
                Icon(
                    // Icon details
                )
            }
        },
    ) { innerPadding ->

       // Display List header and List of Items
        HomeBody(
            itemList = listOf(),  // Empty list is being passed in for itemList
            onItemClick = navigateToItemUpdate,
            modifier = modifier.padding(innerPadding)
                              .fillMaxSize()
        )
    }

Esta función de componibilidad muestra los siguientes elementos:

  • La barra superior de la aplicación con el título
  • El botón de acción flotante (BAF) para agregar nuevos elementos al inventario adc468afa54b6e70.png
  • La función de componibilidad HomeBody()

La función de componibilidad HomeBody() muestra elementos de inventario en función de la lista que se pasó. Como parte de la implementación del código de partida, se pasa una lista vacía (listOf()) a la función de componibilidad HomeBody(). Para pasar la lista de inventario a este elemento componible, debes recuperar los datos del inventario del repositorio y pasarlos a HomeViewModel.

Emite un estado de IU en HomeViewModel

Cuando agregaste métodos a ItemDao para obtener elementos (getItem() y getAllItems()), especificaste un Flow como el tipo de datos que se muestra. Recuerda que un Flow representa una transmisión de datos genérica. Si se devuelve un Flow, solo debes llamar explícitamente a los métodos desde el DAO una vez para un ciclo de vida determinado. Room controla las actualizaciones de los datos subyacentes de manera asíncrona.

Obtener datos de un flujo se denomina recopilación de un flujo. Cuando recopiles datos desde un flujo en la capa de la IU, debes tener en cuenta algunos aspectos.

  • Los eventos de ciclo de vida, como los cambios de configuración, por ejemplo, la rotación del dispositivo, hacen que se vuelva a crear la actividad. Esto provoca la recomposición y la recopilación de tu Flow nuevamente.
  • Querrás que los valores se almacenen en caché como estados para que los datos existentes no se pierdan entre los eventos de ciclo de vida.
  • Los flujos deben cancelarse si no quedan observadores, como después de que finalice el ciclo de vida de un elemento componible.

La forma recomendada de exponer un Flow desde un ViewModel es con un StateFlow. El uso de un StateFlow permite guardar y observar los datos, sin importar el ciclo de vida de la IU. Para convertir un Flow en un StateFlow, usa el operador stateIn.

El operador stateIn tiene tres parámetros que se explican a continuación:

  • scope: El viewModelScope define el ciclo de vida del StateFlow. Cuando se cancela el viewModelScope, también se cancela el StateFlow.
  • started: La canalización solo debe estar activa cuando la IU sea visible. Se usa SharingStarted.WhileSubscribed() para lograr esto. Para configurar una demora (en milisegundos) entre la desaparición del último suscriptor y la detención de la corrutina de uso compartido, pasa TIMEOUT_MILLIS al método SharingStarted.WhileSubscribed().
  • initialValue: Establece el valor inicial del flujo de estado en HomeUiState().

Una vez que conviertas tu Flow en StateFlow, podrás recopilarlo con el método collectAsState() y convertir sus datos en State del mismo tipo.

En este paso, recuperarás todos los elementos de la base de datos de Room como una API observable de StateFlow para el estado de la IU. Cuando cambian los datos de Inventory de Room, la IU se actualiza automáticamente.

  1. Abre el archivo ui/home/HomeViewModel.kt, que contiene una constante TIMEOUT_MILLIS y una clase de datos HomeUiState con una lista de elementos como parámetro de constructor.
// No need to copy over, this code is part of starter code

class HomeViewModel : ViewModel() {

    companion object {
        private const val TIMEOUT_MILLIS = 5_000L
    }
}

data class HomeUiState(val itemList: List<Item> = listOf())
  1. Dentro de la clase HomeViewModel, declara un val llamado homeUiState del tipo StateFlow<HomeUiState>. En breve, resolverás el error de inicialización.
val homeUiState: StateFlow<HomeUiState>
  1. Llama a getAllItemsStream() en itemsRepository y asígnalo al homeUiState que acabas de declarar.
val homeUiState: StateFlow<HomeUiState> =
    itemsRepository.getAllItemsStream()

Ahora se muestra el error que indica que la referencia itemsRepository no está resuelta. Para solucionar este error, debes pasar el objeto ItemsRepository a HomeViewModel.

  1. Agrega un parámetro de constructor del tipo ItemsRepository a la clase HomeViewModel.
import com.example.inventory.data.ItemsRepository

class HomeViewModel(itemsRepository: ItemsRepository): ViewModel() {
  1. En el archivo ui/AppViewModelProvider.kt, en el inicializador HomeViewModel, pasa el objeto ItemsRepository como se muestra.
initializer {
    HomeViewModel(inventoryApplication().container.itemsRepository)
}
  1. Regresa al archivo HomeViewModel.kt. Observa el error de discrepancia de tipos. Para resolver esto, agrega un mapa de transformación como se muestra a continuación.
val homeUiState: StateFlow<HomeUiState> =
    itemsRepository.getAllItemsStream().map { HomeUiState(it) }

Android Studio aún te muestra un error de discrepancia de tipos. Este error se debe a que homeUiState es del tipo StateFlow y getAllItemsStream() devuelve un Flow.

  1. Usa el operador stateIn para convertir Flow en StateFlow. StateFlow es la API observable para el estado de la IU, que permite que la IU se actualice sola.
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

val homeUiState: StateFlow<HomeUiState> =
    itemsRepository.getAllItemsStream().map { HomeUiState(it) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
            initialValue = HomeUiState()
        )
  1. Compila la app para asegurarte de que no haya errores en el código. No habrá cambios visuales.

4. Muestra los datos de inventario

En esta tarea, recopilarás y actualizarás el estado de la IU en HomeScreen.

  1. En el archivo HomeScreen.kt, en la función de componibilidad HomeScreen, agrega un parámetro de función nuevo del tipo HomeViewModel y, luego, inicialízalo.
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.inventory.ui.AppViewModelProvider

@Composable
fun HomeScreen(
    navigateToItemEntry: () -> Unit,
    navigateToItemUpdate: (Int) -> Unit,
    modifier: Modifier = Modifier,
    viewModel: HomeViewModel = viewModel(factory = AppViewModelProvider.Factory)
)
  1. En la función de componibilidad HomeScreen, agrega un val llamado homeUiState para recopilar el estado de la IU de HomeViewModel. Usa collectAsState(), que recopila valores de este StateFlow y representa su valor más reciente mediante State.
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue

val homeUiState by viewModel.homeUiState.collectAsState()
  1. Actualiza la llamada a la función HomeBody() y pasa homeUiState.itemList al parámetro itemList.
HomeBody(
    itemList = homeUiState.itemList,
    onItemClick = navigateToItemUpdate,
    modifier = modifier.padding(innerPadding)
)
  1. Ejecuta la app. Observa que la lista de inventario muestra si guardaste elementos en la base de datos de tu app. Si la lista está vacía, agrega algunos elementos de inventario a la base de datos de la app.

Pantalla de teléfono con elementos del inventario

5. Prueba tu base de datos

En los codelabs anteriores, se analiza la importancia de probar el código. En esta tarea, agregarás algunas pruebas de unidades para probar tus consultas DAO y, luego, agregarás más pruebas a medida que avances en el codelab.

El enfoque recomendado para probar la implementación de la base de datos es escribir una prueba JUnit que se ejecute en un dispositivo Android. Como estas pruebas no requieren la creación de una actividad, se ejecutan más rápido que tus pruebas de IU.

  1. En el archivo build.gradle.kts (Module :app), observa las siguientes dependencias para Espresso y JUnit.
// Testing
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
  1. Cambia a la vista Project y haz clic con el botón derecho en src > New > Directory para crear un conjunto de orígenes de prueba para tu pruebas.

e53b0f0e0b6aba29.png

  1. Selecciona androidTest/kotlin en la ventana emergente New Directory.

860b7e1af5f116a.png

  1. Crea una clase de Kotlin llamada ItemDaoTest.kt.
  2. Anota la clase ItemDaoTest con @RunWith(AndroidJUnit4::class). Ahora tu clase se verá como el siguiente código de ejemplo:
package com.example.inventory

import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ItemDaoTest {
}
  1. Dentro de la clase, agrega variables privadas var de tipo ItemDao y InventoryDatabase.
import com.example.inventory.data.InventoryDatabase
import com.example.inventory.data.ItemDao

private lateinit var itemDao: ItemDao
private lateinit var inventoryDatabase: InventoryDatabase
  1. Agrega una función para crear la base de datos y anótala con @Before para que pueda ejecutarse antes de cada prueba.
  2. Dentro del método, inicializa itemDao.
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import org.junit.Before

@Before
fun createDb() {
    val context: Context = ApplicationProvider.getApplicationContext()
    // Using an in-memory database because the information stored here disappears when the
    // process is killed.
    inventoryDatabase = Room.inMemoryDatabaseBuilder(context, InventoryDatabase::class.java)
        // Allowing main thread queries, just for testing.
        .allowMainThreadQueries()
        .build()
    itemDao = inventoryDatabase.itemDao()
}

En esta función, se usa una base de datos en la memoria y no se conserva en el disco. Para hacerlo, usa la función inMemoryDatabaseBuilder(). Debes hacerlo porque la información no debe ser persistente, sino que debe borrarse cuando se cierra el proceso. Estás ejecutando las consultas DAO en el subproceso principal con .allowMainThreadQueries() solo para pruebas.

  1. Agrega otra función para cerrar la base de datos. Anótala con @After para cerrar la base de datos y ejecutarla después de cada prueba.
import org.junit.After
import java.io.IOException

@After
@Throws(IOException::class)
fun closeDb() {
    inventoryDatabase.close()
}
  1. Declara elementos en la clase ItemDaoTest para que los use la base de datos, como se muestra en el siguiente código de ejemplo:
import com.example.inventory.data.Item

private var item1 = Item(1, "Apples", 10.0, 20)
private var item2 = Item(2, "Bananas", 15.0, 97)
  1. Agrega funciones de utilidad para agregar un elemento y, luego, dos elementos a la base de datos. Más adelante, usarás estas funciones en la prueba. Márcalas como suspend para que puedan ejecutarse en una corrutina.
private suspend fun addOneItemToDb() {
    itemDao.insert(item1)
}

private suspend fun addTwoItemsToDb() {
    itemDao.insert(item1)
    itemDao.insert(item2)
}
  1. Escribe una prueba para insertar un solo elemento en la base de datos, insert(). Asigna el nombre daoInsert_insertsItemIntoDB a la prueba y anótala con @Test.
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test

@Test
@Throws(Exception::class)
fun daoInsert_insertsItemIntoDB() = runBlocking {
    addOneItemToDb()
    val allItems = itemDao.getAllItems().first()
    assertEquals(allItems[0], item1)
}

En esta prueba, usarás la función de utilidad addOneItemToDb() para agregar un elemento a la base de datos. Luego, debes leer el primer elemento de la base de datos. Con assertEquals(), puedes comparar el valor esperado con el real. Ejecuta la prueba en una corrutina nueva con runBlocking{}. Esta configuración es la razón por la que marcas las funciones de utilidad como suspend.

  1. Ejecuta la prueba y asegúrate de que sea exitosa.

cd95648114520f13.png

6521e8595bb33a91.png

  1. Escribe otra prueba para getAllItems() desde la base de datos. Asigna el nombre daoGetAllItems_returnsAllItemsFromDB a la prueba.
@Test
@Throws(Exception::class)
fun daoGetAllItems_returnsAllItemsFromDB() = runBlocking {
    addTwoItemsToDb()
    val allItems = itemDao.getAllItems().first()
    assertEquals(allItems[0], item1)
    assertEquals(allItems[1], item2)
}

En la prueba anterior, agregaste dos elementos a la base de datos dentro de una corrutina. Luego, leíste los dos elementos y los comparaste con los valores esperados.

6. Muestra los detalles del elemento

En esta tarea, leerás y mostrarás los detalles de la entidad en la pantalla Item Details. Usarás el estado de la IU del elemento, como el nombre, el precio y la cantidad de la base de datos de la app de inventario, y los mostrarás en la pantalla Item Details con el elemento componible ItemDetailsScreen. La función de componibilidad ItemDetailsScreen ya está escrita y contiene tres elementos componibles de texto que muestran los detalles del elemento.

ui/item/ItemDetailsScreen.kt

Esta pantalla forma parte del código de partida y muestra los detalles de los elementos, que verás en un codelab posterior. En este codelab, no trabajarás en esa pantalla. ItemDetailsViewModel.kt es el ViewModel correspondiente de esta pantalla.

a5009ad021b830ff.png

  1. En la función de componibilidad HomeScreen, observa la llamada a la función HomeBody(). navigateToItemUpdate se pasa al parámetro onItemClick, al que se llama cuando haces clic en cualquier elemento de la lista.
// No need to copy over
HomeBody(
    itemList = homeUiState.itemList,
    onItemClick = navigateToItemUpdate,
    modifier = modifier
        .padding(innerPadding)
        .fillMaxSize()
)
  1. Abre ui/navigation/InventoryNavGraph.kt y observa el parámetro navigateToItemUpdate en el elemento componible HomeScreen. Este parámetro especifica el destino de la navegación como la pantalla de detalles del elemento.
// No need to copy over
HomeScreen(
    navigateToItemEntry = { navController.navigate(ItemEntryDestination.route) },
    navigateToItemUpdate = {
        navController.navigate("${ItemDetailsDestination.route}/${it}")
   }

Ya se implementó esta parte de la funcionalidad de onItemClick. Cuando haces clic en el elemento de la lista, la app navega a la pantalla de detalles del elemento.

  1. Haz clic en cualquier elemento de la lista del inventario para ver la pantalla de detalles del elemento con campos vacíos.

Pantalla Item Details con campos vacíos

Para completar los campos de texto con los detalles del elemento, debes recopilar el estado de la IU en ItemDetailsScreen().

  1. En UI/Item/ItemDetailsScreen.kt, agrega un parámetro nuevo al elemento componible ItemDetailsScreen del tipo ItemDetailsViewModel y usa el método de fábrica para inicializarlo.
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.inventory.ui.AppViewModelProvider

@Composable
fun ItemDetailsScreen(
    navigateToEditItem: (Int) -> Unit,
    navigateBack: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: ItemDetailsViewModel = viewModel(factory = AppViewModelProvider.Factory)
)
  1. Dentro del elemento componible ItemDetailsScreen(), crea un val llamado uiState para recopilar el estado de la IU. Usa collectAsState() para recopilar uiState StateFlow y representar su valor más reciente mediante State. Android Studio muestra un error de referencia sin resolver.
import androidx.compose.runtime.collectAsState

val uiState = viewModel.uiState.collectAsState()
  1. Para resolver el error, crea un val llamado uiState del tipo StateFlow<ItemDetailsUiState> en la clase ItemDetailsViewModel.
  2. Recupera los datos del repositorio de elementos y asígnalos a ItemDetailsUiState con la función de extensión toItemDetails(). La función de extensión Item.toItemDetails() ya está escrita como parte del código de partida.
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

val uiState: StateFlow<ItemDetailsUiState> =
         itemsRepository.getItemStream(itemId)
             .filterNotNull()
             .map {
                 ItemDetailsUiState(itemDetails = it.toItemDetails())
             }.stateIn(
                 scope = viewModelScope,
                 started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
                 initialValue = ItemDetailsUiState()
             )
  1. Pasa ItemsRepository a ItemDetailsViewModel para resolver el error Unresolved reference: itemsRepository.
class ItemDetailsViewModel(
    savedStateHandle: SavedStateHandle,
    private val itemsRepository: ItemsRepository
    ) : ViewModel() {
  1. En ui/AppViewModelProvider.kt, actualiza el inicializador para ItemDetailsViewModel como se muestra en el siguiente fragmento de código:
initializer {
    ItemDetailsViewModel(
        this.createSavedStateHandle(),
        inventoryApplication().container.itemsRepository
    )
}
  1. Vuelve a ItemDetailsScreen.kt y observa que se resolvió el error en el elemento componible ItemDetailsScreen().
  2. En el elemento componible ItemDetailsScreen(), actualiza la llamada a función ItemDetailsBody() y pasa uiState.value al argumento itemUiState.
ItemDetailsBody(
    itemUiState = uiState.value,
    onSellItem = {  },
    onDelete = { },
    modifier = modifier.padding(innerPadding)
)
  1. Observa las implementaciones de ItemDetailsBody() y ItemInputForm(). Estás pasando el elemento item seleccionado actual de ItemDetailsBody() a ItemDetails().
// No need to copy over

@Composable
private fun ItemDetailsBody(
    itemUiState: ItemUiState,
    onSellItem: () -> Unit,
    onDelete: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
       //...
    ) {
        var deleteConfirmationRequired by rememberSaveable { mutableStateOf(false) }
        ItemDetails(
             item = itemDetailsUiState.itemDetails.toItem(), modifier = Modifier.fillMaxWidth()
         )

      //...
    }
  1. Ejecuta la app. Cuando hagas clic en cualquier elemento de la lista en la pantalla Inventory, se mostrará la pantalla Item Details.
  2. Observa que la pantalla ya no está en blanco. Muestra los detalles de la entidad recuperados de la base de datos de inventario.

Pantalla Item Details con detalles del elemento completados

  1. Presiona el botón Sell. No pasa nada.

En la siguiente sección, implementarás la funcionalidad del botón Sell.

7. Implementa la pantalla Item Details

ui/item/ItemEditScreen.kt

La pantalla Edit Item ya se te proporcionó como parte del código de partida.

Este diseño contiene elementos componibles de campo de texto para editar los detalles de cualquier elemento de inventario nuevo.

Pantalla Edit Item con campos vacíos

El código de esta app todavía no funciona por completo. Por ejemplo, en la pantalla Item Details, cuando presionas el botón Sell, la Quantity in Stock no disminuye. Cuando presionas el botón Delete, la app te muestra un cuadro de diálogo de confirmación. Sin embargo, si seleccionas el botón Yes, la app no borrará el elemento.

Ventana emergente para confirmar la eliminación del elemento

Por último, el botón BAF be6c7ed4ac207351.png abre una pantalla Edit Item vacía.

Pantalla Edit Item con campos vacíos

En esta sección, implementarás las funcionalidades de los botones Sell, Delete y BAF.

8. Implementa la venta de artículos

En esta sección, extenderás las funciones de la app para implementar la funcionalidad de venta. Esta actualización incluye las siguientes tareas:

  • Agregar una prueba para que la función DAO actualice una entidad
  • Agregar una función a ItemDetailsViewModel para reducir la cantidad y actualizar la entidad en la base de datos de la app
  • Inhabilitar el botón Sell si la cantidad es cero
  1. En ItemDaoTest.kt, agrega una función llamada daoUpdateItems_updatesItemsInDB() sin parámetros. Anota con @Test y @Throws(Exception::class).
@Test
@Throws(Exception::class)
fun daoUpdateItems_updatesItemsInDB()
  1. Define la función y crea un bloque runBlocking. Llama a addTwoItemsToDb() dentro de ella.
fun daoUpdateItems_updatesItemsInDB() = runBlocking {
    addTwoItemsToDb()
}
  1. Actualiza las dos entidades con diferentes valores y llama a itemDao.update.
itemDao.update(Item(1, "Apples", 15.0, 25))
itemDao.update(Item(2, "Bananas", 5.0, 50))
  1. Recupera las entidades con itemDao.getAllItems(). Compáralas con la entidad actualizada y afirma.
val allItems = itemDao.getAllItems().first()
assertEquals(allItems[0], Item(1, "Apples", 15.0, 25))
assertEquals(allItems[1], Item(2, "Bananas", 5.0, 50))
  1. Asegúrate de que la función completada se vea de la siguiente manera:
@Test
@Throws(Exception::class)
fun daoUpdateItems_updatesItemsInDB() = runBlocking {
    addTwoItemsToDb()
    itemDao.update(Item(1, "Apples", 15.0, 25))
    itemDao.update(Item(2, "Bananas", 5.0, 50))

    val allItems = itemDao.getAllItems().first()
    assertEquals(allItems[0], Item(1, "Apples", 15.0, 25))
    assertEquals(allItems[1], Item(2, "Bananas", 5.0, 50))
}
  1. Ejecuta la prueba y asegúrate de que sea exitosa.

Agrega una función en ViewModel.

  1. En ItemDetailsViewModel.kt, dentro de la clase ItemDetailsViewModel, agrega una función llamada reduceQuantityByOne() sin parámetros.
fun reduceQuantityByOne() {
}
  1. Dentro de la función, inicia una corrutina con viewModelScope.launch{}.
import kotlinx.coroutines.launch
import androidx.lifecycle.viewModelScope

viewModelScope.launch {
}
  1. Dentro del bloque launch, crea un val llamado currentItem y establécelo en uiState.value.toItem().
val currentItem = uiState.value.toItem()

El uiState.value es del tipo ItemUiState. Lo conviertes al tipo de entidad Item con la función de extensión toItem().

  1. Agrega una sentencia if para verificar si quality es mayor que 0.
  2. Llama a updateItem() en itemsRepository y pasa el currentItem actualizado. Usa copy() para actualizar el valor quantity y que la función se vea de la siguiente manera:
fun reduceQuantityByOne() {
    viewModelScope.launch {
        val currentItem = uiState.value.itemDetails.toItem()
        if (currentItem.quantity > 0) {
    itemsRepository.updateItem(currentItem.copy(quantity = currentItem.quantity - 1))
       }
    }
}
  1. Vuelve a ItemDetailsScreen.kt.
  2. En el elemento componible ItemDetailsScreen, ve a la llamada a la función ItemDetailsBody().
  3. En la lambda onSellItem, llama a viewModel.reduceQuantityByOne().
ItemDetailsBody(
    itemUiState = uiState.value,
    onSellItem = { viewModel.reduceQuantityByOne() },
    onDelete = { },
    modifier = modifier.padding(innerPadding)
)
  1. Ejecuta la app.
  2. En la pantalla Inventory, haz clic en un elemento de la lista. Cuando aparezca la pantalla Item Details, presiona Sell y observa que el valor de la cantidad disminuye en uno.

La pantalla Item Details disminuye la cantidad en uno cuando se presiona el botón Sell

  1. En la pantalla Item Details, presiona continuamente el botón Sell hasta que la cantidad sea cero.

Cuando la cantidad llegue a cero, vuelve a presionar Sell. No hay cambios visuales porque la función reduceQuantityByOne() comprueba si la cantidad es mayor que cero antes de actualizarla.

Pantalla Item Details con la cantidad en stock en 0

Para proporcionar mejores comentarios a los usuarios, puedes inhabilitar el botón Sell cuando no haya ningún artículo para vender.

  1. En la clase ItemDetailsViewModel, establece el valor outOfStock según it.quantity en la transformación de map.
val uiState: StateFlow<ItemDetailsUiState> =
    itemsRepository.getItemStream(itemId)
        .filterNotNull()
        .map {
            ItemDetailsUiState(outOfStock = it.quantity <= 0, itemDetails = it.toItemDetails())
        }.stateIn(
            //...
        )
  1. Ejecuta la app. Observa que la app inhabilita el botón Sell cuando la cantidad en stock es cero.

Pantalla Item Details con el botón Sell inhabilitado

Felicitaciones por implementar la función Sell de elementos en tu app.

Entidad Delete item

Al igual que con la tarea anterior, debes extender las funciones de tu app con la implementación de la función de borrar. Esta función es mucho más fácil de implementar que la función de venta. El proceso incluye las siguientes tareas:

  • Agregar una prueba para la consulta de eliminación de DAO
  • Agregar una función en la clase ItemDetailsViewModel para borrar una entidad de la base de datos
  • Actualizar el elemento componible ItemDetailsBody

Agrega una prueba de DAO

  1. En ItemDaoTest.kt, agrega una prueba llamada daoDeleteItems_deletesAllItemsFromDB().
@Test
@Throws(Exception::class)
fun daoDeleteItems_deletesAllItemsFromDB()
  1. Inicia una corrutina con runBlocking {}.
fun daoDeleteItems_deletesAllItemsFromDB() = runBlocking {
}
  1. Agrega dos elementos a la base de datos y llama a itemDao.delete() en esos dos elementos para borrarlos de la base de datos.
addTwoItemsToDb()
itemDao.delete(item1)
itemDao.delete(item2)
  1. Recupera las entidades de la base de datos y verifica que la lista esté vacía. La prueba completada debería verse de la siguiente manera:
import org.junit.Assert.assertTrue

@Test
@Throws(Exception::class)
fun daoDeleteItems_deletesAllItemsFromDB() = runBlocking {
    addTwoItemsToDb()
    itemDao.delete(item1)
    itemDao.delete(item2)
    val allItems = itemDao.getAllItems().first()
    assertTrue(allItems.isEmpty())
}

Agrega la función de borrar en ItemDetailsViewModel

  1. En ItemDetailsViewModel, agrega una nueva función llamada deleteItem() que no tome parámetros ni devuelva nada.
  2. Dentro de la función deleteItem(), agrega una llamada a la función itemsRepository.deleteItem() y pasa uiState.value.toItem().
suspend fun deleteItem() {
    itemsRepository.deleteItem(uiState.value.itemDetails.toItem())
}

En esta función, conviertes uiState del tipo itemDetails al tipo de entidad Item con la función de extensión toItem().

  1. En el elemento componible ui/item/ItemDetailsScreen, agrega un elemento val llamado coroutineScope y establécelo en rememberCoroutineScope(). Este enfoque muestra un alcance de corrutinas vinculado a la composición a la que se llama (elemento componible ItemDetailsScreen).
import androidx.compose.runtime.rememberCoroutineScope

val coroutineScope = rememberCoroutineScope()
  1. Desplázate hasta la función ItemDetailsBody().
  2. Inicia una corrutina con coroutineScope dentro de la lambda onDelete.
  3. Dentro del bloque launch, llama al método deleteItem() en viewModel.
import kotlinx.coroutines.launch

ItemDetailsBody(
    itemUiState = uiState.value,
    onSellItem = { viewModel.reduceQuantityByOne() },
    onDelete = {
        coroutineScope.launch {
           viewModel.deleteItem()
    }
    modifier = modifier.padding(innerPadding)
)
  1. Después de borrar el elemento, vuelve a la pantalla del inventario.
  2. Llama a navigateBack() después de la llamada a función deleteItem().
onDelete = {
    coroutineScope.launch {
        viewModel.deleteItem()
        navigateBack()
    }
  1. Dentro del archivo ItemDetailsScreen.kt, desplázate hasta la función ItemDetailsBody().

Esta función es parte del código de partida. Este elemento componible muestra un diálogo de alerta para obtener la confirmación del usuario antes de borrar el elemento y llama a la función deleteItem() cuando presionas Yes.

// No need to copy over

@Composable
private fun ItemDetailsBody(
    itemUiState: ItemUiState,
    onSellItem: () -> Unit,
    onDelete: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        /*...*/
    ) {
        //...

        if (deleteConfirmationRequired) {
            DeleteConfirmationDialog(
                onDeleteConfirm = {
                    deleteConfirmationRequired = false
                    onDelete()
                },
                //...
            )
        }
    }
}

Cuando presionas No, la app cierra el diálogo de alerta. La función showConfirmationDialog() muestra la siguiente alerta:

Ventana emergente para confirmar la eliminación del elemento

  1. Ejecuta la app.
  2. Selecciona un elemento de lista en la pantalla Inventory.
  3. En la pantalla Item Details, presiona Delete.
  4. Presiona Yes en el diálogo de alerta y la app regresará a la pantalla Inventory.
  5. Confirma que la entidad que borraste ya no esté en la base de datos de la app.

Felicitaciones por implementar la función de borrar.

Pantalla Item Details con la ventana de diálogo de una alerta.

Pantalla Inventory sin el elemento borrado

Entidad Edit item

Al igual que en las secciones anteriores, en esta sección, agregarás otra mejora de funciones a la app que edita la entidad de un elemento.

A continuación, te mostramos los pasos rápidos para editar una entidad en la base de datos de la app:

  • Agrega una prueba a la consulta de DAO de obtención de elementos de prueba.
  • Propaga los campos de texto y la pantalla Edit Item con los detalles de la entidad.
  • Actualiza la entidad en la base de datos con Room.

Agrega una prueba de DAO

  1. En ItemDaoTest.kt, agrega una prueba llamada daoGetItem_returnsItemFromDB().
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB()
  1. Define la función. Dentro de la corrutina, agrega un elemento a la base de datos.
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
    addOneItemToDb()
}
  1. Recupera la entidad de la base de datos con la función itemDao.getItem() y establécela en un val llamado item.
val item = itemDao.getItem(1)
  1. Compara el valor real con el valor recuperado y afirma con assertEquals(). La prueba completada se verá de la siguiente manera:
@Test
@Throws(Exception::class)
fun daoGetItem_returnsItemFromDB() = runBlocking {
    addOneItemToDb()
    val item = itemDao.getItem(1)
    assertEquals(item.first(), item1)
}
  1. Ejecuta la prueba y asegúrate de que sea exitosa.

Propaga campos de texto

Si ejecutas la app, ve a la pantalla Item Details y haz clic en el BAF. Verás que el título de la pantalla ahora es Edit Item. Sin embargo, todos los campos de texto están vacíos. En este paso, completarás los campos de texto en la pantalla Edit Item con los detalles de la entidad.

Pantalla Item Details con el botón Sell inhabilitado

Pantalla Edit Item con campos vacíos

  1. En ItemDetailsScreen.kt, desplázate hasta el elemento componible ItemDetailsScreen.
  2. En FloatingActionButton(), cambia el argumento onClick para incluir uiState.value.itemDetails.id, que es el id de la entidad seleccionada. Debes usar este id para recuperar los detalles de la entidad.
FloatingActionButton(
    onClick = { navigateToEditItem(uiState.value.itemDetails.id) },
    modifier = /*...*/
)
  1. En la clase ItemEditViewModel, agrega un bloque init.
init {

}
  1. Dentro del bloque init, inicia una corrutina con viewModelScope.launch.
import kotlinx.coroutines.launch

viewModelScope.launch { }
  1. Dentro del bloque launch, recupera los detalles de la entidad con itemsRepository.getItemStream(itemId).
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first

init {
    viewModelScope.launch {
        itemUiState = itemsRepository.getItemStream(itemId)
            .filterNotNull()
            .first()
            .toItemUiState(true)
    }
}

En este bloque de lanzamiento, se agrega un filtro para devolver un flujo que solo contenga valores que no son nulos. Con toItemUiState(), conviertes la entidad item en ItemUiState. Pasas el valor actionEnabled como true para habilitar el botón Save.

Para resolver el error Unresolved reference: itemsRepository, debes pasar ItemsRepository como una dependencia al modelo de vista.

  1. Agrega un parámetro de constructor a la clase ItemEditViewModel.
class ItemEditViewModel(
    savedStateHandle: SavedStateHandle,
    private val itemsRepository: ItemsRepository
)
  1. En el archivo AppViewModelProvider.kt, en el inicializador ItemEditViewModel, agrega el objeto ItemsRepository como argumento.
initializer {
    ItemEditViewModel(
        this.createSavedStateHandle(),
        inventoryApplication().container.itemsRepository
    )
}
  1. Ejecuta la app.
  2. Ve a Item Details y presiona el BAF 2ae4a1588eba091b.png.
  3. Observa que los campos se propagan con los detalles del elemento.
  4. Edita la cantidad en stock o cualquier otro campo, y presiona el botón Save.

No pasa nada. Eso se debe a que no estás actualizando la entidad en la base de datos de la app. Solucionarás este problema en la próxima sección.

Pantalla Item Details con el botón Sell inhabilitado

Pantalla Edit Item con un campo vacío

Actualiza la entidad con Room

En esta tarea final, agregarás las partes finales del código para implementar la funcionalidad de actualización. Definirás las funciones necesarias en el ViewModel y las usarás en el ItemEditScreen.

Es hora de volver a programar.

  1. En la clase ItemEditViewModel, agrega una función llamada updateUiState() que tome un objeto ItemUiState y no devuelva nada. Esta función actualiza itemUiState con los valores nuevos que ingresa el usuario.
fun updateUiState(itemDetails: ItemDetails) {
    itemUiState =
        ItemUiState(itemDetails = itemDetails, isEntryValid = validateInput(itemDetails))
}

En esta función, asignarás el itemDetails pasado a itemUiState y actualizarás el valor isEntryValid. La app habilita el botón Save si itemDetails es true. Establece este valor en true solo si la entrada que ingresa el usuario es válida.

  1. Ve al archivo ItemEditScreen.kt.
  2. En el elemento componible ItemEditScreen, desplázate hacia abajo hasta la llamada a función ItemEntryBody().
  3. Establece el valor del argumento onItemValueChange en la nueva función updateUiState.
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = { },
    modifier = modifier.padding(innerPadding)
)
  1. Ejecuta la app.
  2. Ve a la pantalla Edit Item.
  3. Deja uno de los valores de la entidad en blanco para que no sea válido. Observa cómo el botón Save se inhabilita automáticamente.

Pantalla Item Details con el botón Sell habilitado

Pantalla Edit Item con todos los campos de texto completos y el botón Save habilitado

Pantalla Edit Item con el botón Save inhabilitado

  1. Vuelve a la clase ItemEditViewModel y agrega una función suspend llamada updateItem() que no tome nada. Usarás esta función para guardar la entidad actualizada en la base de datos de Room.
suspend fun updateItem() {
}
  1. Dentro de la función getUpdatedItemEntry(), agrega una condición if para validar la entrada del usuario usando la función validateInput().
  2. Realiza una llamada a la función updateItem() en itemsRepository y pasa itemUiState.itemDetails.toItem(). Las entidades que se pueden agregar a la base de datos de Room deben ser del tipo Item. La función completada se ve de la siguiente manera:
suspend fun updateItem() {
    if (validateInput(itemUiState.itemDetails)) {
        itemsRepository.updateItem(itemUiState.itemDetails.toItem())
    }
}
  1. Vuelve al elemento componible ItemEditScreen. Necesitas un alcance de corrutinas para llamar a la función updateItem(). Crea un val llamado coroutineScope y establécelo en rememberCoroutineScope().
import androidx.compose.runtime.rememberCoroutineScope

val coroutineScope = rememberCoroutineScope()
  1. En la llamada a función ItemEntryBody(), actualiza el argumento de función onSaveClick para iniciar una corrutina en coroutineScope.
  2. Dentro del bloque launch, llama a updateItem() en viewModel y navega hacia atrás.
import kotlinx.coroutines.launch

onSaveClick = {
    coroutineScope.launch {
        viewModel.updateItem()
        navigateBack()
    }
},

La llamada a la función ItemEntryBody() completada se ve de la siguiente manera:

ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = {
        coroutineScope.launch {
            viewModel.updateItem()
            navigateBack()
        }
    },
    modifier = modifier.padding(innerPadding)
)
  1. Ejecuta la app y edita los elementos del inventario. Ahora puedes editar cualquier elemento en la base de datos de la app de Inventory.

Pantalla Edit Item con los detalles del elemento editados

Pantalla Item Details con detalles del elemento actualizados

Felicitaciones por crear tu primera app que usa Room para administrar la base de datos.

9. Código de solución

El código de la solución para este codelab se encuentra en el repositorio y la rama de GitHub que se muestran a continuación:

10. Más información

Documentación para desarrolladores de Android

Referencias de Kotlin