Práctica 2 · 120 minutos

Sabores: tres pantallas y un solo dueño del estado

Una guía de restaurantes en Jetpack Compose, construida para que el ViewModel y la capa de dominio dejen de ser diagramas y se conviertan en código que puedes defender.

120 min en clase Nivel IA: tutor Sin base de datos Sin red Sin corrutinas Sin pruebas automatizadas Todo en memoria

Prerrequisitos. Saber escribir @Composable, usar LazyColumn, Row, Column, Card y OutlinedTextField, y manejar estado local con remember { mutableStateOf(...) } más hoisting.

Relación con el reto. Es la misma arquitectura que vas a usar en tu proyecto real, sobre un dominio pequeño donde cabe completa en dos horas.

Cómo llegar a tiempo

Copia y pega el código. Las dos horas son para entender, no para teclear. Escribe a mano únicamente lo que el texto te pide escribir a ti.

Lo que vas a construir

Pantalla 1 · destino de nivel superior
Lista de restaurantes
LazyColumn de tarjetas con emoji, tipo de cocina, precio y calificación promedio.
tap en la tarjeta → navega con el id, no con el objeto
Pantalla 2 · se apila encima
Detalle del restaurante
Datos del lugar, promedio de estrellas y la lista de reseñas que le han dejado.
“Escribir reseña” → vuelve a pasar el id
Pantalla 3 · formulario
Nueva reseña
Selector de estrellas y comentario validado. Al publicar, regresa al detalle ya actualizado.

Cuando termines, esto tiene que ser cierto

  1. Navegas lista → detalle → reseña y regresas sin perderte.
  2. Al guardar una reseña, el promedio cambia en el detalle y también en la tarjeta de la lista.
  3. Si giras el teléfono mientras escribes, no pierdes lo escrito.
  4. Ningún archivo de domain/ importa nada de androidx.

Parte 0 · 10 min · sin computadoraLas tres preguntas

Antes de escribir una línea, vamos a cerrar la duda que quedó de la clase de MVVM: ¿qué es exactamente el ViewModel y qué es el dominio?

El mapa de capas, con la pregunta que define cada una

UI
“¿Cómo se ve?”Composables tontos. Reciben datos, emiten eventos. No deciden nada.
ViewModel
“¿Qué se ve AHORA?”El estado actual de la pantalla y las funciones que lo cambian.
Dominio
“¿Qué es verdad siempre?”Las reglas. Kotlin puro. No saben que existe Android.
Datos
“¿De dónde salen?”Hoy: una lista en memoria. Más adelante: una base de datos.

Las flechas van solo hacia abajo. El dominio no conoce al ViewModel; el ViewModel no conoce a los Composables.

El dominio DECIDE. El ViewModel ORQUESTA. La UI DIBUJA.

dominio “Una reseña necesita mínimo 15 caracteres.”

viewmodel “El usuario lleva escritos 8, así que todavía no puede guardar.”

ui “El botón se pinta gris.”

Ejercicio 0 Clasifica antes de programar en parejas · 5 min · sin IDE

Llena la columna de la derecha con UI, ViewModel, dominio o datos.

Responsabilidad ¿Qué capa?
1 Calcular el promedio de estrellas de un restaurante
2 Mostrar 4.333… como "4.3"
3 La lista de los restaurantes de muestra
4 “El comentario debe tener al menos 15 caracteres”
5 El botón “Publicar” se pinta gris
6 Recordar cuántas estrellas lleva marcadas el usuario
7 Decidir a qué pantalla ir después de guardar
8 Convertir priceLevel = 2 en "$$"
Respuestas y por qué — ábrelo hasta que todos hayan contestado
  1. dominio Es una regla aritmética del negocio; no cambia si mañana la app es web.
  2. ui Formato de presentación. El dominio produce el número; la UI decide los decimales.
  3. datos Es la fuente. Hoy en memoria; el día que sea una base de datos, nadie arriba se entera.
  4. dominio Regla. Va en ReviewValidator.
  5. ui Es derivado de canSave. Quién decide si es válido es el dominio; quién lo pinta gris es la UI.
  6. viewmodel Prueba de la rotación: debe sobrevivir.
  7. ui El ViewModel jamás conoce las pantallas. Expone estado y recibe eventos; el NavHost decide rutas.
  8. dominio — y es defendible como UI. Vale la pena discutirlo en voz alta: es una etiqueta derivada del modelo y no necesita Android para existir.

Bloque A · 30 minDominio, datos y la primera pantalla

Paso A1 — Estructura del proyecto4 min

Proyecto nuevo: Empty Activity (Compose), paquete mx.tec.sabores. Crea estas carpetas desde el inicio: la estructura de carpetas es el diagrama de capas.

mx/tec/sabores/
├─ MainActivity.kt
├─ domain/          ← Kotlin puro. Cero imports de androidx.
├─ data/            ← de dónde salen los restaurantes
└─ ui/
   ├─ navigation/   ← rutas y NavHost
   ├─ components/   ← piezas reutilizables
   ├─ screens/      ← las 3 pantallas
   └─ state/        ← los ViewModels

En libs.versions.toml (o directo en el build.gradle.kts del módulo app):

implementation("androidx.navigation:navigation-compose:2.9.8")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")

