Saltar al contenido
K
Favoritos con RoomPaso a paso33 min Intermedio

Pestañas en Android

Al terminar: Añadir una barra de navegación inferior y una pantalla de favoritos, sin romper la pila de Navigation 3.

Marcar favoritos ya funciona, pero no hay dónde verlos. Al terminar esta lección la app de Android tendrá dos secciones con una barra inferior, una pantalla de Favoritos (la lista, el aviso de «no hay ninguno» y una hoja inferior para quitarlos todos) y una regla clara sobre cuándo la barra aparece y cuándo no. Quitar un favorito deslizando la fila, con su «Deshacer», va en la lección siguiente.

La barra de pestañas y la pila única ya las montaste en Marcador: cada pieza (Scaffold con bottomBar, NavigationBarItem, AnimatedVisibility, la lista PESTANAS y irAPestana) se explica en Navegación en Marcador, y aquí no se repite. Lo que cambia es la app (la Pokédex, con su DetalleKey), un detalle de márgenes que aparece al tener dos Scaffold y un componente de Material que aún no has usado: ModalBottomSheet. El dato sale del FavoritesViewModel de shared, el mismo que usará iOS.

Paso 1 · Una clave nueva y la lista de pestañas

androidApp
src/main/kotlin/com/sebastiannarvaez/pokedex/android
favorites/FavoritesScreen.ktse crea (paso 4)
navigation/NavKeys.ktse modifica
navigation/PokedexApp.ktse modifica
Una pantalla nueva y dos ficheros de navegación que se modifican (módulo androidApp).

El archivo. NavKeys.kt guarda las claves de la pila, que creaste en Navegación en Android con Navigation 3. Ábrelo (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/NavKeys.kt). Añade una clave más, justo debajo de ListaKey:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/NavKeys.kt
@Serializable
data object FavoritosKey : NavKey

Y, debajo de DetalleKey, la lista que dice qué es una pestaña:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/NavKeys.kt
/** Las pestañas de primer nivel, que nunca se apilan entre si. */
val PESTANAS: List<NavKey> = listOf(ListaKey, FavoritosKey)

Es la misma idea que en Marcador (Navegación en Marcador): una pestaña no es un tipo especial: es una lista de claves y una regla que dice que entre ellas no se apila nada. Y @Serializable es lo que ya viste en Navegación en Android con Navigation 3: la pila se guarda para sobrevivir a que el sistema mate el proceso.

Paso 2 · La barra solo en el primer nivel

El archivo. PokedexApp.kt (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt) tiene el NavDisplay. Pasa a vivir dentro de un Scaffold con la barra inferior, como en Marcador. Sustituye la función PokedexApp entera por esta. Lo que cambia respecto a la que ya tenías está marcado con NUEVO; los dos decoradores de entryDecorators se quedan, en el mismo orden (el de rememberSaveable primero y el de ViewModel después, como en La pila de pantallas en Android): sin el primero la rejilla vuelve arriba del todo al volver de una ficha.

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt
@Composable
fun PokedexApp(inicio: NavKey = ListaKey) {
    val pila = rememberNavBackStack(inicio)
    val actual = pila.lastOrNull()   // NUEVO: la clave de arriba, para decidir la barra
 
    Scaffold(                         // NUEVO: el Scaffold que lleva la barra
        // NUEVO: sin márgenes propios (se explica justo debajo)
        contentWindowInsets = WindowInsets(0, 0, 0, 0),
        bottomBar = {
            // La barra solo aparece en las pantallas de primer nivel. En un
            // detalle estorba: ocupa sitio y ofrece saltar a otra seccion
            // justo cuando el usuario acaba de entrar en algo.
            AnimatedVisibility(
                visible = actual in PESTANAS,
                enter = slideInVertically { it },
                exit = slideOutVertically { it },
            ) {
                NavigationBar {
                    NavigationBarItem(
                        selected = actual == ListaKey,
                        onClick = { irAPestana(pila, ListaKey) },
                        icon = { Icon(Icons.Default.Menu, contentDescription = null) },
                        label = { Text("Pokédex") },
                    )
                    NavigationBarItem(
                        selected = actual == FavoritosKey,
                        onClick = { irAPestana(pila, FavoritosKey) },
                        icon = { Icon(Icons.Default.Favorite, contentDescription = null) },
                        label = { Text("Favoritos") },
                    )
                }
            }
        },
    ) { relleno ->
        NavDisplay(
            // NUEVO: padding deja sitio a la barra de abajo, y consumeWindowInsets
            // avisa a las pantallas de dentro de que ese hueco ya está puesto.
            modifier = Modifier.padding(relleno).consumeWindowInsets(relleno),
            backStack = pila,
            onBack = { if (pila.size > 1) pila.removeAt(pila.lastIndex) },
            entryDecorators = listOf(
                rememberSaveableStateHolderNavEntryDecorator(),   // primero: el scroll
                rememberViewModelStoreNavEntryDecorator(),        // después: un ViewModel por entrada
            ),
            entryProvider = entryProvider {
                entry<ListaKey> {
                    PokemonListScreen(alPulsar = { id -> pila.add(DetalleKey(id)) })
                }
                entry<FavoritosKey> {   // NUEVO: la pantalla del paso 4
                    FavoritesScreen(alPulsar = { id -> pila.add(DetalleKey(id)) })
                }
                entry<DetalleKey> { clave ->
                    PokemonDetailScreen(
                        pokemonId = clave.pokemonId,
                        alVolver = { if (pila.size > 1) pila.removeAt(pila.lastIndex) },
                    )
                }
            },
        )
    }
}

