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.
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.
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
LazyColumn de tarjetas con emoji, tipo de cocina, precio y calificación promedio.Cuando termines, esto tiene que ser cierto
- Navegas lista → detalle → reseña y regresas sin perderte.
- Al guardar una reseña, el promedio cambia en el detalle y también en la tarjeta de la lista.
- Si giras el teléfono mientras escribes, no pierdes lo escrito.
- Ningún archivo de
domain/importa nada deandroidx.
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
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.”
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
- dominio Es una regla aritmética del negocio; no cambia si mañana la app es web.
- ui Formato de presentación. El dominio produce el número; la UI decide los decimales.
- datos Es la fuente. Hoy en memoria; el día que sea una base de datos, nadie arriba se entera.
- dominio Regla. Va en
ReviewValidator. - ui Es derivado de
canSave. Quién decide si es válido es el dominio; quién lo pinta gris es la UI. - viewmodel Prueba de la rotación: debe sobrevivir.
- ui El ViewModel jamás conoce las pantallas. Expone estado y recibe eventos; el
NavHostdecide rutas. - 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).
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.
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)
}
package mx.tec.sabores.domain
data class Review(
val restaurantId: Int,
val stars: Int,
val comment: String
)
La primera regla de verdad
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
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
}
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
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 }
}
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.
@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
)
}
}
@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)
}
}
}
@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 = {}
)
}
}
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 = { }
)
}
}
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
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"
}
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
@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() } }
| 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
@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
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:
- Abre el detalle de Kaze. Toca el FAB tres veces. → Ves 3 reseñas. Funciona. 🎉
- Presiona “atrás” para volver a la lista.
- 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?”
- ¿Cuál de las tres pruebas de la Parte 0 detecta este error?
- ¿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.
- Si movieras las reseñas a un
rememberenSaboresApp()—el Composable que contiene alNavHost— ¿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
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
private set. El ViewModel expone el estado para leerse, no para escribirse. La UI no haceviewModel.reviews = …; llama aaddReview(...). Estado hacia arriba, eventos hacia abajo.reviews = reviews + Review(...), noreviews.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.summaryOfno calcula nada. Le pregunta aRatingSummary.from, que vive en el dominio. El ViewModel orquesta.addReviewno valida nada. Le pregunta aReviewValidator, que vive en el dominio.- No hay un solo
import androidx.compose.uini unNavController. 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() }
)
}
}
}
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? | Sí | Sí |
| ¿Sobrevive a salir de la pantalla? | Sí | No, y está bien |
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)
}
}
}
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.
@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)
)
}
}
}
}
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.
/** 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)
)
}
}
}
@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() }
)
}
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.
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)
}
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:
@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.
- Filtro por precio. Una fila de
FilterChip($,$$,$$$, “Todos”) arriba de la lista. Hazlo primero conrememberdentro de la pantalla; luego decide si ese estado debe mudarse al ViewModel y justifica tu decisión. - Autor de la reseña. Agrega un campo de nombre. Toca el modelo, el validador, el
UiState, eladdReviewy la UI. Cuenta cuántos archivos tocaste — es la medida real de qué tan acoplado quedó tu código. - 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.
- Accesibilidad. Haz que la tarjeta se anuncie como una sola cosa con
Modifier.semantics(mergeDescendants = true) {}y pruébala con TalkBack. - 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.
- Confirmación al cancelar. Si el formulario tiene texto y el usuario toca la ✕, muestra un
AlertDialog. ¿Dónde vive elshowDialog? - Editar en vez de duplicar. Si ya reseñaste un restaurante, el FAB dice “Editar mi reseña” y el formulario abre precargado. (Pista:
NewReviewViewModelno puede leer el ViewModel compartido; el valor inicial tiene que llegarle como parámetro.) - 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
RestaurantRepositoryy 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 ylista = lista + xsí. 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
- Enséñame una línea de tu ViewModel y dime qué pasaría si la muevo al dominio.
- ¿Por qué la lista se actualiza sola cuando publicas una reseña desde otra pantalla? Sigue el dato.
- ¿Por qué el formulario se borra al salir pero las reseñas no? Nombra a los dueños de cada estado.
- ¿Por qué pasas el
idy no el objetoRestaurant? - Borra
private setdereviews. ¿Qué se rompe conceptualmente aunque siga compilando?
Entregables
- Repositorio con un commit por bloque:
bloque-a,bloque-b,bloque-c,menu. - Video de 60 s recorriendo el checkpoint final del Bloque C, incluyendo la rotación.
docs/bitacora.mdcon los ejercicios 0, B2 y B4.
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.