Y en el bloque android { }, compileSdk = 37. No es opcional: las librerías de AndroidX de 2026 se niegan a enlazar contra 36.

El código de esta práctica está probado con AGP 9.3.1 · Kotlin 2.4.10 · Gradle 9.7.1 · Compose BOM 2026.08.00, sobre JDK 25 (el JBR de Android Studio).

Dos trampas del andamiaje en 2026

1. AGP 9 trae Kotlin integrado. Si tu app/build.gradle.kts tiene alias(libs.plugins.kotlin.android), el build falla con “The 'org.jetbrains.kotlin.android' plugin is no longer required for Kotlin support since AGP 9.0”. Bórralo: se quedan solo com.android.application y org.jetbrains.kotlin.plugin.compose.

2. Se va con él el bloque kotlinOptions que ponían las plantillas viejas. Con AGP 9 basta compileOptions { sourceCompatibility / targetCompatibility }.

Paso A2 — El dominio10 min

Cuatro archivos cortos. Cópialos tal cual y dedica el tiempo a leerlos, no a escribirlos.

domain/Restaurant.kt
package mx.tec.sabores.domain

data class Restaurant(
    val id: Int,
    val name: String,
    val cuisine: String,
    val address: String,
    val description: String,
    val priceLevel: Int,   // 1, 2 o 3
    val emoji: String
) {
    val priceLabel: String get() = "$".repeat(priceLevel)
}
domain/Review.kt
package mx.tec.sabores.domain

data class Review(
    val restaurantId: Int,
    val stars: Int,
    val comment: String
)

La primera regla de verdad

domain/RatingSummary.kt
package mx.tec.sabores.domain

data class RatingSummary(
    val average: Double,
    val count: Int
) {
    val hasReviews: Boolean get() = count > 0

    companion object {
        fun from(reviews: List<Review>): RatingSummary {
            if (reviews.isEmpty()) return RatingSummary(average = 0.0, count = 0)
            return RatingSummary(
                average = reviews.sumOf { it.stars }.toDouble() / reviews.size,
                count = reviews.size
            )
        }
    }
}

Las reglas de una reseña válida

domain/ReviewValidator.kt
package mx.tec.sabores.domain

sealed interface ReviewError {
    data object NoStars : ReviewError
    data object CommentTooShort : ReviewError
    data object CommentTooLong : ReviewError
}

object ReviewValidator {
    const val COMMENT_MIN = 15
    const val COMMENT_MAX = 240

    fun validateStars(stars: Int): ReviewError? =
        if (stars !in 1..5) ReviewError.NoStars else null

    fun validateComment(comment: String): ReviewError? {
        val text = comment.trim()
        return when {
            text.length < COMMENT_MIN -> ReviewError.CommentTooShort
            text.length > COMMENT_MAX -> ReviewError.CommentTooLong
            else -> null
        }
    }

    fun isValid(stars: Int, comment: String): Boolean =
        validateStars(stars) == null && validateComment(comment) == null
}
Por qué el error no trae el texto adentro

ReviewError.CommentTooShort dice qué está mal, no cómo decírselo al usuario. El mensaje en español lo escribe la UI, en el Paso C2. Si mañana la app se traduce al inglés, el dominio no se toca.

Paso A3 — La capa de datos3 min

data/RestaurantRepository.kt
package mx.tec.sabores.data

import mx.tec.sabores.domain.Restaurant

class RestaurantRepository {

    private val restaurants = listOf(
        Restaurant(1, "La Chinampa", "Mexicana", "Av. Garza Sada 300",
            "Cocina de mercado: tacos de guisado, sopes y agua del día.", 1, "🌮"),
        Restaurant(2, "Nonna Rosa", "Italiana", "Río Nazas 118",
            "Pasta fresca hecha en casa y horno de leña a la vista.", 3, "🍝"),
        Restaurant(3, "Kaze", "Japonesa", "Vasconcelos 402",
            "Barra de sushi de doce lugares. Menú corto que cambia cada semana.", 3, "🍣"),
        Restaurant(4, "Verde Limón", "Vegetariana", "Hidalgo 1204",
            "Bowls de temporada y jugos prensados en frío. Todo sin carne.", 2, "🥗"),
        Restaurant(5, "Brasa 33", "Parrilla", "Gómez Morín 33",
            "Cortes al carbón y papas rústicas. Reserva el fin de semana.", 3, "🥩")
    )

    fun getAll(): List<Restaurant> = restaurants

    fun getById(id: Int): Restaurant? = restaurants.firstOrNull { it.id == id }
}
Deuda técnica consciente

Hoy este repositorio es de solo lectura y las reseñas vivirán en el ViewModel, que no es su lugar definitivo. Cuando veamos persistencia, este archivo se convierte en el acceso a la base de datos, las reseñas se mudan aquí, y ni la UI ni el dominio cambian una sola línea. Ese es el premio de haber separado las capas — anótalo, lo vamos a cobrar.

Paso A4 — Pantalla 1: la lista13 min

Empezamos por la UI tonta: Composables que reciben datos y avisan de eventos. No saben de dónde vienen los datos ni a dónde llevan los taps.

