Universidad del Quindío
Programa de Ingeniería de Sistemas y Computación
Título: Entidades y datos en Jetpack Compose
Docente: Carlos Andrés Florez V.

Entidades del Dominio

Introducción

Toda aplicación que gestione datos debe tener definidas sus entidades del dominio. Una entidad del dominio representa un objeto o concepto central en el sistema. Por ejemplo, en una aplicación de gestión de tareas, una entidad del dominio podría ser “Tarea”, que tendría atributos como título, descripción, fecha de vencimiento y estado. En una aplicación de comercio electrónico, una entidad del dominio podría ser “Producto”, con atributos como nombre, precio, descripción y categoría.

Definir claramente las entidades del dominio es crucial para el diseño y desarrollo de la aplicación, ya que estas entidades forman la base sobre la cual se construyen las funcionalidades y la lógica del negocio.

Definición de Entidades del Dominio

De ahora en adelante, a manera de ejemplo, trabajaremos con una aplicación donde los ciudadanos (users) reportan problemas urbanos usando un mapa, y los administradores (admin) gestionan, validan y dan seguimiento a esos reportes.

Cada reporte (Report) tendrá los siguientes atributos:

Adicionalmente, la ubicación (Location) tendrá:

Por otro lado, cada usuario (User) tendrá los siguientes atributos:

Por lo tanto, debemos crear tres entidades de dominio: Report, User y Location. Y dos enumeraciones: ReportStatus y UserRole.

Creación de las Entidades del Dominio

En el paquete domain.model, cree los siguientes archivos de Kotlin para definir las entidades y enumeraciones mencionadas:

Para la entidad Report, cree un archivo llamado Report.kt y defina la clase de la siguiente manera:

data class Report(
    val id: String,
    val title: String,
    val description: String,
    val location: Location,
    val status: ReportStatus,
    val type: String,
    val photoUrl: String,
    val ownerId: String,
    val date: LocalDate
)

El atributo date representa la fecha en que se creó el reporte y usa la clase LocalDate del paquete java.time (recuerde agregar el import java.time.LocalDate). Nos servirá, por ejemplo, para ordenar los reportes del más reciente al más antiguo.

Para la entidad Location, cree un archivo llamado Location.kt y defina la clase de la siguiente manera:

data class Location(
    val latitude: Double,
    val longitude: Double
)

Para la entidad User, cree un archivo llamado User.kt y defina la clase de la siguiente manera:

data class User (
    val id: String,
    val name: String,
    val city: String,
    val address: String,
    val email: String,
    val password: String,
    val phoneNumber: String = "",
    val profilePictureUrl: String = "",
    val role: UserRole = UserRole.USER
)

Los atributos phoneNumber y profilePictureUrl son opcionales y tienen valores predeterminados vacíos. El atributo role también tiene un valor predeterminado de UserRole.USER.

Para la enumeración ReportStatus, cree un archivo llamado ReportStatus.kt y defina la enumeración de la siguiente manera:

enum class ReportStatus {
    PENDING,  // Pendiente de revisión por un administrador
    VERIFIED, // Verificado: el reporte fue revisado y aprobado
    REJECTED, // Rechazado: el reporte no procede
    RESOLVED, // Resuelto: el problema reportado ya fue solucionado
    DELETED   // Eliminado: borrado lógico del reporte
}

Finalmente, para la enumeración UserRole, cree un archivo llamado UserRole.kt y defina la enumeración de la siguiente manera:

enum class UserRole {
    USER,
    ADMIN
}

Con base en estas definiciones, iremos integrando nuevas pantallas y composables para gestionar los reportes en la aplicación. La entidad User la usaremos principalmente para el registro, el inicio de sesión y el manejo de roles; el protagonista de las pantallas que construiremos será el reporte.

⚠️ Importante: La diferencia entre una clase y una data class en Kotlin es que una data class está diseñada para almacenar datos y proporciona automáticamente métodos útiles como equals(), hashCode(), toString(), y copy(). Esto facilita la manipulación y comparación de objetos de datos. En contraste, una clase normal no tiene estas funcionalidades automáticas y se utiliza para definir comportamientos más complejos.