Los import nuevos (AnimatedVisibility, slideInVertically, slideOutVertically, NavigationBar, NavigationBarItem, Scaffold, Icon, Icons, androidx.compose.material.icons.filled.Menu, androidx.compose.material.icons.filled.Favorite, Text, Modifier, WindowInsets, padding, consumeWindowInsets y FavoritesScreen) los propone Android Studio con Option+Enter (si alguno ya lo tienes de antes, no lo repitas). Hasta el paso 4, FavoritesScreen saldrá en rojo, porque aún no existe, y irAPestana también, hasta el paso 3.

La barra no tiene estado propio ni recuerda nada: es una función de la pila. En un detalle, actual deja de estar en PESTANAS y la barra se va deslizándose hacia abajo.

Un Scaffold dentro de otro: quién pone los márgenes

Cada Scaffold reserva por su cuenta el hueco de las barras del sistema. Aquí hay uno por fuera, el que lleva la barra inferior, y cada pantalla de dentro tiene el suyo (la lista, con su barra superior grande; Favoritos, con la suya). Si no haces nada, los dos reservan el mismo hueco y sobra espacio. El de fuera no tiene barra superior, así que lo que reserva arriba es la altura de la barra de estado; la barra superior de la pantalla de dentro la vuelve a sumar, porque nadie le ha dicho que ya está puesta.

Con dos líneas se arregla, y son las dos que acabas de escribir:

  • contentWindowInsets = WindowInsets(0, 0, 0, 0) en el Scaffold de fuera: «yo no pongo márgenes propios». Cada pantalla de dentro conserva los suyos y los pone donde le corresponde: arriba la barra de estado, abajo la barra de gestos.
  • .consumeWindowInsets(relleno) en el NavDisplay: da a las pantallas de dentro el hueco de la barra inferior (eso es padding(relleno)) y además les avisa de que ese hueco ya está puesto. «Consumir» un inset es marcarlo como ya aplicado, para que el siguiente que lo vea no lo cuente otra vez.

Abajo pasa lo mismo. El relleno que recibes ya incluye la altura de la NavigationBar, y la NavigationBar ya se aparta por su cuenta de la barra de gestos (la franja del borde inferior donde el sistema dibuja la raya de «inicio»). Si la pantalla de dentro no sabe que eso ya está puesto, reserva ella otra vez la barra de gestos, y entre el final de la rejilla y la barra de pestañas queda una franja vacía.

Qué se ve antes y después. Quita las dos líneas y ejecuta: el título «Pokédex» baja, porque la barra de estado se cuenta dos veces, y entre la rejilla y la barra de pestañas aparece esa franja vacía. Con las dos líneas puestas, el título sube y la rejilla llega hasta la barra. La ficha, que no lleva barra de pestañas, no cambia: sigue con su flecha justo debajo de la barra de estado. Cuánto sobra depende de cada móvil, porque cada uno tiene sus barras. Vuelve a poner las dos líneas.

Paso 3 · Una sola pila: la decisión que ya conoces