ui/components/RatingLabel.kt
@Composable
fun RatingLabel(summary: RatingSummary, modifier: Modifier = Modifier) {
    if (!summary.hasReviews) {
        Text(
            text = "Sin reseñas todavía",
            style = MaterialTheme.typography.labelMedium,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
            modifier = modifier
        )
        return
    }
    Row(modifier, verticalAlignment = Alignment.CenterVertically) {
        Icon(
            imageVector = Icons.Default.Star,
            contentDescription = null,
            tint = Color(0xFFF5A623),
            modifier = Modifier.size(16.dp)
        )
        Spacer(Modifier.width(4.dp))
        Text(
            text = "%.1f".format(summary.average) +
                   " · ${summary.count} reseña${if (summary.count == 1) "" else "s"}",
            style = MaterialTheme.typography.labelMedium
        )
    }
}
ui/components/RestaurantCard.kt
@Composable
fun RestaurantCard(
    restaurant: Restaurant,
    summary: RatingSummary,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Card(modifier = modifier.fillMaxWidth().clickable(onClick = onClick)) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            Text(restaurant.emoji, style = MaterialTheme.typography.displaySmall)
            Column(Modifier.weight(1f)) {
                Text(restaurant.name, style = MaterialTheme.typography.titleMedium)
                Text(
                    text = "${restaurant.cuisine} · ${restaurant.priceLabel}",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
                Spacer(Modifier.height(6.dp))
                RatingLabel(summary)
            }
            Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
        }
    }
}
ui/screens/RestaurantListScreen.kt
@Composable
fun RestaurantListScreen(
    restaurants: List<Restaurant>,
    summaryOf: (Int) -> RatingSummary,
    onRestaurantClick: (Int) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        items(restaurants, key = { it.id }) { restaurant ->
            RestaurantCard(
                restaurant = restaurant,
                summary = summaryOf(restaurant.id),
                onClick = { onRestaurantClick(restaurant.id) }
            )
        }
    }
}

@Preview(showBackground = true)
@Composable
private fun ListPreview() {
    SaboresTheme {
        RestaurantListScreen(
            restaurants = RestaurantRepository().getAll(),
            summaryOf = { RatingSummary(4.2, 3) },   // datos falsos: es solo el dibujo
            onRestaurantClick = {}
        )
    }
}
Fíjate en la firma

RestaurantListScreen recibe onRestaurantClick: (Int) -> Unit. No recibe un NavController. Por eso el @Preview funciona: la pantalla es dibujable sin app, sin navegación y sin ViewModel. Si tu pantalla necesita un NavController para verse, dejó de ser tonta.

Y como no hay pruebas automatizadas en este curso todavía, el @Preview es tu única forma barata de verificar. Aprovéchalo.

Conéctala provisionalmente en MainActivity:

setContent {
    SaboresTheme {
        val repository = remember { RestaurantRepository() }
        RestaurantListScreen(
            restaurants = repository.getAll(),
            summaryOf = { RatingSummary(0.0, 0) },
            onRestaurantClick = { }
        )
    }
}
Ejercicio A4 El estado vacío 5 min · solo este, los demás quedan de tarea

Si restaurants viene vacía, la pantalla hoy se ve en blanco. Muestra un Column centrado con un icono y el texto “No hay restaurantes que mostrar”, y agrega un segundo @Preview que le pase emptyList().

La pregunta que importa: ¿ese if es una decisión de UI, de ViewModel o de dominio? Contéstala antes de escribirlo.


Bloque B · 35 minNavegación, paso de datos y el bug que explica el ViewModel

Paso B1 — Rutas5 min

ui/navigation/Route.kt
package mx.tec.sabores.ui.navigation

object Route {
    const val HOME = "home"
    const val MY_REVIEWS = "myReviews"
    const val DETAIL = "detail/{restaurantId}"
    const val NEW_REVIEW = "review/{restaurantId}"

    const val ARG_RESTAURANT_ID = "restaurantId"

    fun detail(id: Int) = "detail/$id"
    fun newReview(id: Int) = "review/$id"
}
Por qué las funciones detail(id) y newReview(id)

La plantilla ("detail/{restaurantId}") y la ruta concreta ("detail/3") son cosas distintas. Tenerlas juntas en un solo archivo evita el error número uno de esta práctica: escribir el string a mano en un lado y equivocarse de nombre en el otro.

Por la navegación viaja el id, nunca el objeto.

Un argumento de navegación es parte de una URL: solo aguanta tipos primitivos (Int, String, Boolean, Float). Meter un objeto entero significa serializarlo, y en cuanto ese objeto cambie —una reseña nueva— la copia que viajó por la ruta se queda vieja. El id es un apuntador estable; los datos se leen siempre de la única fuente de verdad.

Paso B2 — El NavHost12 min

