Práctica 4 · 120 minutos

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.

120 min en clase Nivel IA: tutor Sobre tu proyecto de la Práctica 2 API compartida por el grupo Sin base de datos local Sin pruebas automatizadas

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.

Cómo llegar a tiempo

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

Antes
RestaurantRepository con una lista en memoria
Los cinco restaurantes están escritos en el código. Nadie más los ve.
se cambia una sola clase
Después
El mismo repositorio, hablando por HTTP
Los datos llegan de startdroid.com/api. Las reseñas que escribes las ve todo el grupo.
y aparece lo que antes no existía
Lo nuevo
Cargando, error y permisos
La red tarda, falla, y a veces te dice que no.

Cuando termines, esto tiene que ser cierto

  1. La lista sale del servidor, y las reseñas de tus compañeros aparecen en ella.
  2. Publicas una reseña y el promedio cambia — también en el teléfono de junto.
  3. Con el modo avión encendido la app no truena: avisa y ofrece reintentar.
  4. Puedes editar y borrar tus reseñas, y la app te lo impide con las ajenas.
  5. 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.

Los códigos que vas a ver, y qué significan aquí
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
Ejercicio 0 Cada endpoint, ¿a qué pantalla sirve? en parejas · 5 min · sin IDE

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:

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:

app/build.gradle.kts
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:

app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />

    <application …>
Por qué 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

data/remote/Dtos.kt
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
)
¿Por qué no usar 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:

domain/Review.kt
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:

data/remote/Mappers.kt
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
)
Ejercicio A2 ¿Quién calcula el promedio ahora? 3 min · en voz alta

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

data/remote/SaboresApi.kt
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.

data/remote/Network.kt
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:

data/RestaurantRepository.kt
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ó:

domain/RestaurantEnLista.kt
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:

data/remote/Mappers.kt — se agrega al final
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:

data/RestaurantRepository.kt — se agrega
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.

No lo arregles todavía

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.

Ejercicio B1 ¿Cuántos estados tiene de verdad una pantalla con red? 4 min · por escrito

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:

  1. Cargando — un indicador de progreso. Antes duraba cero milisegundos; ahora dura lo que tarde la red.
  2. Éxito — la lista. Que puede venir vacía, y eso es un caso distinto de "todavía no llega".
  3. 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

ui/state/UiState.kt
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:

ui/state/SaboresViewModel.kt
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()}.")
            }
        }
    }
}
Dos 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.

ui/screens/RestaurantListScreen.kt
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

ui/components/ErrorView.kt
@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:

data/RestaurantRepository.kt
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:

ui/state/NewReviewViewModel.kt
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.

El botón tiene que apagarse

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:

ui/screens/NewReviewScreen.kt — dentro del Column, antes del Button
// 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:

ui/navigation/SaboresNavHost.kt — en composable(Route.NEW_REVIEW)
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:

ui/state/ApiErrors.kt
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()}."
    }
}
Ejercicio C2 Salta tu propia validación 4 min · con el IDE

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:

ui/state/SaboresViewModel.kt — se agrega
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:

ui/state/SaboresViewModel.kt — el ayudante que evita repetir los catch
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:

ui/navigation/SaboresNavHost.kt — en composable(Route.DETAIL)
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.

Lo que este bloque te toca escribir a ti

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:

  1. En el ViewModel: cargarMisResenas(), que pide getMyReviews() y deja el resultado en un UiState, igual que cargarRestaurantes() en el Bloque B.
  2. En el ViewModel: una función por cada acción —editar y borrar— con sus dos catch, como en el Bloque C.
  3. En MyReviewsScreen: dos parámetros nuevos, uno por acción. La pantalla sigue siendo tonta; solo avisa que el usuario tocó algo.
  4. En el NavHost: el when sobre el estado, como el de la lista.

D1 — PATCH8 min

data/RestaurantRepository.kt
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

data/RestaurantRepository.kt
/** 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ó.

Un 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

  1. 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.
  2. Actualizar deslizando. PullToRefreshBox sobre la lista.
  3. 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.
  4. Sin repetir. Si el usuario toca publicar dos veces, que solo se cree una reseña.
  5. Caché. Guarda la última lista recibida y muéstrala mientras carga la nueva, en vez de la rueda de progreso.
  6. Tu propia API. Levanta npx json-server con el mismo contrato de datos y cambia BASE_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 SerializationException en este JSON y este data class. Dime qué campo no coincide, sin corregirme el código.”
  • “¿Por qué separar RestaurantDto de Restaurant si hoy tienen casi los mismos campos?”
  • “Explícame la diferencia entre atrapar IOException y atrapar HttpException en 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

  1. Enséñame tu RestaurantDto y tu Restaurant. ¿Por qué son dos clases?
  2. ¿Qué pasa en tu app si la API agrega mañana un campo phone? ¿Y si le quita uno?
  3. ¿Por qué el servidor valida el comentario si tu dominio ya lo validaba?
  4. Enséñame dónde se agrega el encabezado X-Alumno. ¿Por qué ahí y no en cada llamada?
  5. ¿Qué diferencia hay entre atrapar IOException y HttpException? Dame un caso de cada uno.
  6. Borra una reseña del profesor. Explícame qué pasó, capa por capa.

Entregables

  1. Repositorio con un commit por bloque: bloque-a, bloque-b, bloque-c, bloque-d.
  2. 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.
  3. docs/bitacora.md con los ejercicios 0, A2, B1 y C2.
Sobre la API

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.