Múltiples Elementos en Compose

Es común que las aplicaciones móviles se requiera mostrar múltiples elementos en la pantalla, como listas de datos, galerías de imágenes o menús. En Jetpack Compose, existen varias formas de manejar y mostrar múltiples elementos de manera eficiente y flexible. Esto será importante para nuestra aplicación de reportes urbanos, donde necesitaremos mostrar la lista de reportes.

Composables para Múltiples Elementos

LazyColumn y LazyRow

LazyColumn y LazyRow son componentes que permiten mostrar listas de elementos de manera eficiente, cargando solo los elementos visibles en pantalla. Esto es especialmente útil para listas largas, ya que mejora el rendimiento de la aplicación. Además, estos componentes incluyen scroll automático.

LazyColumn {
    items(itemsList) { item ->
        Text(text = item.name)
    }
}

Supongamos que itemsList es una lista de objetos que contienen un atributo name. El código anterior crea una columna perezosa que muestra el nombre de cada elemento en la lista, renderizando solo los elementos visibles.

Así mismo, podemos usar LazyRow para mostrar los elementos en una fila horizontal:

LazyRow {
    items(itemsList) { item ->
        Text(text = item.name)
    }
}

Grid

Para mostrar elementos en una cuadrícula, podemos usar LazyVerticalGrid o LazyHorizontalGrid. Estos componentes permiten organizar los elementos en filas y columnas formando una cuadrícula.

LazyVerticalGrid(
    columns = GridCells.Fixed(2)
) {
    items(itemsList) { item ->
        Text(text = item.name)
    }
}

En este ejemplo, LazyVerticalGrid crea una cuadrícula con dos columnas, mostrando los nombres de los elementos en la lista.

Para más información sobre cómo usar LazyColumn, LazyRow y LazyVerticalGrid, puede consultar la documentación oficial de Jetpack Compose: Layouts en Jetpack Compose.

Representación gráfica

La siguiente imagen muestra cómo se vería una lista de elementos utilizando LazyColumn, LazyRow y LazyVerticalGrid, donde cada elemento de la lista se representa con un composable en sí mismo.

Ejemplo de LazyColumn con ListItem Fuente: Medium - Jetpack Compose LazyColumn

Estos composables son fundamentales para mostrar listas de datos en las aplicaciones ya que permiten una representación eficiente y flexible de múltiples elementos si los comparamos con un Column o Row tradicional, que renderizaría todos los elementos de la lista, lo que podría afectar el rendimiento si la lista es larga.


Ejemplo: Listado de Reportes y su Detalle

Cree una pantalla llamada ReportListScreen que muestre una lista de reportes utilizando LazyColumn. Cada reporte debe mostrarse en un ListItem, y al hacer clic en un reporte, la idea es poder navegar a una pantalla de detalles del reporte (aunque la navegación no se implementará en este momento).

1. Crear la pantalla de la lista de reportes

Cree un nuevo composable llamado ReportListScreen que muestre una lista de reportes, este composable debe crearse en features/report/list:

package com.example.demoapp.features.report.list

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.example.demoapp.domain.model.Report

@Composable
fun ReportListScreen(
    onNavigateToReportDetail: (String) -> Unit, // Función para navegar a la pantalla de detalles del reporte (recibe el ID del reporte)
    padding: PaddingValues = PaddingValues(), // Espaciado que más adelante recibirá del Scaffold (barras superior/inferior)
    reportsViewModel: ReportListViewModel = viewModel()
){
    // Obtener la lista de reportes desde el ViewModel
    val reports by reportsViewModel.reports.collectAsState(initial = emptyList())

    // Se usa LazyColumn para mostrar la lista de reportes. 
    // LazyColumn solo renderiza los elementos visibles en pantalla, mejorando el rendimiento, además integra scrolling automáticamente.
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        // contentPadding aplica el espaciado del Scaffold sin recortar el scroll de la lista
        contentPadding = padding
    ) {
        // Iterar sobre la lista de reportes y crear un ItemReport para cada uno
        items(reports) {
            ItemReport(
                onNavigateToReportDetail = onNavigateToReportDetail,
                report = it
            )
        }
    }
}