ui/navigation/SaboresNavHost.kt
@Composable
fun SaboresApp() {
    val nav = rememberNavController()
    val repository = remember { RestaurantRepository() }

    NavHost(navController = nav, startDestination = Route.HOME) {

        composable(Route.HOME) {
            RestaurantListScreen(
                restaurants = repository.getAll(),
                summaryOf = { RatingSummary(0.0, 0) },        // provisional
                onRestaurantClick = { id -> nav.navigate(Route.detail(id)) }
            )
        }

        composable(
            route = Route.DETAIL,
            arguments = listOf(navArgument(Route.ARG_RESTAURANT_ID) { type = NavType.IntType })
        ) { backStackEntry ->
            val id = backStackEntry.arguments?.getInt(Route.ARG_RESTAURANT_ID) ?: return@composable
            val restaurant = repository.getById(id) ?: return@composable

            RestaurantDetailScreen(
                restaurant = restaurant,
                summary = RatingSummary(0.0, 0),               // provisional
                reviews = emptyList(),                         // provisional
                onWriteReviewClick = { nav.navigate(Route.newReview(id)) },
                onBack = { nav.popBackStack() }
            )
        }
    }
}

Y MainActivity queda en tres líneas para siempre:

setContent { SaboresTheme { SaboresApp() } }
Ejercicio B2 Rompe la navegación a propósito 4 min · anota qué pasó
Rompimiento Qué observar
Cambia type = NavType.IntType por StringType sin tocar getInt ¿Crashea o llega 0? ¿En qué momento exactamente?
Toca la misma tarjeta 5 veces rápido y luego “atrás” 5 veces Esto es el back stack. ¿Cómo lo arreglarías? (pista: launchSingleTop, lo usarás en el Paso D)

Paso B3 — Pantalla 2: el detalle10 min