El archivo. Sigue en PokedexApp.kt: añade irAPestana al final, fuera de la función PokedexApp. Es la función de Marcador con las claves de la Pokédex:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt
/**
 * Cambiar de pestaña vacía la pila y deja la pestaña sola.
 *
 * Con una sola pila, volver desde una pestaña distinta de la inicial cierra la
 * app; para evitarlo, la lista siempre queda debajo.
 */
private fun irAPestana(pila: MutableList<NavKey>, destino: NavKey) {
    if (pila.lastOrNull() == destino) return   // tocar donde ya estás no hace nada
    pila.clear()                                // cambiar de pestaña olvida la anterior
    if (destino != ListaKey) pila.add(ListaKey) // la lista queda debajo: atrás no cierra la app
    pila.add(destino)
}

Las tres decisiones son las de Navegación en Marcador:

  1. No hacer nada si ya estás en esa pestaña. Con estas claves la pila quedaría igual aunque la vaciaras y la rellenaras, así que la pantalla tampoco se recrearía. La línea deja escrita la intención y se ahorra el trabajo.
  2. Olvidar el camino de la pestaña anterior (clear()): una sola pila, sin memoria de dónde estabas.
  3. Dejar la lista debajo. Sin la línea de if (destino != ListaKey), en Favoritos la pila tendría un solo elemento: el onBack del NavDisplay está escrito como if (pila.size > 1) …, así que no haría nada, y cuando la app no consume el gesto de «atrás», el sistema lo entiende como «sal de aquí» y cierra la app. Para quien la usa es un cierre inesperado: entró en una sección y «atrás» lo echó. Si quieres verlo, quita esa línea, ve a Favoritos y pulsa atrás; después, vuelve a ponerla. Esto explica por qué PESTANAS empieza por ListaKey: hay una pestaña que es la raíz, y no todas son iguales.

Paso 4 · La pantalla de favoritos

Es la parte nueva. Un componente de Material que no has usado: ModalBottomSheet, una hoja inferior modal: sube desde el borde de abajo, tapa parte de la pantalla y bloquea lo que hay detrás hasta que la cierras o eliges algo. No tiene equivalente compartido con iOS, y por eso las pantallas no se comparten en este curso: la lógica sí, FavoritesViewModel es el mismo en las dos apps.

Un ejemplo solo para leer (no lo escribas en el proyecto), con datos ajenos a la Pokédex: una cesta de la compra con una hoja de opciones.

ejemplo mínimo: una hoja inferior en una cesta de la compra (con comentarios)
@OptIn(ExperimentalMaterial3Api::class)            // ModalBottomSheet aún es experimental
@Composable
fun CestaConHoja() {
    // ¿Se ve la hoja? Un estado de pantalla, como cualquier otro.
    var abierta by remember { mutableStateOf(false) }
 
    Button(onClick = { abierta = true }) { Text("Opciones de la cesta") }
 
    // La hoja no se «esconde»: mientras `abierta` sea false, simplemente no existe.
    if (abierta) {
        ModalBottomSheet(
            // Se llama cuando la persona toca fuera de la hoja o la desliza hacia abajo.
            onDismissRequest = { abierta = false },
        ) {
            Text("Vaciar la cesta", modifier = Modifier.padding(24.dp))
            Button(
                onClick = { abierta = false },       // elegir algo también la cierra
                modifier = Modifier.padding(horizontal = 24.dp),
            ) { Text("Cerrar") }
        }
    }
}

Lo que se ve: un botón; al pulsarlo, una hoja sube desde abajo con un texto y un botón y oscurece lo de detrás; al deslizarla hacia abajo, tocar fuera o pulsar «Cerrar», baja y desaparece. Fíjate en que quien decide si la hoja existe es tu estado (abierta): la hoja no se cierra sola, avisa con onDismissRequest de que quieren cerrarla y tú lo apuntas.

El archivo. FavoritesScreen.kt es la pantalla de la pestaña Favoritos. Créalo en un paquete nuevo, favorites, dentro de androidApp: clic derecho sobre la carpeta androidApp → New → File y escribe la ruta desde esa carpeta:

src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt

Android Studio crea la carpeta favorites si no existe. (Si el menú no te lo ofrece, en la Terminal, desde la carpeta de tu proyecto; mkdir -p crea la carpeta y touch crea el fichero vacío: mkdir -p androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites y touch androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt.)