@Composable
fun ItemReport(
    onNavigateToReportDetail: (String) -> Unit, // Función para navegar a la pantalla de detalles del reporte (recibe el ID del reporte)
    report: Report
){
    // ListItem es un composable que muestra un elemento de una lista con un diseño predefinido. 
    ListItem(
        modifier = Modifier
            .clip(MaterialTheme.shapes.small)
            .clickable {
                // Sabemos que al hacer clic en el ListItem, se navega a la pantalla de detalles del reporte, pasando el ID del reporte seleccionado, pero no se implementa la navegación aquí.
                onNavigateToReportDetail(report.id)
            },
        headlineContent = {
            Text(text = report.title)
        },
        supportingContent = {
            // Mostrar el estado del reporte como contenido secundario (puede ajustarse según se desee)
            Text(text = report.status.name)
        },
        leadingContent = {
            // Mostrar la foto del problema reportado
            AsyncImage(
                contentScale = ContentScale.Crop,
                model = ImageRequest.Builder(LocalContext.current)
                    .data(report.photoUrl) // URL de la imagen
                    .crossfade(true) // Efecto de desvanecimiento al cargar
                    .build(),
                contentDescription = "Foto del reporte",
                modifier = Modifier
                    .clip(RoundedCornerShape(16.dp))
                    .size(80.dp)
            )
        }
    )
}

Observe que ReportListScreen utiliza un ViewModel llamado ReportListViewModel para obtener la lista de reportes. Cada reporte se muestra en un ListItem, y al hacer clic en un reporte, se llama a la función onNavigateToReportDetail, pasando el ID del reporte seleccionado.

Para obtener la lista de reportes, se usa collectAsState para observar los cambios en el StateFlow del ViewModel. Esto asegura que la UI se actualice automáticamente cuando la lista de reportes cambie.

2. Crear el ReportListViewModel

Cree el ReportListViewModel en el mismo paquete de ReportListScreen, con el siguiente código:

package com.example.demoapp.features.report.list

import androidx.lifecycle.ViewModel
import com.example.demoapp.domain.model.Location
import com.example.demoapp.domain.model.Report
import com.example.demoapp.domain.model.ReportStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.time.LocalDate

class ReportListViewModel: ViewModel() {
    // Patrón de StateFlow para manejar el estado de la lista de reportes
    private val _reports = MutableStateFlow(emptyList<Report>())
    val reports: StateFlow<List<Report>> = _reports.asStateFlow()

    // Inicializar con algunos datos de ejemplo
    init {
        fetchReports()
    }

    // Función para obtener un reporte por su ID
    fun findById(id: String): Report? {
        return _reports.value.find { it.id == id }
    }

    // Función para agregar un nuevo reporte a la lista
    fun save(report: Report) {
        _reports.value += report
    }

    // Función para simular algunos datos de reportes
    private fun fetchReports() {
        val reports = listOf(
            Report(
                id = "1",
                title = "Hueco en la vía",
                description = "Hueco grande en plena calzada, los vehículos deben esquivarlo",
                location = Location(latitude = 4.5339, longitude = -75.6811),
                status = ReportStatus.PENDING,
                type = "Infraestructura",
                photoUrl = "https://picsum.photos/200?random=1",
                ownerId = "1",
                date = LocalDate.now()
            ),
            Report(
                id = "2",
                title = "Alumbrado dañado",
                description = "Poste de luz sin funcionar desde hace una semana",
                location = Location(latitude = 4.5402, longitude = -75.6658),
                status = ReportStatus.VERIFIED,
                type = "Alumbrado",
                photoUrl = "https://picsum.photos/200?random=2",
                ownerId = "2",
                date = LocalDate.now().minusDays(3)
            ),
            Report(
                id = "3",
                title = "Basura acumulada",
                description = "Acumulación de basura en la esquina del parque",
                location = Location(latitude = 4.5475, longitude = -75.6585),
                status = ReportStatus.RESOLVED,
                type = "Basura",
                photoUrl = "https://picsum.photos/200?random=3",
                ownerId = "1",
                date = LocalDate.now().minusWeeks(1)
            )
        )
        _reports.value = reports
    }
}