ui/screens/RestaurantDetailScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RestaurantDetailScreen(
    restaurant: Restaurant,
    summary: RatingSummary,
    reviews: List<Review>,
    onWriteReviewClick: () -> Unit,
    onBack: () -> Unit,
    modifier: Modifier = Modifier
) {
    Scaffold(
        modifier = modifier,
        topBar = {
            TopAppBar(
                title = { Text(restaurant.name) },
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Regresar")
                    }
                }
            )
        },
        floatingActionButton = {
            ExtendedFloatingActionButton(
                onClick = onWriteReviewClick,
                icon = { Icon(Icons.Default.Star, contentDescription = null) },
                text = { Text("Escribir reseña") }
            )
        }
    ) { padding ->
        LazyColumn(
            modifier = Modifier.fillMaxSize().padding(padding),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp)
        ) {
            item {
                Text(restaurant.emoji, style = MaterialTheme.typography.displayLarge)
                Text(
                    "${restaurant.cuisine} · ${restaurant.priceLabel}",
                    style = MaterialTheme.typography.titleMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
                Spacer(Modifier.height(8.dp))
                RatingLabel(summary)
                Spacer(Modifier.height(12.dp))
                Text(restaurant.description, style = MaterialTheme.typography.bodyLarge)
                Spacer(Modifier.height(4.dp))
                Text(restaurant.address, style = MaterialTheme.typography.bodySmall)
                Spacer(Modifier.height(20.dp))
                Text("Reseñas", style = MaterialTheme.typography.titleLarge)
            }

            if (reviews.isEmpty()) {
                item {
                    Text(
                        "Nadie ha reseñado este lugar. Sé la primera persona.",
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
            } else {
                items(reviews) { review ->
                    Card(Modifier.fillMaxWidth()) {
                        Column(Modifier.padding(14.dp)) {
                            StarsRow(review.stars)      // una reseña: solo sus estrellas
                            Spacer(Modifier.height(6.dp))
                            Text(review.comment, style = MaterialTheme.typography.bodyMedium)
                        }
                    }
                }
            }

            item { Spacer(Modifier.height(72.dp)) }   // que el FAB no tape la última reseña
        }
    }
}

Paso B4 — 🔴 El bug8 min

No te saltes este paso

Aquí es donde el ViewModel deja de ser un concepto y se vuelve necesario. Vas a escribir código malo a propósito.

Experimento 1 — el estado que muere al navegar

En RestaurantDetailScreen, borra temporalmente el parámetro reviews y pon el estado dentro de la pantalla:

// ❌ A PROPÓSITO MAL
var reviews by remember { mutableStateOf(listOf<Review>()) }

Y en el FAB, en vez de navegar, agrega una reseña de mentiras:

onClick = { reviews = reviews + Review(restaurant.id, 5, "Estuvo increíble, volvería mañana") }

Ahora, en el emulador, en este orden:

  1. Abre el detalle de Kaze. Toca el FAB tres veces. → Ves 3 reseñas. Funciona. 🎉
  2. Presiona “atrás” para volver a la lista.
  3. Vuelve a entrar a Kaze.

Las reseñas desaparecieron. remember guarda el valor mientras el Composable siga en la composición. Al hacer “atrás”, RestaurantDetailScreen salió de la composición y el remember se fue con él. Volver a entrar no es regresar: es construir la pantalla desde cero.

Experimento 2 — el estado que muere al girar

Sin arreglar nada: entra al detalle, toca el FAB dos veces y gira el teléfono (en el emulador Ctrl + , con la rotación automática activa).

Otra vez desaparecieron. Girar destruye y recrea la Activity; todo lo que vivía en la composición murió con ella. Es la pregunta clásica: “¿qué pasa con lo que el usuario escribió si el sistema recrea la pantalla?”

Ejercicio B4 Diagnostica antes de la solución 4 min · por escrito, dos renglones cada una
  1. ¿Cuál de las tres pruebas de la Parte 0 detecta este error?
  2. ¿A quién le pertenecen las reseñas: al detalle, a la lista, o a ninguno de los dos? Justifica con lo que pasa cuando el promedio debe verse también en la tarjeta de la lista.
  3. Si movieras las reseñas a un remember en SaboresApp() —el Composable que contiene al NavHost— ¿se arregla el Experimento 1? ¿Y el 2? (Uno sí, el otro no. Pruébalo si te da tiempo.)

La pregunta 3 es la clave de todo: hoistear el estado hacia arriba arregla el problema del alcance, pero no el del ciclo de vida. Para el ciclo de vida necesitas algo que sobreviva a la muerte de la Activity. Eso es un ViewModel.


Bloque C · 33 minViewModel compartido y la pantalla de reseña

Paso C1 — El ViewModel compartido13 min

ui/state/SaboresViewModel.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 mx.tec.sabores.data.RestaurantRepository
import mx.tec.sabores.domain.RatingSummary
import mx.tec.sabores.domain.Restaurant
import mx.tec.sabores.domain.Review
import mx.tec.sabores.domain.ReviewValidator

class SaboresViewModel : ViewModel() {

    private val repository = RestaurantRepository()

    // Estado que NO cambia: se lee una vez.
    val restaurants: List<Restaurant> = repository.getAll()

    // Estado que SÍ cambia: Compose se suscribe y recompone solo.
    var reviews by mutableStateOf<List<Review>>(emptyList())
        private set                                   // ← nadie de afuera puede asignarlo

    fun restaurantById(id: Int): Restaurant? = repository.getById(id)

    fun reviewsOf(restaurantId: Int): List<Review> =
        reviews.filter { it.restaurantId == restaurantId }

    fun summaryOf(restaurantId: Int): RatingSummary =
        RatingSummary.from(reviewsOf(restaurantId))     // ← el dominio decide; el VM pregunta

    // --- eventos que llegan desde la UI ---

    fun addReview(restaurantId: Int, stars: Int, comment: String) {
        if (!ReviewValidator.isValid(stars, comment)) return    // ← el dominio manda
        reviews = reviews + Review(restaurantId, stars, comment.trim())
    }
}

Cinco cosas para leer despacio — cada una es una regla del patrón

  1. private set. El ViewModel expone el estado para leerse, no para escribirse. La UI no hace viewModel.reviews = …; llama a addReview(...). Estado hacia arriba, eventos hacia abajo.
  2. reviews = reviews + Review(...), no reviews.add(...). Se crea una lista nueva. Compose detecta el cambio porque cambió la referencia; si mutas la lista por dentro, no se entera y la UI no se actualiza. Este es el bug silencioso más común de la semana.
  3. summaryOf no calcula nada. Le pregunta a RatingSummary.from, que vive en el dominio. El ViewModel orquesta.
  4. addReview no valida nada. Le pregunta a ReviewValidator, que vive en el dominio.
  5. No hay un solo import androidx.compose.ui ni un NavController. El ViewModel no sabe que existen pantallas.

Conéctalo en SaboresApp()

@Composable
fun SaboresApp() {
    val nav = rememberNavController()
    val viewModel: SaboresViewModel = viewModel()   // androidx.lifecycle.viewmodel.compose.viewModel

    NavHost(navController = nav, startDestination = Route.HOME) {

        composable(Route.HOME) {
            RestaurantListScreen(
                restaurants = viewModel.restaurants,
                summaryOf = { id -> viewModel.summaryOf(id) },
                onRestaurantClick = { id -> nav.navigate(Route.detail(id)) }
            )
        }

        composable(
            route = Route.DETAIL,
            arguments = listOf(navArgument(Route.ARG_RESTAURANT_ID) { type = NavType.IntType })
        ) { entry ->
            val id = entry.arguments?.getInt(Route.ARG_RESTAURANT_ID) ?: return@composable
            val restaurant = viewModel.restaurantById(id) ?: return@composable

            RestaurantDetailScreen(
                restaurant = restaurant,
                summary = viewModel.summaryOf(id),
                reviews = viewModel.reviewsOf(id),
                onWriteReviewClick = { nav.navigate(Route.newReview(id)) },
                onBack = { nav.popBackStack() }
            )
        }
    }
}
¿Por qué el viewModel() está aquí y no dentro de cada pantalla?

Porque SaboresApp() se llama directo desde setContent, así que el dueño del ViewModel es la Activity. Ese ViewModel es uno solo para toda la app: la lista y el detalle ven exactamente el mismo objeto. Si pusieras viewModel() dentro de cada composable(...) del NavHost, cada destino tendría su propia copia y volverías al bug del Bloque B.

Paso C2 — Pantalla 3: la reseña, con su propio ViewModel20 min

Segunda idea que suele costar trabajo: no todo va en el ViewModel grande. El texto que llevas escrito en el formulario no le importa a la lista ni al detalle — y si cancelas, debe desaparecer. Ese estado tiene otro dueño y otro tiempo de vida.

SaboresViewModel NewReviewViewModel
¿Qué guarda? Las reseñas ya publicadas Lo que llevas escrito
¿Quién lo necesita? Lista, detalle, mis reseñas Solo el formulario
¿Cuánto debe durar? Toda la sesión Hasta que salgas de la pantalla
¿Dónde se crea? En SaboresApp()
dueño: la Activity
Dentro de composable(NEW_REVIEW)
dueño: ese destino
¿Sobrevive a la rotación?
¿Sobrevive a salir de la pantalla? No, y está bien
ui/state/NewReviewViewModel.kt
data class NewReviewUiState(
    val stars: Int = 0,
    val comment: String = ""
) {
    // Estado DERIVADO: se calcula, no se guarda.
    val commentError: ReviewError? =
        if (comment.isEmpty()) null else ReviewValidator.validateComment(comment)

    val canSave: Boolean = ReviewValidator.isValid(stars, comment)

    val charactersLeft: Int = ReviewValidator.COMMENT_MAX - comment.trim().length
}

class NewReviewViewModel : ViewModel() {

    var uiState by mutableStateOf(NewReviewUiState())
        private set

    fun onStarsChange(stars: Int) {
        uiState = uiState.copy(stars = stars)
    }

    fun onCommentChange(text: String) {
        if (text.length <= ReviewValidator.COMMENT_MAX) {
            uiState = uiState.copy(comment = text)
        }
    }
}
Un UiState en vez de tres variables sueltas

Con data class + copy() la pantalla siempre ve un estado coherente: es imposible que las estrellas ya se hayan actualizado y canSave todavía no. Con tres mutableStateOf sueltos, sí es posible.

ui/components/StarPicker.kt
@Composable
fun StarPicker(value: Int, onValueChange: (Int) -> Unit, modifier: Modifier = Modifier) {
    Row(modifier) {
        (1..5).forEach { n ->
            IconButton(onClick = { onValueChange(n) }) {
                // Ojo: NO existe una estrella hueca en el set de iconos base.
                // La diferencia entre marcada y sin marcar la hace el TINTE.
                Icon(
                    imageVector = Icons.Default.Star,
                    contentDescription = "$n estrella${if (n == 1) "" else "s"}",
                    tint = if (n <= value) Color(0xFFF5A623)
                           else MaterialTheme.colorScheme.outlineVariant,
                    modifier = Modifier.size(36.dp)
                )
            }
        }
    }
}
Por qué no hay Icons.Default.StarBorder

El artefacto material-icons-core quedó congelado con unos 55 iconos, y StarBorder no está entre ellos: el proyecto no compila. Icons.Outlined.Star tampoco sirve — en ese artefacto el tema “outlined” es un alias del sólido, con el mismo trazo exacto. Para una estrella realmente hueca tendrías que agregar material-icons-extended (pesado y también congelado) o dibujar el vector a mano. Para dos horas, el tinte resuelve: cuatro doradas y una gris se leen perfecto.

ui/components/StarsRow.kt — las estrellas de UNA reseña
/** Las estrellas de UNA reseña. Distinto de RatingLabel, que resume MUCHAS. */
@Composable
fun StarsRow(stars: Int, modifier: Modifier = Modifier) {
    Row(modifier.semantics { contentDescription = "$stars de 5 estrellas" }) {
        repeat(stars) {
            Icon(
                imageVector = Icons.Default.Star,
                contentDescription = null,
                tint = Color(0xFFF5A623),
                modifier = Modifier.size(16.dp)
            )
        }
    }
}
ui/screens/NewReviewScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewReviewScreen(
    restaurant: Restaurant,
    uiState: NewReviewUiState,
    onStarsChange: (Int) -> Unit,
    onCommentChange: (String) -> Unit,
    onSave: () -> Unit,
    onCancel: () -> Unit,
    modifier: Modifier = Modifier
) {
    Scaffold(
        modifier = modifier,
        topBar = {
            TopAppBar(
                title = { Text("Reseñar ${restaurant.name}") },
                navigationIcon = {
                    IconButton(onClick = onCancel) {
                        Icon(Icons.Default.Close, contentDescription = "Cancelar")
                    }
                }
            )
        }
    ) { padding ->
        Column(
            modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            Text("¿Cómo estuvo?", style = MaterialTheme.typography.titleMedium)
            StarPicker(value = uiState.stars, onValueChange = onStarsChange)

            OutlinedTextField(
                value = uiState.comment,
                onValueChange = onCommentChange,
                label = { Text("Tu reseña") },
                minLines = 4,
                isError = uiState.commentError != null,
                supportingText = {
                    val error = uiState.commentError
                    if (error != null) Text(error.message())
                    else Text("Te quedan ${uiState.charactersLeft} caracteres")
                },
                modifier = Modifier.fillMaxWidth()
            )

            Button(
                onClick = onSave,
                enabled = uiState.canSave,
                modifier = Modifier.fillMaxWidth()
            ) { Text("Publicar reseña") }
        }
    }
}

// Traducir el error del dominio a español es trabajo de la UI, no del dominio.
private fun ReviewError.message(): String = when (this) {
    ReviewError.NoStars         -> "Selecciona de 1 a 5 estrellas"
    ReviewError.CommentTooShort -> "Escribe al menos ${ReviewValidator.COMMENT_MIN} caracteres"
    ReviewError.CommentTooLong  -> "Máximo ${ReviewValidator.COMMENT_MAX} caracteres"
}

El destino en el NavHost

composable(
    route = Route.NEW_REVIEW,
    arguments = listOf(navArgument(Route.ARG_RESTAURANT_ID) { type = NavType.IntType })
) { entry ->
    val id = entry.arguments?.getInt(Route.ARG_RESTAURANT_ID) ?: return@composable
    val restaurant = viewModel.restaurantById(id) ?: return@composable

    // OJO: este viewModel() vive DENTRO del composable → su dueño es este destino.
    val formViewModel: NewReviewViewModel = viewModel()

    NewReviewScreen(
        restaurant = restaurant,
        uiState = formViewModel.uiState,
        onStarsChange = formViewModel::onStarsChange,
        onCommentChange = formViewModel::onCommentChange,
        onSave = {
            viewModel.addReview(                       // ← el VM COMPARTIDO recibe el dato
                restaurantId = id,
                stars = formViewModel.uiState.stars,
                comment = formViewModel.uiState.comment
            )
            nav.popBackStack()                         // ← la NAVEGACIÓN la decide la UI
        },
        onCancel = { nav.popBackStack() }
    )
}
Estado compartido, alcance y ciclo de vida

Los pasos 2 y 3 demuestran estado compartido. El 4 demuestra alcance. El 5 demuestra ciclo de vida. Esas tres palabras son todo el ViewModel.


Paso D · 10 minEl menú de navegación

Con dos destinos de nivel superior, el NavHost gana una barra inferior. El detalle y el formulario no son de nivel superior: se apilan encima, y por eso ocultan la barra.

ui/navigation/MenuItem.kt
enum class MenuItem(val route: String, val label: String, val icon: ImageVector) {
    HOME(Route.HOME, "Restaurantes", Icons.Default.Home),
    MY_REVIEWS(Route.MY_REVIEWS, "Mis reseñas", Icons.Default.Star)
}
ui/navigation/SaboresNavHost.kt — la barra dentro de SaboresApp()
val backStackEntry by nav.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val showBottomBar = MenuItem.entries.any { it.route == currentRoute }

Scaffold(
    bottomBar = {
        if (showBottomBar) {
            NavigationBar {
                MenuItem.entries.forEach { item ->
                    NavigationBarItem(
                        selected = currentRoute == item.route,
                        onClick = {
                            nav.navigate(item.route) {
                                popUpTo(Route.HOME) { saveState = true }
                                launchSingleTop = true
                                restoreState = true
                            }
                        },
                        icon = { Icon(item.icon, contentDescription = null) },
                        label = { Text(item.label) }
                    )
                }
            }
        }
    }
) { padding ->
    NavHost(
        navController = nav,
        startDestination = Route.HOME,
        modifier = Modifier.padding(padding)
    ) {
        /* … los cuatro destinos … */
    }
}

Las tres opciones de navigate no son adorno

Opción Qué pasa si la quitas
launchSingleTop = true Tocar “Restaurantes” 5 veces apila 5 copias; “atrás” te hace salir 5 veces. Es el arreglo del Ejercicio B2.
popUpTo(Route.HOME) { saveState = true } El back stack crece sin control al brincar entre pestañas.
restoreState = true Al volver a una pestaña pierde el scroll donde la dejaste.

La cuarta pantalla, que sale casi gratis

En el ViewModel compartido, una propiedad más:

data class MyReviewItem(val restaurantName: String, val review: Review)

val myReviews: List<MyReviewItem>
    get() = reviews.reversed().mapNotNull { review ->
        restaurantById(review.restaurantId)?.let { MyReviewItem(it.name, review) }
    }

Y la pantalla completa:

ui/screens/MyReviewsScreen.kt
@Composable
fun MyReviewsScreen(items: List<MyReviewItem>, modifier: Modifier = Modifier) {
    if (items.isEmpty()) {
        Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            Text("Todavía no has reseñado ningún lugar.",
                 color = MaterialTheme.colorScheme.onSurfaceVariant)
        }
        return
    }
    LazyColumn(
        modifier = modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        items(items) { item ->
            Card(Modifier.fillMaxWidth()) {
                Column(Modifier.padding(16.dp)) {
                    Text(item.restaurantName, style = MaterialTheme.typography.titleMedium)
                    StarsRow(item.review.stars)
                    Spacer(Modifier.height(6.dp))
                    Text(item.review.comment, style = MaterialTheme.typography.bodyMedium)
                }
            }
        }
    }
}