Las piezas. Antes de escribir:

  • ModalBottomSheet y la TopAppBar están marcados como experimentales en Material 3; por eso la función que los usa lleva @OptIn(ExperimentalMaterial3Api::class), que significa «sé que esta API puede cambiar» (lo viste en Compose: Scaffold, slots y tema).
  • FavoritesUiState.vacia viene de shared (lo escribiste en Favoritos en el núcleo): distingue «todavía cargando» de «de verdad no hay ninguno». El estado vacío se pinta a partir de vacia, no de isEmpty().
  • koinViewModel() pide el FavoritesViewModel al grafo de Koin, como en la lista.

Los import. Primero la cabecera del fichero, completa (si prefieres, escribe el resto y deja que Android Studio añada los import con Option+Enter, pero esta es la lista que usa el fichero al terminar esta lección):

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt
package com.sebastiannarvaez.pokedex.android.favorites
 
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.sebastiannarvaez.pokedex.domain.FavoritePokemon
import com.sebastiannarvaez.pokedex.feature.favorites.FavoritesViewModel
import org.koin.compose.viewmodel.koinViewModel

Las dos piezas de relleno. Son diseño sin concepto nuevo (la fila con imagen, nombre y número, y el aviso de «sin favoritos»): pégalas tal cual a continuación de los import, sin entretenerte en ellas.

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt
@Composable
private fun Fila(favorito: FavoritePokemon, alPulsar: () -> Unit) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .background(MaterialTheme.colorScheme.surface)
            .clickable(onClick = alPulsar)
            .padding(horizontal = 20.dp, vertical = 12.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        AsyncImage(
            model = favorito.artworkUrl,
            contentDescription = null,
            contentScale = ContentScale.Fit,
            modifier = Modifier.size(52.dp).clip(RoundedCornerShape(14.dp)),
        )
        Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
            Text(
                favorito.name.replaceFirstChar { it.uppercase() },
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.SemiBold,
            )
            Text(
                "N.º ${favorito.id.toString().padStart(4, '0')}",
                style = MaterialTheme.typography.labelSmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
        }
    }
}
 
