Sabores en red: los datos ya no son tuyos
La misma app de la Práctica 2, pero los restaurantes y las reseñas viven en un servidor que todo el grupo comparte. Un CRUD completo, con los errores que trae el mundo real.
Prerrequisitos. Tu proyecto de la Práctica 2 funcionando, y las corrutinas de la Práctica 3: suspend, launch y viewModelScope. Aquí se usan las tres cosas todo el tiempo.
Relación con el reto. Esta es la práctica que más se parece a tu proyecto final. Cambia de dónde salen los datos sin tocar ni la UI ni las reglas — que es exactamente el premio que quedó anotado en el Paso A3 de la Práctica 2.
Trabaja sobre una copia de tu proyecto de la Práctica 2, no sobre el original: git switch -c red antes de empezar. Si algo se rompe sin remedio, vuelves a main y no perdiste la práctica anterior.
Lo que vas a construir
RestaurantRepository con una lista en memoriastartdroid.com/api. Las reseñas que escribes las ve todo el grupo.Cuando termines, esto tiene que ser cierto
- La lista sale del servidor, y las reseñas de tus compañeros aparecen en ella.
- Publicas una reseña y el promedio cambia — también en el teléfono de junto.
- Con el modo avión encendido la app no truena: avisa y ofrece reintentar.
- Puedes editar y borrar tus reseñas, y la app te lo impide con las ajenas.
- Ningún archivo de
domain/importa nada de Retrofit.
Parte 0 · 13 min · en el navegadorLa API antes del código
Antes de escribir Kotlin, conoce el servidor con el que vas a hablar. Todo esto se ve desde el navegador.
P0.1 — Explora7 min
Abre estas tres direcciones, en este orden:
| Dirección | Qué te devuelve |
|---|---|
| https://startdroid.com/api/health | La lista de todo lo que la API sabe hacer |
| https://startdroid.com/api/restaurants | Los cinco restaurantes, con su promedio |
| https://startdroid.com/api/reviews?restaurantId=3 | Las reseñas de Kaze |
Fíjate en un restaurante cualquiera:
{
"id": 1,
"name": "La Chinampa",
"cuisine": "Mexicana",
"address": "Av. Garza Sada 300",
"description": "Cocina de mercado: tacos de guisado, sopes y agua del día.",
"priceLevel": 1,
"emoji": "🌮",
"ratingCount": 2,
"ratingAverage": 4.5
}
Los primeros siete campos son idénticos a tu data class Restaurant. Los dos últimos son nuevos: el servidor ya calculó el promedio.
P0.2 — Lo que cambia respecto a la Práctica 26 min
Tres diferencias, y las tres tienen consecuencias en el código:
Las reseñas ahora tienen dueño. Traen id y author. Tu data class Review no tiene ninguno de los dos, así que va a tener que crecer.
La base es de todo el grupo. No hay una copia por equipo: si publicas una reseña, la ven todos. Por eso los restaurantes son de solo lectura —cualquier POST a /restaurants responde 405— y por eso cada quien solo puede tocar sus propias reseñas.
Escribir exige firma. Cada petición que crea, edita o borra tiene que traer el encabezado X-Alumno con tu matrícula. Sin él, 401. Con la matrícula de alguien más, no pasa nada: no es seguridad, es evitar accidentes.
| Código | Cuándo | Qué debe hacer tu app |
|---|---|---|
200 / 201 |
Todo bien | Mostrar los datos |
204 |
Borrado con éxito | Quitarla de la lista |
401 |
No mandaste X-Alumno |
Es un bug tuyo, no del usuario |
403 |
La reseña es de alguien más | Avisar, sin dejar la app rota |
404 |
Ese id no existe | Avisar |
422 |
Los datos no pasan las reglas | Mostrar el mensaje del servidor |
Llenen la columna de la derecha con la pantalla de su app de la Práctica 2.
| Endpoint | ¿Qué pantalla lo usa? | |
|---|---|---|
| 1 | GET /restaurants |
|
| 2 | GET /restaurants/3 |
|
| 3 | GET /reviews?restaurantId=3 |
|
| 4 | POST /reviews |
|
| 5 | GET /me/reviews |
|
| 6 | DELETE /reviews/12 |
Respuestas
1 y 3 los usa la lista y el detalle; el 2 también el detalle. El 4 es la pantalla de nueva reseña, que ya tienes hecha. El 5 es Mis reseñas, que en la Práctica 2 filtraba una lista en memoria y ahora se la pide al servidor. El 6 no tiene pantalla todavía: es lo que construyes en el Bloque D.
Bloque A · 35 minConectar
A1 — Dependencias y permiso8 min
En gradle/libs.versions.toml:
[versions]
retrofit = "3.0.0"
serializationJson = "1.9.0"
okhttp = "4.12.0"
[libraries]
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
En app/build.gradle.kts, el plugin y las cuatro dependencias:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
dependencies {
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json)
implementation(libs.okhttp.logging)
}
Y en el manifiesto, el permiso sin el cual nada de esto funciona:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application …>
okhttp = "4.12.0" y no la más nueva
Si buscas logging-interceptor en Maven, lo más reciente es un 5.0.0-alpha. No lo uses. Retrofit 3.0.0 depende de OkHttp 4.12.0, y mezclar el interceptor de la 5 con el cliente de la 4 rompe el build de formas poco obvias.
La regla general: el interceptor va en la misma versión de OkHttp que arrastra tu cliente HTTP, no en la más nueva que exista.
A2 — Los DTO, y por qué no son el dominio10 min
package mx.tec.sabores.data.remote
import kotlinx.serialization.Serializable
/** Lo que el servidor manda. No es el dominio: es su envoltura de transporte. */
@Serializable
data class RestaurantDto(
val id: Int,
val name: String,
val cuisine: String,
val address: String,
val description: String,
val priceLevel: Int,
val emoji: String,
val ratingAverage: Double = 0.0,
val ratingCount: Int = 0
)
@Serializable
data class ReviewDto(
val id: Int,
val restaurantId: Int,
val author: String,
val stars: Int,
val comment: String,
val createdAt: String
)
/** Lo que se manda al crear. Sin id ni autor: esos los pone el servidor. */
@Serializable
data class NewReviewBody(
val restaurantId: Int,
val stars: Int,
val comment: String
)
@Serializable
data class EditReviewBody(
val stars: Int? = null,
val comment: String? = null
)
Restaurant directamente y ahorrarse una clase?
Porque RestaurantDto es la forma que hoy tiene la respuesta de ese servidor, y Restaurant es lo que tu app entiende por restaurante. El día que la API renombre priceLevel o agregue quince campos, quieres que el temblor se detenga en el DTO.
Es la misma prueba del borrado del Bloque A de la Práctica 2: si tiro la API y pongo otra, ¿qué código tendría que reescribir? Solo data/remote/.
Tu Review del dominio tiene que crecer, porque ahora las reseñas tienen identidad:
data class Review(
val id: Int,
val restaurantId: Int,
val author: String,
val stars: Int,
val comment: String
)
Y los traductores, que van del transporte al dominio:
package mx.tec.sabores.data.remote
import mx.tec.sabores.domain.Restaurant
import mx.tec.sabores.domain.Review
fun RestaurantDto.toDomain() = Restaurant(
id = id,
name = name,
cuisine = cuisine,
address = address,
description = description,
priceLevel = priceLevel,
emoji = emoji
)
fun ReviewDto.toDomain() = Review(
id = id,
restaurantId = restaurantId,
author = author,
stars = stars,
comment = comment
)
El servidor manda ratingAverage ya calculado. Tú tienes RatingSummary.from(reviews) en el dominio, que hace lo mismo. ¿Con cuál te quedas?
Respuesta
Con los dos, y no es contradicción.
ratingAverage sirve para la lista: te ahorra pedir las reseñas de los cinco restaurantes solo para pintar una estrella. RatingSummary.from(...) sirve para el detalle, donde ya tienes las reseñas cargadas y quieres que el promedio se actualice al instante en cuanto publicas la tuya, sin esperar otra vuelta a la red.
Lo que no debes hacer es borrar la regla del dominio porque el servidor "ya la trae". El día que la API cambie, el dominio sigue sabiendo qué es un promedio.
A3 — La interfaz y la firma12 min
package mx.tec.sabores.data.remote
import retrofit2.Response
import retrofit2.http.*
interface SaboresApi {
@GET("restaurants")
suspend fun getRestaurants(): List<RestaurantDto>
@GET("restaurants/{id}")
suspend fun getRestaurant(@Path("id") id: Int): RestaurantDto
@GET("reviews")
suspend fun getReviews(@Query("restaurantId") restaurantId: Int): List<ReviewDto>
@GET("me/reviews")
suspend fun getMyReviews(): List<ReviewDto>
@POST("reviews")
suspend fun createReview(@Body body: NewReviewBody): ReviewDto
@PATCH("reviews/{id}")
suspend fun editReview(@Path("id") id: Int, @Body body: EditReviewBody): ReviewDto
// Response<Unit> para poder leer el código: 204 si era tuya, 403 si no.
@DELETE("reviews/{id}")
suspend fun deleteReview(@Path("id") id: Int): Response<Unit>
}
Todas son suspend. Retrofit las convierte en llamadas que suspenden la corrutina mientras la red trabaja, y devuelven cuando llega la respuesta. Es exactamente el delay() de la Práctica 3, pero con un servidor del otro lado.
package mx.tec.sabores.data.remote
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
object Network {
private const val BASE_URL = "https://startdroid.com/api/"
/** ⚠️ Cambia esto por TU matrícula antes de correr la app. */
var alumno: String = "a01234567"
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
/** Firma cada petición. Así es como viajan las credenciales de verdad. */
private val identidad = Interceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("X-Alumno", alumno)
.build()
chain.proceed(request)
}
private val client = OkHttpClient.Builder()
.addInterceptor(identidad)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
.build()
val api: SaboresApi = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(SaboresApi::class.java)
}
Tres decisiones que vale la pena mirar:
ignoreUnknownKeys = true. Si mañana la API agrega un campo, tu app no truena. Sin esto, un campo nuevo es un SerializationException.
La barra final en BASE_URL. "https://startdroid.com/api/" con / al final. Sin ella, Retrofit resuelve mal las rutas relativas y te da un 404 que cuesta encontrar.
El interceptor. No tienes que acordarte de mandar X-Alumno en cada llamada: se agrega solo, en un único lugar. Cuando en tu proyecto real uses una API con token, ahí es donde va.
A4 — La lista real5 min
El repositorio conserva su nombre y su forma. Por dentro, cambia todo:
package mx.tec.sabores.data
import mx.tec.sabores.data.remote.Network
import mx.tec.sabores.data.remote.SaboresApi
import mx.tec.sabores.data.remote.toDomain
import mx.tec.sabores.domain.Restaurant
import mx.tec.sabores.domain.Review
class RestaurantRepository(private val api: SaboresApi = Network.api) {
suspend fun getAll(): List<Restaurant> =
api.getRestaurants().map { it.toDomain() }
suspend fun getById(id: Int): Restaurant =
api.getRestaurant(id).toDomain()
suspend fun getReviews(restaurantId: Int): List<Review> =
api.getReviews(restaurantId).map { it.toDomain() }
suspend fun getMyReviews(): List<Review> =
api.getMyReviews().map { it.toDomain() }
}
Todos los métodos son suspend ahora, así que quien los llamaba desde el ViewModel tiene que hacerlo dentro de viewModelScope.launch { }.
Falta una pieza: la tarjeta de la lista necesita el promedio, y toDomain() lo tiró. Pedir las reseñas de los cinco restaurantes solo para pintar una estrella serían cinco viajes de más, así que la lista se lleva el promedio que el servidor ya calculó:
package mx.tec.sabores.domain
/**
* Un restaurante con su calificación, tal como se muestra en la lista.
* Vive en el dominio, no en ui/: si viviera en ui/, la capa de datos tendría
* que importar de la capa de arriba para poder devolverlo.
*/
data class RestaurantEnLista(
val restaurant: Restaurant,
val summary: RatingSummary
)
Hace falta un mapeador más, que traduce los dos campos que el servidor calculó a la forma que el dominio ya entiende:
import mx.tec.sabores.domain.RatingSummary
/** El promedio que el servidor ya calculó, en la forma que entiende el dominio. */
fun RestaurantDto.toSummary() = RatingSummary(average = ratingAverage, count = ratingCount)
Y ahora sí, el método de la lista:
import mx.tec.sabores.data.remote.toSummary // ← junto a los otros imports
import mx.tec.sabores.domain.RestaurantEnLista
suspend fun getAllForList(): List<RestaurantEnLista> =
api.getRestaurants().map { RestaurantEnLista(it.toDomain(), it.toSummary()) }
Ese comentario sobre dónde vive la clase no es teórico: al escribir esta práctica la puse primero en ui/state/, y el repositorio acabó importando de la capa de UI — justo la inversión que el Bloque A de la Práctica 2 enseña a evitar.
Bloque B · 25 minCuando la red falla
B1 — 🔴 El bug8 min
Enciende el modo avión en el emulador y abre la app.
Según cómo hayas escrito el ViewModel, va a pasar una de dos cosas: la app truena con UnknownHostException, o se queda con una lista vacía para siempre, sin decir nada.
Las dos están mal, y las dos vienen del mismo hueco: tu estado no tiene forma de decir “estoy cargando” ni “salió mal”. En memoria eso no hacía falta, porque los datos siempre estaban ahí al instante.
En la Práctica 2, RestaurantListScreen recibía una List<Restaurant> y ya. Enumeren en la bitácora todos los estados en que puede estar esa pantalla ahora, y qué debería ver el usuario en cada uno.
Respuesta
Tres, y la lista vacía no es uno de ellos:
- Cargando — un indicador de progreso. Antes duraba cero milisegundos; ahora dura lo que tarde la red.
- Éxito — la lista. Que puede venir vacía, y eso es un caso distinto de "todavía no llega".
- Error — un mensaje y un botón de reintentar.
El error más común es confundir "cargando" con "lista vacía". Son cosas distintas y el usuario las lee distinto.
B2 — Un estado con forma12 min
package mx.tec.sabores.ui.state
/** Los tres estados de cualquier pantalla que dependa de la red. */
sealed interface UiState<out T> {
data object Cargando : UiState<Nothing>
data class Exito<T>(val datos: T) : UiState<T>
data class Error(val mensaje: String) : UiState<Nothing>
}
En el ViewModel:
class SaboresViewModel(
private val repository: RestaurantRepository = RestaurantRepository()
) : ViewModel() {
var restaurantes by mutableStateOf<UiState<List<Restaurant>>>(UiState.Cargando)
private set
init { cargarRestaurantes() }
fun cargarRestaurantes() {
viewModelScope.launch {
restaurantes = UiState.Cargando
restaurantes = try {
UiState.Exito(repository.getAll())
} catch (e: IOException) {
UiState.Error("No hay conexión. Revisa tu internet.")
} catch (e: HttpException) {
UiState.Error("El servidor respondió ${e.code()}.")
}
}
}
}
catch, no uno
IOException es no llegué al servidor: sin internet, DNS caído, se cayó el wifi. El usuario puede hacer algo: reintentar.
HttpException es llegué y me dijo que no: un 403, un 404, un 500. Reintentar casi nunca ayuda; hay que leer el código.
Y fíjate en lo que no hay: un catch (e: Exception). Eso se llevaría por delante la CancellationException, y ya viste en la Práctica 3 lo que pasa entonces.
En la pantalla, el when es exhaustivo porque UiState es sealed: el compilador no te deja olvidar un caso.
when (val estado = viewModel.restaurantes) {
is UiState.Cargando -> Box(Modifier.fillMaxSize(), Alignment.Center) {
CircularProgressIndicator()
}
is UiState.Error -> ErrorView(
mensaje = estado.mensaje,
onReintentar = { viewModel.cargarRestaurantes() }
)
is UiState.Exito -> LazyColumn { /* lo que ya tenías */ }
}
B3 — Reintentar5 min
@Composable
fun ErrorView(mensaje: String, onReintentar: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(mensaje, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(16.dp))
Button(onClick = onReintentar) { Text("Reintentar") }
}
}
Bloque C · 25 minCrear
C1 — POST12 min
La pantalla de nueva reseña ya existe desde la Práctica 2. Lo único que cambia es a dónde va el resultado.
En el repositorio:
suspend fun addReview(restaurantId: Int, stars: Int, comment: String): Review =
api.createReview(NewReviewBody(restaurantId, stars, comment)).toDomain()
Fíjate en lo que no mandas: ni id ni author. El id lo asigna el servidor y el autor sale del encabezado X-Alumno. Mandarlos sería mentir sobre algo que no te toca decidir.
En el ViewModel del formulario cambian tres cosas: recibe el repositorio, el estado crece con dos campos nuevos, y publicar deja de ser instantáneo. Va completo, porque los tres cambios se explican juntos:
package mx.tec.sabores.ui.state
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import mx.tec.sabores.data.RestaurantRepository
import mx.tec.sabores.domain.ReviewError
import mx.tec.sabores.domain.ReviewValidator
import retrofit2.HttpException
import java.io.IOException
data class NewReviewUiState(
val stars: Int = 0,
val comment: String = "",
val guardando: Boolean = false,
val errorAlGuardar: String? = null
) {
// Estado DERIVADO: se calcula, no se guarda.
val commentError: ReviewError? =
if (comment.isEmpty()) null else ReviewValidator.validateComment(comment)
// Con la red de por medio, "puedo guardar" incluye "no estoy guardando ya".
val canSave: Boolean = ReviewValidator.isValid(stars, comment) && !guardando
val charactersLeft: Int = ReviewValidator.COMMENT_MAX - comment.trim().length
}
class NewReviewViewModel(
private val repository: RestaurantRepository = RestaurantRepository()
) : ViewModel() {
var uiState by mutableStateOf(NewReviewUiState())
private set
fun onStarsChange(stars: Int) {
uiState = uiState.copy(stars = stars, errorAlGuardar = null)
}
fun onCommentChange(text: String) {
if (text.length <= ReviewValidator.COMMENT_MAX) {
uiState = uiState.copy(comment = text, errorAlGuardar = null)
}
}
fun publicar(restaurantId: Int, alTerminar: () -> Unit) {
if (!uiState.canSave) return
viewModelScope.launch {
uiState = uiState.copy(guardando = true, errorAlGuardar = null)
try {
repository.addReview(restaurantId, uiState.stars, uiState.comment)
uiState = uiState.copy(guardando = false)
alTerminar()
} catch (e: IOException) {
uiState = uiState.copy(
guardando = false,
errorAlGuardar = "No hay conexión. Tu reseña no se publicó."
)
} catch (e: HttpException) {
uiState = uiState.copy(guardando = false, errorAlGuardar = mensajeDe(e))
}
}
}
}
Lo que cambió respecto a la Práctica 2:
guardando y errorAlGuardar viven dentro del UiState, no sueltos en el ViewModel. Es la misma razón del Paso C2 de la Práctica 2: con un solo objeto la pantalla nunca ve un estado a medias — es imposible que el botón ya se haya apagado y el error todavía no exista.
canSave ahora incluye && !guardando. Esa es la línea que apaga el botón; sin ella el callout de abajo es una promesa que nadie cumple.
publicar recibe solo el restaurantId. Las estrellas y el comentario ya están en uiState: volver a pasarlos sería darle dos fuentes de verdad al mismo dato.
alTerminar() se llama únicamente si el servidor confirmó. Por eso el popBackStack() se le pasa como parámetro en vez de hacerlo en la pantalla: si la petición falla, no se cierra nada y el error se queda a la vista.
Mientras guardando es true, canSave es false y el botón queda deshabilitado. Si no, el usuario impaciente toca tres veces y publica tres reseñas idénticas — y en una base compartida eso lo ve todo el grupo.
En la pantalla también conviene que el texto cambie a “Publicando…”, para que se note que algo está pasando.
En la pantalla, dos cambios pequeños:
// El error del servidor: un 422 que tu validación no atrapó, o una caída de
// red. Se muestra aquí y la pantalla NO se cierra.
val errorDelServidor = uiState.errorAlGuardar
if (errorDelServidor != null) {
Text(
text = errorDelServidor,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
Button(
onClick = onSave,
enabled = uiState.canSave,
modifier = Modifier.fillMaxWidth()
) { Text(if (uiState.guardando) "Publicando…" else "Publicar reseña") }
Y en el NavHost, onSave deja de hacer dos cosas seguidas:
onSave = {
// El popBackStack ya no es inmediato: ocurre cuando el servidor confirma.
// Si falla, la pantalla se queda y el error se ve.
formViewModel.publicar(id) { nav.popBackStack() }
},
C2 — El servidor también valida8 min
Publica una reseña con un comentario de cinco letras. El servidor responde:
{ "error": "comment debe tener al menos 15 caracteres", "field": "comment" }
Con código 422.
Pero tu ReviewValidator del dominio ya impedía eso desde la Práctica 2. Entonces, ¿por qué el servidor lo repite?
El cliente valida para ser amable. El servidor valida porque no puede confiar.
Tu app es un cliente entre varios: alguien puede hablarle a la API con curl y mandar lo que quiera. Que la regla viva en los dos lados no es duplicación por descuido — es que responden a preguntas distintas.
Para leer el mensaje del servidor:
fun mensajeDe(e: HttpException): String {
val cuerpo = e.response()?.errorBody()?.string()
val mensaje = cuerpo
?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() }
?.jsonObject?.get("error")?.jsonPrimitive?.contentOrNull
return when (e.code()) {
401 -> "Falta tu matrícula en Network.alumno."
403 -> mensaje ?: "Esa reseña no es tuya."
404 -> "Eso ya no existe. Actualiza la lista."
422 -> mensaje ?: "Los datos no son válidos."
else -> "El servidor respondió ${e.code()}."
}
}
Comenta la llamada a ReviewValidator en el ViewModel y publica una reseña de tres letras. Anota en la bitácora: qué código respondió el servidor, qué mensaje mostró tu app, y si la app quedó utilizable después.
Vuelve a poner la validación cuando termines.
C3 — Que se note5 min
Al publicar, regresas al detalle — y el detalle tiene que volver a pedir las reseñas, porque la que acabas de crear vive en el servidor, no en memoria.
El detalle necesita dos cosas a la vez: el restaurante y sus reseñas. Se piden juntas y se guardan juntas:
data class Detalle(
val restaurant: Restaurant,
val reviews: List<Review>
) {
// La regla del dominio sigue viva: el promedio se calcula aquí, no se hereda
// del servidor, para que cambie al instante al publicar tu reseña.
val summary: RatingSummary = RatingSummary.from(reviews)
}
var detalle by mutableStateOf<UiState<Detalle>>(UiState.Cargando)
private set
fun cargarDetalle(id: Int) {
viewModelScope.launch {
detalle = UiState.Cargando
detalle = pedir { Detalle(repository.getById(id), repository.getReviews(id)) }
}
}
Es la misma forma de cargarRestaurantes() del Bloque B. Si te repetiste los dos catch, sácalos a una función:
private suspend fun <T> pedir(block: suspend () -> T): UiState<T> = try {
UiState.Exito(block())
} catch (e: IOException) {
UiState.Error("No hay conexión. Revisa tu internet.")
} catch (e: HttpException) {
UiState.Error(mensajeDe(e))
}
Y en la pantalla de detalle, para que se recargue al volver de publicar:
LaunchedEffect(id) { viewModel.cargarDetalle(id) }
Bloque D · 20 minEditar y borrar
“Mis reseñas” deja de ser una lista filtrada y se convierte en la pantalla desde donde administras lo tuyo.
Aquí la guía te da los métodos del repositorio y nada más. El resto lo armas tú, que para eso hiciste los bloques anteriores:
- En el ViewModel:
cargarMisResenas(), que pidegetMyReviews()y deja el resultado en unUiState, igual quecargarRestaurantes()en el Bloque B. - En el ViewModel: una función por cada acción —editar y borrar— con sus dos
catch, como en el Bloque C. - En
MyReviewsScreen: dos parámetros nuevos, uno por acción. La pantalla sigue siendo tonta; solo avisa que el usuario tocó algo. - En el
NavHost: elwhensobre el estado, como el de la lista.
D1 — PATCH8 min
suspend fun editReview(id: Int, stars: Int? = null, comment: String? = null): Review =
api.editReview(id, EditReviewBody(stars, comment)).toDomain()
PATCH manda solo lo que cambia. Por eso EditReviewBody tiene los dos campos nulos por omisión: si solo cambias las estrellas, el comentario ni viaja.
D2 — DELETE, y el 40312 min
/** true si se borró, false si el servidor dijo que no era tuya. */
suspend fun deleteReview(id: Int): Boolean {
val response = api.deleteReview(id)
return when (response.code()) {
204 -> true
403 -> false
else -> throw HttpException(response)
}
}
Aquí es donde Response<Unit> gana su lugar: un DELETE que devuelve Unit a secas no te deja distinguir “se borró” de “no te dejo”.
Prueba el caso prohibido: toma el id de una reseña del profesor —salen en cualquier detalle— e intenta borrarla. El servidor responde 403 con el nombre de quién la escribió.
403 no es un error de programación
Es una respuesta legítima a una petición legítima. Tu app no debe tronar ni mostrar “error inesperado”: debe decir “esa reseña no es tuya” y seguir funcionando.
En la interfaz, lo correcto es no llegar ahí: los botones de editar y borrar solo se pintan en las reseñas cuyo author es igual a tu matrícula. El 403 es la red de seguridad, no la interfaz.
Retos para casa
- Búsqueda. Un campo que filtre la lista por nombre o cocina. Hazlo primero en el cliente; luego piensa qué tendría que cambiar en la API para hacerlo en el servidor, y cuándo vale la pena.
- Actualizar deslizando.
PullToRefreshBoxsobre la lista. - Optimista. Al borrar, quita la reseña de la pantalla antes de que responda el servidor, y vuelve a ponerla si falla. Se siente instantáneo, y es como funcionan las apps que te gustan.
- Sin repetir. Si el usuario toca publicar dos veces, que solo se cree una reseña.
- Caché. Guarda la última lista recibida y muéstrala mientras carga la nueva, en vez de la rueda de progreso.
- Tu propia API. Levanta
npx json-servercon el mismo contrato de datos y cambiaBASE_URL. Si la app funciona sin tocar nada más, tu capa de datos está bien puesta.
Problemas comunes
| Síntoma | Causa | Solución |
|---|---|---|
SecurityException: Permission denied (missing INTERNET permission?) |
Falta el permiso | <uses-permission android:name="android.permission.INTERNET" /> |
IllegalArgumentException: baseUrl must end in / |
Falta la barra final | "https://startdroid.com/api/" |
| Todo da 404 aunque la URL exista | La ruta empieza con / |
En Retrofit va @GET("restaurants"), sin barra inicial |
SerializationException: Unexpected JSON token |
La API mandó un campo nuevo | Json { ignoreUnknownKeys = true } |
HttpException: 401 |
Falta o está mal el X-Alumno |
Revisa Network.alumno y el interceptor |
HttpException: 403 al borrar |
La reseña es de alguien más | No es un bug: es la regla. Muéstralo en la UI |
422 al publicar |
El comentario tiene menos de 15 caracteres | Lee error del cuerpo y muéstralo |
NetworkOnMainThreadException |
Llamaste sin corrutina | Retrofit con suspend no bloquea; revisa que no metiste runBlocking |
Suspend function 'getAll' should be called only from a coroutine en un @Preview |
La preview le pedía datos al repositorio, que ahora es red | Una preview no hace peticiones: dale datos de mentira escritos a mano |
Unresolved reference 'toSummary' (o toDomain) |
Los mapeadores son funciones de extensión: viven en otro archivo y no se importan solas | import mx.tec.sabores.data.remote.toSummary |
| La app truena sin internet | No hay catch (e: IOException) |
Bloque B |
| La lista queda vacía y no dice nada | Confundiste “cargando” con “vacío” | Los tres estados de UiState |
| Publico y el detalle no cambia | No recargaste tras volver | LaunchedEffect(restaurantId) |
| No veo las peticiones en Logcat | Falta el interceptor de log | HttpLoggingInterceptor, filtra por okhttp |
IA en esta práctica — nivel tutor
Permitido: explicar, diagnosticar, contrastar tu diseño. Prohibido: generar el código de los entregables.
- “Tengo un
SerializationExceptionen este JSON y estedata class. Dime qué campo no coincide, sin corregirme el código.” - “¿Por qué separar
RestaurantDtodeRestaurantsi hoy tienen casi los mismos campos?” - “Explícame la diferencia entre atrapar
IOExceptiony atraparHttpExceptionen una llamada de Retrofit.”
Rúbrica
| Criterio | Pts | Qué se ve |
|---|---|---|
| capas | 25 | DTO separados del dominio, con mapeadores; domain/ sin un solo import de Retrofit ni de kotlinx.serialization |
| estados | 25 | UiState con los tres casos; when exhaustivo en la pantalla; nada de listas vacías mudas |
| crud | 25 | Las seis operaciones funcionando contra la API real |
| errores | 15 | IOException y HttpException por separado; 403 y 422 con mensaje útil; la app nunca truena |
| bitácora | 10 | Ejercicios 0, A2, B1 y C2 contestados por escrito |
Defensa oral — 2 min, aleatoria, obligatoria para acreditar
- Enséñame tu
RestaurantDtoy tuRestaurant. ¿Por qué son dos clases? - ¿Qué pasa en tu app si la API agrega mañana un campo
phone? ¿Y si le quita uno? - ¿Por qué el servidor valida el comentario si tu dominio ya lo validaba?
- Enséñame dónde se agrega el encabezado
X-Alumno. ¿Por qué ahí y no en cada llamada? - ¿Qué diferencia hay entre atrapar
IOExceptionyHttpException? Dame un caso de cada uno. - Borra una reseña del profesor. Explícame qué pasó, capa por capa.
Entregables
- Repositorio con un commit por bloque:
bloque-a,bloque-b,bloque-c,bloque-d. - Video de 60 s: lista desde el servidor, publicar una reseña, modo avión con el error y el reintento, y un borrado ajeno rechazado.
docs/bitacora.mdcon los ejercicios 0, A2, B1 y C2.
https://startdroid.com/api la mantiene el curso. Es una base compartida por todo el grupo: las reseñas que publiques las ven tus compañeros, y las de ellos aparecen en tu app.
Los restaurantes no se pueden modificar, y cada quien solo edita lo suyo. Si necesitas volver a empezar, DELETE /api/me/reviews borra tus reseñas y nada más.
Pide GET /api/health para ver la lista completa de endpoints.