Retos para casa

En orden de dificultad. Los tres primeros son los ejercicios que no cupieron en las dos horas.

  1. Filtro por precio. Una fila de FilterChip ($, $$, $$$, “Todos”) arriba de la lista. Hazlo primero con remember dentro de la pantalla; luego decide si ese estado debe mudarse al ViewModel y justifica tu decisión.
  2. Autor de la reseña. Agrega un campo de nombre. Toca el modelo, el validador, el UiState, el addReview y la UI. Cuenta cuántos archivos tocaste — es la medida real de qué tan acoplado quedó tu código.
  3. Regla nueva del “cliente”. Una reseña de 1 o 2 estrellas debe traer al menos 40 caracteres de explicación. ¿En qué capa va? Impleméntala y comprueba que ninguna pantalla necesita cambios.
  4. Accesibilidad. Haz que la tarjeta se anuncie como una sola cosa con Modifier.semantics(mergeDescendants = true) {} y pruébala con TalkBack.
  5. Favoritos. Un corazón en la tarjeta y en el detalle. Al tocarlo cambia en las dos pantallas al instante. Si te salió con un cambio de una línea en el ViewModel, ya lo entendiste.
  6. Confirmación al cancelar. Si el formulario tiene texto y el usuario toca la ✕, muestra un AlertDialog. ¿Dónde vive el showDialog?
  7. Editar en vez de duplicar. Si ya reseñaste un restaurante, el FAB dice “Editar mi reseña” y el formulario abre precargado. (Pista: NewReviewViewModel no puede leer el ViewModel compartido; el valor inicial tiene que llegarle como parámetro.)
  8. Prepara la persistencia. Sin implementar nada: escribe qué archivos tendrías que tocar para que las reseñas sobrevivan a cerrar la app. Si tu respuesta es “solo RestaurantRepository y el ViewModel”, tu arquitectura está bien.