@Composable
private fun Vacio(modifier: Modifier = Modifier) {
    Column(
        modifier = modifier.fillMaxSize().padding(32.dp),
        verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Icon(
            Icons.Default.FavoriteBorder,
            contentDescription = null,
            tint = MaterialTheme.colorScheme.onSurfaceVariant,
            modifier = Modifier.size(44.dp),
        )
        Text("Todavía no hay favoritos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
        Text(
            "Toca el corazón de cualquier Pokémon para guardarlo aquí.",
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
            textAlign = TextAlign.Center,
        )
    }
}

La pantalla. Lo que hay que mirar: dos estados locales (hojaAbierta y el estado de la hoja, que es el mismo patrón del ejemplo de la cesta), un Scaffold con una TopAppBar cuyo botón de tres puntos solo existe si hay favoritos, y un when que pinta el aviso o la lista. Un when escrito sin nada entre paréntesis (when {) comprueba sus condiciones de arriba abajo y usa la primera que sea verdad; else recoge el resto. Cada fila es la Fila de arriba: al tocarla se abre la ficha. Añádela debajo:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt
/**
 * Los favoritos, con una hoja inferior modal para la accion destructiva.
 *
 * En iOS el equivalente es otro —un dialogo de confirmacion— y por eso se
 * escribe aparte.
 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FavoritesScreen(
    alPulsar: (Int) -> Unit,
    modifier: Modifier = Modifier,
    viewModel: FavoritesViewModel = koinViewModel(),
) {
    val estado by viewModel.uiState.collectAsStateWithLifecycle()
    var hojaAbierta by remember { mutableStateOf(false) }
    val estadoHoja = rememberModalBottomSheetState()
 
    Scaffold(
        modifier = modifier.fillMaxSize(),
        topBar = {
            TopAppBar(
                title = { Text("Favoritos", fontWeight = FontWeight.Bold) },
                actions = {
                    if (estado.favoritos.isNotEmpty()) {
                        IconButton(onClick = { hojaAbierta = true }) {
                            Icon(Icons.Default.MoreVert, contentDescription = "Más opciones")
                        }
                    }
                },
            )
        },
    ) { relleno ->
        when {
            estado.vacia -> Vacio(Modifier.padding(relleno))
 
            else -> LazyColumn(modifier = Modifier.padding(relleno).fillMaxSize()) {
                items(estado.favoritos, key = { it.id }) { favorito ->
                    Fila(favorito, alPulsar = { alPulsar(favorito.id) })
                    HorizontalDivider(color = MaterialTheme.colorScheme.surfaceContainerHighest)
                }
            }
        }
    }
 
    if (hojaAbierta) {
        ModalBottomSheet(onDismissRequest = { hojaAbierta = false }, sheetState = estadoHoja) {
            Text(
                "Favoritos",
                style = MaterialTheme.typography.titleMedium,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
            )
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable {
                        viewModel.vaciar()
                        hojaAbierta = false
                    }
                    .padding(horizontal = 24.dp, vertical = 16.dp),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.spacedBy(16.dp),
            ) {
                Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error)
                Text("Quitar todos", color = MaterialTheme.colorScheme.error)
            }
            Text(
                "Los favoritos se guardan solo en este dispositivo.",
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 32.dp),
            )
        }
    }
}

Dos detalles de la hoja. rememberModalBottomSheetState() guarda el estado interno de la hoja (si está subiendo, a medias o bajando); en el ejemplo de la cesta no hacía falta escribirlo, porque ModalBottomSheet crea uno igual por su cuenta. Aquí se lo pasamos a sheetState para tenerlo a mano: te serviría para subir o bajar la hoja desde el código, como en estadoHoja.hide(). Para esta lección no hace falta, y quitarlo no cambia lo que ves. Y el texto «Quitar todos» va en rojo (colorScheme.error) a mano: Material no sabe que esa acción es destructiva, así que el color lo eliges tú.

Paso 5 · Comprobar

Ejecuta la app (configuración androidApp, como en Ejecutar en Android). Marca dos Pokémon con el corazón de la lista y comprueba que:

  • Aparece la barra inferior con «Pokédex» y «Favoritos».
  • En Favoritos están los dos que marcaste; al tocar uno se abre su ficha y la barra desaparece.
  • Al volver de una ficha, la rejilla de la Pokédex sigue donde la dejaste (de eso se ocupan los dos decoradores de la pila).
  • Desde Favoritos, el botón atrás del sistema lleva a la Pokédex y no cierra la app.
  • El título «Pokédex» y el de «Favoritos» quedan justo debajo de la barra de estado, sin una franja vacía encima, y la rejilla llega hasta la barra de pestañas.
  • El botón de tres puntos abre la hoja inferior y «Quitar todos» vacía la lista y muestra el aviso «Todavía no hay favoritos».

Punto de control

  • La barra inferior aparece en Pokédex y en Favoritos, y desaparece al abrir una ficha.
  • Sin franja vacía sobre los títulos ni entre la rejilla y la barra de pestañas.
  • Al volver de una ficha, la rejilla de la Pokédex sigue donde la dejaste.
  • Estando en Favoritos, el botón atrás lleva a la Pokédex y no cierra la app.
  • La hoja de los tres puntos vacía la lista, y con cero favoritos se ve el estado vacío, no una pantalla en blanco.
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test sigue en BUILD SUCCESSFUL con 51 tests en la JVM, 40 en Android host y 42 en iOS, sin contar los de la carpeta ejercicios: la lección no añade ninguno, porque es pantalla.

Falta un gesto: quitar un favorito sin abrir la hoja, deslizando su fila. Es la lección siguiente, con su aviso de «Deshacer»: Deslizar y deshacer en Android.

Comprueba lo que entendiste

7 preguntas · necesitas 5 para completar la lección

  1. 1.¿Qué hace la Pokédex con las pilas de las pestañas?

  2. 2.En irAPestana, ¿qué evita la línea if (destino != ListaKey) pila.add(ListaKey)?

  3. 3.¿Qué aporta la línea if (pila.lastOrNull() == destino) return al principio de irAPestana?

  4. 4.¿Cómo decide la app si pinta la barra inferior?

  5. 5.La app tiene un Scaffold por fuera (con la barra de pestañas) y uno por pantalla. Sin ningún ajuste, ¿qué se ve y cómo se arregla?

  6. 6.¿Qué distingue a un ModalBottomSheet de una pantalla nueva?

  7. 7.¿Por qué el estado vacío de Favoritos se decide con estado.vacia y no con estado.favoritos.isEmpty()?

Faltan 7 de 7