El View Model inicializa una lista de reportes de ejemplo y la expone como un StateFlow para que la UI pueda observarla. Además, incluye la función save, que nos permitirá agregar nuevos reportes a la lista más adelante.

3. Crear la pantalla de detalles del reporte

Cree un nuevo composable en features/report/detail llamado ReportDetailScreen que reciba el ID del reporte como parámetro y muestre los detalles del reporte. Una implementación básica podría ser la siguiente:

package com.example.demoapp.features.report.detail

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

@Composable
fun ReportDetailScreen(
    reportId: String, // Recibe el ID del reporte como parámetro
    padding: PaddingValues = PaddingValues() // Espaciado que más adelante recibirá del Scaffold (barras superior/inferior)
){
    // Un Box es un contenedor simple que ubica a sus hijos uno encima del otro.
    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(padding) // Aplica el espaciado del Scaffold para no quedar tapado por las barras
    ){
        Text(text = "Report Detail Screen $reportId")
    }
}

💡 Agregamos el parámetro padding (con un valor por defecto, para que la pantalla siga funcionando por sí sola) pensando en la guía de navegación: cuando estas pantallas se coloquen dentro de un Scaffold con barras superior e inferior, el Scaffold entrega un PaddingValues que debemos aplicar para que el contenido no quede oculto detrás de esas barras.

Esta pantalla es muy simple, pero nos servirá para demostrar que la navegación con parámetros funciona correctamente.

4. Ejecutar la aplicación

Por ahora, la navegación no está implementada, pero puede ejecutar la aplicación para asegurarse de que no haya errores de compilación y que la pantalla de lista de reportes se muestre correctamente. Asegúrese de que la pantalla ReportListScreen esté configurada como la pantalla inicial en MainActivity.kt, de la siguiente manera:

setContent {
    DemoAppTheme {
        // Asegúrese de que ReportListScreen sea la pantalla inicial
        ReportListScreen(
            onNavigateToReportDetail = { reportId ->
                // Aquí se implementará la navegación más adelante
            }
        )
    }
}

Si todo está correcto, debería ver la lista de reportes en la pantalla al ejecutar la aplicación. Agregue más reportes en el ReportListViewModel si desea ver más elementos en la lista y pruebe el desplazamiento (scrolling) en la lista.


Actividad práctica

1. Mejorar la pantalla de detalles del reporte

Mejore la pantalla ReportDetailScreen para que muestre los detalles completos del reporte, incluyendo su título, descripción, tipo, estado, ubicación (latitud y longitud) y la foto del problema. Utilice el ReportListViewModel para obtener los datos del reporte basado en el ID recibido como parámetro. Por ahora, para probar puede “quemar” el ID del reporte directamente en la llamada a ReportDetailScreen.

2. Crear un nuevo reporte (aspectos básicos)

Cree una pantalla llamada CreateReportScreen en features/report/create con un formulario básico para crear un nuevo reporte, reutilizando lo aprendido en las guías de formularios: campos para el título, el tipo y la descripción del problema, con sus respectivas validaciones, y un botón “Crear reporte”. Al presionar el botón, construya un objeto Report y agréguelo a la lista mediante la función save del ReportListViewModel.

Por ahora puede usar valores fijos para la ubicación, la foto y el ownerId (para la fecha use LocalDate.now()); más adelante, en las guías de mapas, permisos y autenticación, capturaremos estos datos reales. Para verificar que el reporte se creó, coloque temporalmente CreateReportScreen como pantalla inicial e imprima el reporte en el Logcat (o muestre un mensaje en pantalla) al presionar el botón. El flujo completo (botón en la lista → crear → volver y ver el nuevo reporte) lo armaremos en las guías de navegación y arquitectura.

3. Mejorar la presentación de la lista

Investigue el composable Card en Jetpack Compose y úselo para mejorar la presentación de cada elemento de la lista de reportes, por ejemplo, mostrando la foto, el título, el tipo y el estado del reporte dentro de una tarjeta.