Problemas comunes

Síntoma Causa Solución
The 'org.jetbrains.kotlin.android' plugin is no longer required AGP 9 ya trae Kotlin integrado Borra ese plugin; deja solo com.android.application y kotlin.plugin.compose
requires ... compile against version 37 or later Las AndroidX de 2026 exigen API 37 compileSdk = 37 y descarga esa plataforma en el SDK Manager
Unresolved reference 'StarBorder' Ese icono no existe en material-icons-core Usa Icons.Default.Star y distingue por tinte
Agrego una reseña y la UI no cambia Mutaste la lista en vez de reemplazarla reviews = reviews + nueva, nunca reviews.add(...)
Unresolved reference: items Import equivocado androidx.compose.foundation.lazy.items
by mutableStateOf marca error Faltan los delegados Importa runtime.getValue y runtime.setValue
El estado se pierde entre pantallas viewModel() llamado dentro de cada composable(...) Créalo una vez en SaboresApp()
Se pierde al girar, pero no al navegar El estado sigue en un remember Muévelo al ViewModel
Cannot find destination detail/3 La plantilla y la ruta concreta no coinciden Usa siempre Route.detail(id); nunca el string a mano
El argumento llega como 0 getInt sobre un argumento declarado StringType El NavType debe coincidir con el getter
TopAppBar no compila API experimental @OptIn(ExperimentalMaterial3Api::class)
El FAB tapa la última reseña Falta espacio al final item { Spacer(Modifier.height(72.dp)) }
La barra inferior tapa el contenido No usaste el padding del Scaffold NavHost(modifier = Modifier.padding(padding))
Lint marca "%.1f".format(...) Formato sin Locale explícito String.format(Locale.getDefault(), "%.1f", x)
No gira el emulador Rotación bloqueada Actívala en Ajustes; luego Ctrl +

