Proyecto final: aplicación Android moderna en Kotlin de principio a fin

Capítulo 14

Tiempo estimado de lectura: 11 minutos

+ Ejercicio

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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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 String en Room.
  • Si usas el selector de documentos (OpenDocument) podrías necesitar persistir permisos de lectura; con GetContent suele 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)

EscenarioPasosResultado esperado
Carga inicialAbrir app con red disponibleListado muestra elementos; no hay bloqueos; loading desaparece
RefrescoPulsar refrescarSe muestra loading; al terminar, lista se actualiza o se mantiene; si falla, aparece error con reintento
OfflineAbrir app sin red (modo avión) con datos ya cacheadosListado muestra datos locales; refresco falla con error recuperable
FavoritosMarcar favorito en detalle; volver a listado y a favoritosEl estado favorito se refleja en todas las pantallas
Persistencia favoritosMarcar favorito; cerrar app; abrirFavorito se conserva
BúsquedaEscribir texto en búsquedaLista filtra; si no hay resultados, estado vacío
ImagenEn detalle, seleccionar imagen; volver y reabrir detalleImagen se muestra y persiste
Cancelación selectorAbrir selector y cancelarNo hay crash; no cambia la imagen
Navegación rápidaAbrir/cerrar detalle repetidamenteNo 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 UiState y que el composable lea ese estado (no una copia local).
  • Favoritos se pierden tras refrescar: revisa el merge en refresh(); asegúrate de conservar isFavorite e imageUri del 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, OpenDocument y 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).

Ahora responde el ejercicio sobre el contenido:

¿Qué acción del repositorio ayuda a mantener consistentes los favoritos y la imagen asociada cuando se refrescan los datos desde la API?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Al refrescar desde la API, el repositorio debe combinar datos remotos con los locales y preservar campos locales (como isFavorite e imageUri) para que no se pierdan preferencias del usuario al reemplazar el contenido.

Portada de libro electrónico gratuitaAndroid desde Cero con Kotlin
100%

Android desde Cero con Kotlin

Nuevo curso

14 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.