Objetivo del proyecto y alcance
En este proyecto final vas a integrar, en una sola aplicación, los componentes clave de una app Android moderna: UI con Jetpack Compose, navegación, ViewModel, repositorio, base de datos local con Room, consumo de API, manejo consistente de estados y errores, y una funcionalidad del dispositivo (selector de imágenes). La meta es que termines con una app funcional, coherente y mantenible, construida por capas (datos, dominio, UI) y con entregables verificables.
Idea de app (propuesta): “Catalog”
Una app que muestra una lista de elementos (por ejemplo, “productos”, “recetas” o “lugares”), permite ver detalle, marcar favoritos (persistidos localmente), refrescar desde una API y asociar una imagen local a un elemento usando el selector de imágenes del sistema.
Entregables del proyecto (lo que debes construir)
Pantallas requeridas
- Home/Listado: lista paginada simple o lista con scroll; muestra título, subtítulo y estado (favorito/no). Incluye búsqueda por texto (local) y botón de refresco.
- Detalle: muestra información completa del elemento, botón para marcar/desmarcar favorito, y sección de imagen: ver imagen asociada o seleccionar una nueva desde el dispositivo.
- Favoritos: lista filtrada de favoritos (desde Room) con navegación al detalle.
- Ajustes/Info (simple): muestra versión y un toggle de preferencia (por ejemplo, “solo mostrar disponibles”), persistido localmente (puede ser DataStore si ya lo tienes, o Room si prefieres mantenerlo en una sola tecnología).
- Estados globales: pantalla/overlay de carga, error recuperable con reintento, y estado vacío (sin resultados).
Modelos de datos (mínimos)
- Network DTO: modelo tal como llega de la API.
- Entity (Room): modelo para persistencia local (incluye campos extra como favorito e imagenUri).
- Domain Model: modelo que usa la UI y casos de uso (independiente de Room/Retrofit).
Casos de uso (dominio)
GetItemsUseCase: obtiene lista combinando caché local + refresco remoto (según estrategia).GetItemDetailUseCase: obtiene detalle desde local (y opcionalmente sincroniza).ToggleFavoriteUseCase: marca/desmarca favorito y persiste.SearchItemsUseCase: filtra por texto (idealmente en DB para listas grandes).SetItemImageUseCase: guarda la URI de imagen seleccionada asociada al elemento.RefreshItemsUseCase: fuerza sincronización con API y actualiza DB.
Criterios de aceptación (checklist)
- La app navega entre Listado, Detalle y Favoritos sin crashes.
- El listado muestra datos aunque no haya red (si ya se cargaron antes) y comunica claramente estados (cargando, vacío, error).
- El usuario puede marcar favoritos y se conservan al reiniciar la app.
- El usuario puede seleccionar una imagen desde el selector del sistema y queda asociada al elemento (se ve al volver a abrir el detalle).
- Errores de red muestran mensaje y acción de reintento; no bloquean permanentemente la UI.
- La arquitectura respeta capas: UI no conoce DTO ni Entity; repositorio no depende de Compose.
Arquitectura propuesta (por capas)
Estructura de paquetes recomendada
com.tuapp.catalog ├─ data │ ├─ local (Room) │ ├─ remote (Retrofit) │ ├─ repository (implementaciones) │ └─ mapper (dto/entity/domain) ├─ domain │ ├─ model │ ├─ repository (interfaces) │ └─ usecase ├─ ui │ ├─ navigation │ ├─ screen (list/detail/favorites/settings) │ ├─ component │ └─ state (UiState, UiEvent) └─ di (si usas Hilt/Koin)Reglas de dependencia
- ui depende de domain.
- domain no depende de nadie (solo Kotlin).
- data depende de domain (implementa interfaces).
Secuencia de implementación paso a paso (de datos a UI)
Paso 1: Contratos del dominio (interfaces y modelos)
Define primero el modelo de dominio y el contrato del repositorio. Esto evita que la UI se acople a detalles de Room/Retrofit.
data class Item( val id: String, val title: String, val subtitle: String, val description: String, val isFavorite: Boolean, val imageUri: String?)interface ItemRepository { suspend fun getItems(query: String? = null): List<Item> suspend fun getItem(id: String): Item? suspend fun toggleFavorite(id: String): Item? suspend fun setItemImage(id: String, imageUri: String?): Item? suspend fun refresh(): Result<Unit>}Nota: Result<Unit> te permite propagar error sin lanzar excepciones a la UI. Si prefieres un tipo propio, crea un sealed class (por ejemplo, AppResult).
Paso 2: Capa data (Room + Retrofit + mappers)
Implementa DTO, Entity y mappers. Mantén los mappers en un paquete dedicado para que el repositorio sea legible.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
Entity (Room) sugerida
@Entity(tableName = "items")data class ItemEntity( @PrimaryKey val id: String, val title: String, val subtitle: String, val description: String, val isFavorite: Boolean = false, val imageUri: String? = null, val updatedAt: Long = System.currentTimeMillis())DAO mínimo
@Dao interface ItemDao { @Query("SELECT * FROM items ORDER BY title") suspend fun getAll(): List<ItemEntity> @Query("SELECT * FROM items WHERE id = :id LIMIT 1") suspend fun getById(id: String): ItemEntity? @Query("SELECT * FROM items WHERE title LIKE '%' || :q || '%' OR subtitle LIKE '%' || :q || '%' ORDER BY title") suspend fun search(q: String): List<ItemEntity> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(items: List<ItemEntity>) @Update suspend fun update(item: ItemEntity)}Servicio remoto (Retrofit) mínimo
interface ItemApi { @GET("items") suspend fun getItems(): List<ItemDto> @GET("items/{id}") suspend fun getItem(@Path("id") id: String): ItemDto}Repositorio: estrategia de datos
El repositorio decide cómo combinar local y remoto. Una estrategia simple y efectiva para este proyecto:
- Lectura: primero desde Room (rápido, offline).
- Refresco: cuando el usuario lo pide (pull-to-refresh o botón), llama API y actualiza Room.
- Merge: al refrescar, conserva campos locales (favorito, imageUri) al reemplazar datos remotos.
class ItemRepositoryImpl( private val dao: ItemDao, private val api: ItemApi) : ItemRepository { override suspend fun getItems(query: String?): List<Item> { val local = if (query.isNullOrBlank()) dao.getAll() else dao.search(query) return local.map { it.toDomain() } } override suspend fun refresh(): Result<Unit> = runCatching { val remote = api.getItems() val localById = dao.getAll().associateBy { it.id } val merged = remote.map { dto -> val local = localById[dto.id] dto.toEntity( isFavorite = local?.isFavorite ?: false, imageUri = local?.imageUri) } dao.upsertAll(merged) } override suspend fun getItem(id: String): Item? = dao.getById(id)?.toDomain() override suspend fun toggleFavorite(id: String): Item? { val current = dao.getById(id) ?: return null val updated = current.copy(isFavorite = !current.isFavorite) dao.update(updated) return updated.toDomain() } override suspend fun setItemImage(id: String, imageUri: String?): Item? { val current = dao.getById(id) ?: return null val updated = current.copy(imageUri = imageUri) dao.update(updated) return updated.toDomain() }}Consejo: si tu API es grande, evita dao.getAll() dentro de refresh() y usa una consulta por ids o una tabla auxiliar; para el proyecto final, esta simplificación es aceptable.
Paso 3: Casos de uso (dominio)
Los casos de uso encapsulan acciones de negocio y hacen que el ViewModel sea más simple.
class RefreshItemsUseCase(private val repo: ItemRepository) { suspend operator fun invoke(): Result<Unit> = repo.refresh()}class ToggleFavoriteUseCase(private val repo: ItemRepository) { suspend operator fun invoke(id: String): Item? = repo.toggleFavorite(id)}class SetItemImageUseCase(private val repo: ItemRepository) { suspend operator fun invoke(id: String, uri: String?): Item? = repo.setItemImage(id, uri)}Paso 4: UI State, eventos y manejo de errores
Define un estado de UI por pantalla. Mantén el estado como una sola fuente de verdad para Compose.
sealed class UiError { data class Network(val message: String) : UiError() data class Unknown(val message: String) : UiError()}data class ListUiState( val query: String = "", val isLoading: Boolean = false, val items: List<Item> = emptyList(), val error: UiError? = null, val isEmpty: Boolean = false)Regla práctica: isEmpty se deriva de items.isEmpty() y de si no estás cargando; puedes calcularlo en el ViewModel para simplificar la UI.
Paso 5: ViewModels (orquestación)
El ViewModel coordina casos de uso, actualiza el estado y expone funciones para acciones del usuario.
class ListViewModel( private val getItems: suspend (String) -> List<Item>, private val refresh: RefreshItemsUseCase) : ViewModel() { var state by mutableStateOf(ListUiState()) private set fun onQueryChange(q: String) { state = state.copy(query = q) load() } fun load() { viewModelScope.launch { state = state.copy(isLoading = true, error = null) try { val items = getItems(state.query) state = state.copy( isLoading = false, items = items, isEmpty = items.isEmpty()) } catch (t: Throwable) { state = state.copy(isLoading = false, error = UiError.Unknown(t.message ?: "Error")) } } } fun onRefresh() { viewModelScope.launch { state = state.copy(isLoading = true, error = null) val result = refresh() result.exceptionOrNull()?.let { e -> state = state.copy(error = UiError.Network(e.message ?: "Fallo de red")) } load() } }}En tu implementación real, getItems debería ser un caso de uso (por ejemplo, GetItemsUseCase) y conviene usar Flow si ya lo vienes usando. Aquí se muestra una versión directa para enfatizar la secuencia.
Paso 6: Navegación (Compose Navigation) y rutas
Define rutas claras y parámetros mínimos.
sealed class Route(val path: String) { data object List : Route("list") data object Favorites : Route("favorites") data object Settings : Route("settings") data object Detail : Route("detail/{id}") { fun create(id: String) = "detail/$id" }}En el grafo de navegación, conecta:
- List -> Detail (con id)
- Favorites -> Detail (con id)
- List -> Favorites
- List -> Settings
Paso 7: Pantallas Compose (con estados y acciones)
Implementa cada pantalla consumiendo su UiState y callbacks. Evita lógica de negocio en composables.
Patrón recomendado de pantalla
@Composable fun ListScreen( state: ListUiState, onQueryChange: (String) -> Unit, onItemClick: (String) -> Unit, onRefresh: () -> Unit, onRetry: () -> Unit) { // 1) Barra de búsqueda // 2) Loading indicator si state.isLoading // 3) Error banner si state.error != null con botón Reintentar // 4) Empty state si state.isEmpty // 5) Lista de items con click -> onItemClick(id)}En Detalle, muestra botón de favorito y sección de imagen (ver siguiente paso).
Paso 8: Funcionalidad del dispositivo: selector de imágenes
Usa el selector del sistema para elegir una imagen y guarda la URI en tu base local asociada al ítem. La UI solo necesita disparar la acción y renderizar la URI.
Integración en Compose (Activity Result API)
@Composable fun DetailImageSection( imageUri: String?, onPickImage: (String) -> Unit) { val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri -> if (uri != null) onPickImage(uri.toString()) } // Botón: "Seleccionar imagen" -> launcher.launch("image/*") // Render: si imageUri != null, mostrar imagen con AsyncImage/Coil (si lo usas) // Si no, placeholder visual}En el ViewModel de detalle, implementa onPickImage(uriString) llamando a SetItemImageUseCase y actualiza el estado.
Persistencia de la URI y consideraciones
- Guarda la URI como
Stringen Room. - Si usas el selector de documentos (
OpenDocument) podrías necesitar persistir permisos de lectura; conGetContentsuele ser suficiente para casos simples, pero prueba reinicios de app. - Valida que la URI no sea nula y maneja el caso de cancelación sin error.
Plan de implementación por iteraciones (recomendado)
Iteración 1: Esqueleto navegable
- Crear rutas y grafo de navegación.
- Crear pantallas con UI mínima (texto y botones) y navegación funcional.
- Crear modelos de dominio y contratos del repositorio (sin implementación real aún).
Iteración 2: Persistencia local primero
- Implementar Room (Entity, DAO, DB) y repositorio con lectura local.
- Sembrar datos de prueba (por ejemplo, insert inicial en debug) para validar UI.
- Favoritos funcionando 100% local.
Iteración 3: Integración remota (refresco)
- Implementar Retrofit y
refresh()en repositorio. - Merge de datos remotos con campos locales (favorito, imageUri).
- Estados de carga y error en listado.
Iteración 4: Selector de imágenes
- Agregar sección de imagen en detalle.
- Guardar URI en Room y verificar persistencia tras reinicio.
Iteración 5: Pulido de estados y UX
- Empty states (sin resultados, sin favoritos).
- Reintentos en errores de red.
- Deshabilitar acciones durante carga cuando corresponda.
Revisión final: pruebas manuales y corrección de fallos
Checklist de pruebas manuales (pasos concretos)
| Escenario | Pasos | Resultado esperado |
|---|---|---|
| Carga inicial | Abrir app con red disponible | Listado muestra elementos; no hay bloqueos; loading desaparece |
| Refresco | Pulsar refrescar | Se muestra loading; al terminar, lista se actualiza o se mantiene; si falla, aparece error con reintento |
| Offline | Abrir app sin red (modo avión) con datos ya cacheados | Listado muestra datos locales; refresco falla con error recuperable |
| Favoritos | Marcar favorito en detalle; volver a listado y a favoritos | El estado favorito se refleja en todas las pantallas |
| Persistencia favoritos | Marcar favorito; cerrar app; abrir | Favorito se conserva |
| Búsqueda | Escribir texto en búsqueda | Lista filtra; si no hay resultados, estado vacío |
| Imagen | En detalle, seleccionar imagen; volver y reabrir detalle | Imagen se muestra y persiste |
| Cancelación selector | Abrir selector y cancelar | No hay crash; no cambia la imagen |
| Navegación rápida | Abrir/cerrar detalle repetidamente | No hay estados inconsistentes ni errores |
Guía de depuración de fallos comunes
- La UI no se actualiza tras una acción: verifica que el ViewModel esté actualizando el
UiStatey que el composable lea ese estado (no una copia local). - Favoritos se pierden tras refrescar: revisa el merge en
refresh(); asegúrate de conservarisFavoriteeimageUridel local. - Crashes por null: en detalle, maneja
Item?(id inválido) mostrando error y opción de volver. - Errores de red bloquean la pantalla: el estado de error debe permitir reintento y no impedir renderizar datos locales.
- Imagen no persiste: confirma que guardas la URI en Room y que el detalle lee desde DB; si usas un contrato que requiere permisos persistentes, ajusta la estrategia (por ejemplo,
OpenDocumenty persistir permisos). - Duplicación de lógica: si varias pantallas repiten manejo de loading/error, crea componentes Compose reutilizables (banner de error, empty state, loader).