IA en esta práctica — nivel tutor

Permitido: explicar, diagnosticar errores, contrastar tu diseño. Prohibido: generar el código de los entregables.

  • “Tengo un mutableStateOf<List<Review>> en un ViewModel. Explícame por qué lista.add(x) no recompone la UI y lista = lista + x sí. No me des código.”
  • “Aquí está mi SaboresViewModel. Sin corregirlo, dime qué responsabilidades tiene que deberían estar en la capa de dominio y por qué.”
  • “¿Por qué Navigation Compose no deja pasar un objeto complejo como argumento de ruta?”

Verifica siempre lo que te responda rompiendo el código a propósito, como en el Paso B4.

Rúbrica

Criterio Pts Qué se ve
capas 30 domain/ sin un solo import de androidx; validaciones y promedio en dominio, no en el ViewModel ni en la UI.
viewmodel 30 Estado compartido con private set; eventos como funciones; el VM no conoce NavController ni Composables; el formulario tiene su propio VM.
navegación 20 3 pantallas + menú; se pasa el id, no el objeto; popBackStack correcto; barra oculta en detalle y formulario.
ui 15 Pantallas sin NavController en la firma; al menos un @Preview por pantalla; estado vacío resuelto.
bitácora 5 Ejercicios 0, B2 y B4 contestados por escrito.

Defensa oral — 2 min, aleatoria, obligatoria para acreditar

  1. Enséñame una línea de tu ViewModel y dime qué pasaría si la muevo al dominio.
  2. ¿Por qué la lista se actualiza sola cuando publicas una reseña desde otra pantalla? Sigue el dato.
  3. ¿Por qué el formulario se borra al salir pero las reseñas no? Nombra a los dueños de cada estado.
  4. ¿Por qué pasas el id y no el objeto Restaurant?
  5. Borra private set de reviews. ¿Qué se rompe conceptualmente aunque siga compilando?

Entregables

  1. Repositorio con un commit por bloque: bloque-a, bloque-b, bloque-c, menu.
  2. Video de 60 s recorriendo el checkpoint final del Bloque C, incluyendo la rotación.
  3. docs/bitacora.md con los ejercicios 0, B2 y B4.
Conexión con el reto

Esta semana, en tu proyecto real: identifica el estado que dos o más pantallas comparten y súbelo a un ViewModel con private set. Es el mismo movimiento del Bloque C, sobre tu dominio.