Saltar al contenido
K
Ajustes con DataStorePaso a paso35 min Básico

Ajustes en iOS

Al terminar: Montar la misma pantalla con Form, aplicar el tema con preferredColorScheme y mostrar el número de favoritos con badge.

La misma pantalla y el mismo SettingsViewModel que en Android, pero con una interfaz escrita desde cero. Al terminar esta lección iOS tendrá su tercera pestaña, un Form con secciones, el tema aplicado sobre el TabView, el número de favoritos sobre la pestaña de Favoritos con .badge, y el orden de favoritos que elijas se verá también en la lista. De Kotlin no hay que cambiar casi nada: el ViewModel es el de shared y el orden ya lo calcula desde Ajustes y favoritos para toda la app, para las dos plataformas; en iOS solo cambia lo que se ve.

Las vistas, @State, Picker, Toggle y .task ya los conoces de SwiftUI (@State, @EnvironmentObject: los ajustes para toda la app, .task frente a .onAppear): aquí solo se usan. Lo nuevo de SwiftUI es poco: LabeledContent, .badge y nil como «sin preferencia» en preferredColorScheme. Lo propio de Kotlin Multiplatform es el puente que ya hiciste en El mismo ViewModel en iOS y cómo se ve un enumerado de Kotlin desde Swift.

Los pasos de Kotlin se hacen en Android Studio y los de Swift, en Xcode.

Lo nuevo, con un ejemplo ajeno al proyecto

Tres piezas de SwiftUI aparecen en esta lección. Un ejemplo con una tienda de barrio, para verlas sin Pokémon por medio:

  • LabeledContent("Tienda", value: "La Esquina") es una fila de solo lectura: la etiqueta a la izquierda y el valor a la derecha, en gris, como en los Ajustes del iPhone. No hay nada que colocar ni que alinear.
  • .badge(texto) pone un globito con ese texto sobre la pestaña. Si el texto es nil (nada), no se pinta ningún globo.
  • .preferredColorScheme(esquema) fuerza el modo claro o el oscuro en una vista y en todo lo que cuelga de ella. Acepta un opcional y nil significa «no impongo ninguno»: iOS usa el del sistema. Es el mismo termostato de Android: se monta donde calienta toda la casa, en la raíz.
ejemplo mínimo: LabeledContent, badge y preferredColorScheme (con comentarios)
import SwiftUI
 
struct TiendaView: View {
    // `String?`: puede ser nil (sin texto) o un texto como "3".
    @State private var pendientes: String? = "3"
    // `ColorScheme?`: nil significa «no impongo ninguno, que decida el sistema».
    @State private var esquema: ColorScheme? = nil
 
    var body: some View {
        TabView {
            // Dos filas de solo lectura: etiqueta a la izquierda, valor a la derecha.
            Form {
                LabeledContent("Tienda", value: "La Esquina")
                LabeledContent("Abierta desde", value: "1998")
            }
            .tabItem { Label("Datos", systemImage: "info.circle") }
 
            Text("Pedidos")
                .tabItem { Label("Pedidos", systemImage: "bag") }
                // El globo con el texto sobre la pestaña; con nil no se pinta nada.
                .badge(pendientes)
        }
        // Se pone en la raíz, sobre el TabView: afecta a las dos pestañas.
        .preferredColorScheme(esquema)
    }
}
 
// Lo que alimenta la vista previa del lienzo.
#Preview {
    TiendaView()
}

En la vista previa (el panel de la derecha de Xcode, con Editor → Canvas o Option+Cmd+Enter) verás dos pestañas, «Datos» con las dos filas y «Pedidos» con un globo con un 3. Si pones pendientes = nil, el globo desaparece; si pones esquema = .dark, las dos pestañas se oscurecen.

Paso 1 · El puente, y un enumerado que llega como clase

shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/settings
SettingsIos.ktnuevo
iosApp/iosApp
Settings/SettingsView.swiftnueva
ContentView.swiftse modifica
Un fichero de puente en iosMain, una vista nueva y un cambio en la raíz.

El archivo del puente. SettingsIos.kt es el equivalente, para ajustes, de los puentes de El mismo ViewModel en iOS y de Favoritos en iOS: una función que pide el ViewModel a Koin y una propiedad anotada por cada flujo. Créalo en iosMain: clic derecho sobre el nodo shared → New → File y escribe la ruta desde la carpeta del módulo:

src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/settings/SettingsIos.kt

(Si el menú no te lo ofrece, en la Terminal, desde la carpeta de tu proyecto, estos dos comandos: mkdir -p shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/settings y después touch shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/settings/SettingsIos.kt.) En el panel Project lo verás en shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/settings/.

shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/settings/SettingsIos.kt
package com.sebastiannarvaez.pokedex.feature.settings
 
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
import kotlinx.coroutines.flow.StateFlow
import org.koin.mp.KoinPlatform
 
// Pide el SettingsViewModel a Koin y lo guarda en `owner` (lo pasa SwiftUI).
// Es la misma receta de los puentes anteriores; los trozos raros, uno a uno.
fun settingsViewModel(owner: ViewModelStoreOwner): SettingsViewModel {
    val koin = KoinPlatform.getKoin()   // el contenedor de Koin ya arrancado
    return ViewModelProvider.create(    // «dame el ViewModel guardado en `owner`, y si no hay, créalo»
        owner = owner,
        // La fábrica: la receta para crearlo cuando no existe, pidiéndoselo a Koin.
        factory = viewModelFactory { initializer { koin.get<SettingsViewModel>() } },
    )[SettingsViewModel::class]         // `[X::class]`: «el de tipo X» (el `owner` puede guardar varios, uno por tipo)
}
 
// Genera `settingsForIos` (valor actual) y `settingsForIosFlow` (para `for await`).
@NativeCoroutinesState
val SettingsViewModel.settingsForIos: StateFlow<Settings>
    get() = settings   // una propiedad con `get() =`: se calcula cada vez que se lee

El enumerado, visto desde Swift. No hace falta puente para Tema: llega solo. Pero llega como clase, no como un enum de Swift.

Tres consecuencias que vas a ver en el código de Swift: los valores se escriben en minúscula (Tema.sistema, Tema.claro, Tema.oscuro), la lista de todos es Tema.entries, y un switch sobre un Tema pide siempre un default (el compilador de Swift no sabe cuántos valores tiene una clase). Además, cada valor sabe cómo se escribió en Kotlin: su propiedad name es un texto con ese nombre, en mayúsculas ("SISTEMA", "CLARO", "OSCURO"); la usarás en el paso 2. Para comparar con == y para Picker no hay problema: las constantes del enumerado son objetos únicos y las clases de Kotlin se pueden comparar y usar como identificador. Más sobre cómo cruzan los enumerados en Los tipos que cruzan a Swift.

Paso 2 · La pantalla: Form, el contenedor del sistema

El archivo. SettingsView.swift es la pantalla de la pestaña. Va en una carpeta nueva, Settings, junto a List, Detail y Favorites. Créala desde la Terminal, en la carpeta de tu proyecto: mkdir iosApp/iosApp/Settings. Xcode la muestra sola (la carpeta iosApp está sincronizada con el disco). Después, en el Project navigator (Cmd+1: iosApp → iosApp), clic derecho sobre Settings → New File from Template… → Swift File → Next, nombre SettingsView y Create. (Plan B, en la Terminal: touch iosApp/iosApp/Settings/SettingsView.swift.) Es el mismo método de La lista en iOS.

El concepto. Form con secciones es el lenguaje de Apple para una pantalla de ajustes: agrupa, pone los separadores, respeta el tamaño de letra del sistema y en iPad se adapta solo. En Android hubo que componer las filas a mano y escribir una Fila reutilizable; aquí Form, Section, Picker, Toggle y LabeledContent hacen ese trabajo.

Primero la cabecera: los import, el estado de la vista y el NavigationStack con el título.

iosApp/iosApp/Settings/SettingsView.swift: la cabecera
import SwiftUI
import Shared
import KMPNativeCoroutinesAsync
 
/// Los ajustes en iOS: un `Form` con secciones.
struct SettingsView: View {
 
    // Quien guarda el ViewModel de Kotlin mientras la vista viva (ver
    // «El mismo ViewModel en iOS»): @StateObject lo crea una sola vez.
    @StateObject private var owner = IosViewModelStoreOwner()
    // Todos los parámetros: los valores por defecto de Kotlin no cruzan a Swift
    // (ver «Corrutinas en commonMain»).
    @State private var ajustes = Settings(tema: Tema.sistema, favoritosPorNumero: false)
    @State private var viewModel: SettingsViewModel?   // se pide a Koin en la `.task`
 
    var body: some View {
        NavigationStack {
            Form {
                // Las secciones, a continuación.
            }
            .navigationTitle("Ajustes")
        }
    }
}

Dentro del Form, la primera sección: el tema. Un Picker con una fila por valor del enumerado. La forma Section { filas } header: { título } footer: { nota } tiene tres llaves, cada una con su etiqueta: la primera lleva las filas, header: lo que va encima (el título de la sección) y footer: lo que va debajo (una nota). Es lo mismo que Section("Favoritos") { … } de más abajo, pero con el título y la nota hechos con vistas.

iosApp/iosApp/Settings/SettingsView.swift: la sección del tema, dentro del Form
                Section {
                    // `Binding(get:set:)` crea a mano un enlace de lectura y
                    // escritura: `get` dice de dónde se lee el valor y `set`
                    // qué se hace cuando la interfaz lo cambia. Aquí lee del
                    // estado y escribe en el ViewModel.
                    Picker("Tema", selection: Binding(
                        get: { ajustes.tema },
                        set: { viewModel?.cambiarTema(tema: $0) }
                    )) {
                        // Una opción por cada valor: `Tema.entries` es la lista de todos.
                        ForEach(Tema.entries, id: \.self) { tema in
                            Text(tema.etiqueta).tag(tema)   // `etiqueta`: se escribe abajo
                        }
                    }
                } header: {
                    Text("Apariencia")
                } footer: {
                    Text("«Lo que diga el sistema» sigue el ajuste de iOS.")
                }

Cuatro piezas de ese bloque ya las has usado:

El Picker no guarda una copia propia del tema elegido: lo que marca es lo que llegó por el flujo (ajustes.tema) y al elegir solo le pide al ViewModel que lo cambie. Así la pantalla y los datos no se pueden desincronizar: si la escritura falla, el Picker se queda donde estaba.

Las otras dos secciones, debajo de la anterior y dentro del mismo Form. LabeledContent("App", value: "Pokédex") es la fila de solo lectura del ejemplo de arriba:

iosApp/iosApp/Settings/SettingsView.swift: las otras dos secciones, dentro del Form
                Section("Favoritos") {
                    Toggle("Ordenar por número", isOn: Binding(
                        get: { ajustes.favoritosPorNumero },
                        set: { viewModel?.cambiarOrden(porNumero: $0) }
                    ))
                }
 
                // `LabeledContent`: una fila de etiqueta y valor, alineada como
                // las del sistema y sin nada que colocar.
                Section("Acerca de") {
                    LabeledContent("App", value: "Pokédex")
                    LabeledContent("Datos", value: "PokeAPI")
                }

Y, después del NavigationStack y dentro de body (es decir, tras la llave que cierra el NavigationStack), la tarea que pide el ViewModel y recoge el flujo. Es la misma .task de El mismo ViewModel en iOS (SettingsIosKt es el nombre que Kotlin le da a las funciones sueltas de SettingsIos.kt: nombre del fichero más Kt): ajustes = vm.settingsForIos lee de golpe el valor actual para que la pantalla no empiece vacía, y el for try await recibe cada cambio.

iosApp/iosApp/Settings/SettingsView.swift: la tarea, dentro de body y tras el NavigationStack
        .task {
            // `a ?? b`: «usa `a`, y si es nil, usa `b`». Si la `.task` se vuelve a lanzar
            // (SwiftUI puede hacerlo si la vista desaparece y vuelve), no pide otro ViewModel.
            let vm = viewModel ?? SettingsIosKt.settingsViewModel(owner: owner)
            viewModel = vm
            ajustes = vm.settingsForIos                  // el valor actual
            do {
                for try await nuevo in asyncSequence(for: vm.settingsForIosFlow) {
                    ajustes = nuevo                      // cada cambio posterior
                }
            } catch {
                // Cancelar no es fallar.
            }
        }

El catch vacío es a propósito: cuando la pantalla desaparece, SwiftUI cancela la .task y el for try await termina lanzando un error de cancelación. No es un fallo, así que no se hace nada; por eso el comentario.

Por último, fuera del struct, al final del fichero, el nombre legible de cada tema (en la app y no en shared, igual que en Android) y el esquema de color que le corresponde:

iosApp/iosApp/Settings/SettingsView.swift: la extensión de Tema, al final del fichero
extension Tema {
    /// El nombre legible. Es un `switch` sobre `name` (el texto con que se
    /// escribió el valor en Kotlin: «CLARO», «OSCURO»…), así que necesita un
    /// `default`: un texto tiene infinitos valores posibles.
    var etiqueta: String {
        switch name {
        case "CLARO": return "Claro"
        case "OSCURO": return "Oscuro"
        default: return "Lo que diga el sistema"
        }
    }
 
    /// El esquema que pide SwiftUI. `nil` significa «el del sistema».
    var esquema: ColorScheme? {
        switch name {
        case "CLARO": return .light
        case "OSCURO": return .dark
        default: return nil
        }
    }
}

Cuidado con el texto: en "CLARO" y "OSCURO" importan las mayúsculas y cada letra. Si te equivocas ("Claro", "OSCUOR"), Swift no avisa: el valor cae en el default y la pantalla dice «Lo que diga el sistema» sin que nada falle. Si «Claro» u «Oscuro» no responden como esperas, mira primero esas comillas.

Ese nil es la pieza elegante de la lección. preferredColorScheme acepta un opcional, y nil significa «no impongo ninguno», que es exactamente el tercer estado del enumerado: no hay que consultar el tema del sistema ni convertirlo, se dice que no hay preferencia y iOS hace el resto.

Para comprobar que el fichero está completo, mira que tenga, de arriba abajo: los tres import; el struct SettingsView con sus cuatro propiedades (owner, ajustes, viewModel, el body); dentro del Form, tres secciones (Apariencia, Favoritos, Acerca de); la .task tras el NavigationStack; y, fuera del struct, la extension Tema. Si Xcode marca un error en etiqueta o esquema, falta compilar una vez para que el framework Shared conozca Tema (paso 4).

Paso 3 · La pestaña, el tema y la insignia, en la raíz

El archivo. ContentView.swift (iosApp/iosApp/ContentView.swift) es la raíz, la del TabView de Favoritos en iOS. Cuatro cambios; en cada bloque, lo nuevo es lo que se indica y lo demás ya estaba. (Y si quieres, cambia el comentario de arriba de struct ContentView, «Las dos secciones de la app», por «Las tres»: es solo un comentario.)

Primero, junto al import SwiftUI, los dos import que necesita para hablar con Kotlin:

iosApp/iosApp/ContentView.swift: imports nuevos
import Shared
import KMPNativeCoroutinesAsync

Segundo, tres estados nuevos junto a caminoLista y caminoFavoritos: el que guarda el ViewModel, el último valor de las preferencias y el texto de la insignia.

iosApp/iosApp/ContentView.swift: estados nuevos
    @StateObject private var owner = IosViewModelStoreOwner()
    @State private var ajustes = Settings(tema: Tema.sistema, favoritosPorNumero: false)
    @State private var insignia: String? = nil        // nil: sin globo en Favoritos

Tercero, la pestaña de Ajustes, debajo de la de Favoritos y dentro del TabView, y el .badge en la pestaña de Favoritos (debajo de su .tabItem):

iosApp/iosApp/ContentView.swift: la pestaña de Favoritos con su globo y la de Ajustes, dentro del TabView
            FavoritesView(camino: $caminoFavoritos)                          // ya estaba
                .tabItem { Label("Favoritos", systemImage: "heart.fill") }   // ya estaba
                .badge(insignia)   // NUEVO: con nil SwiftUI no pinta nada
 
            SettingsView()   // NUEVO: la pestaña entera
                .tabItem { Label("Ajustes", systemImage: "gearshape") }

Cuarto, después de la llave que cierra el TabView: el tema y las dos tareas que lo mantienen al día, una por cada dato.

iosApp/iosApp/ContentView.swift: tema y tareas, tras el TabView
        // El tema se aplica en la raíz: un `.preferredColorScheme` dentro de la
        // pantalla de ajustes solo pintaría esa pestaña.
        .preferredColorScheme(ajustes.tema.esquema)
        .task {
            let vm = SettingsIosKt.settingsViewModel(owner: owner)
            ajustes = vm.settingsForIos
            do {
                for try await nuevo in asyncSequence(for: vm.settingsForIosFlow) {
                    ajustes = nuevo
                }
            } catch { }   // vacío a propósito: es la cancelación al desaparecer la vista
        }
        // La insignia de Favoritos: otro lector del mismo repositorio de favoritos.
        .task {
            let favoritos = FavoritesIosKt.favoritesViewModel(owner: owner)
            insignia = favoritos.insigniaForIos
            do {
                for try await nuevo in asyncSequence(for: favoritos.insigniaForIosFlow) {
                    insignia = nuevo
                }
            } catch { }
        }

La segunda .task es la misma receta que la primera, con el ViewModel de favoritos (el puente favoritesViewModel es el de Favoritos en iOS) y la propiedad insigniaForIos que se escribió en Ajustes y favoritos para toda la app. insignia es un String?: nil sin favoritos, el número hasta 99 y «99+» desde 100. La regla la decide Kotlin, y Swift solo pinta lo que llega.

Un modificador de SwiftUI afecta a la vista sobre la que se aplica y a lo que cuelga de ella. Puesto dentro de SettingsView, la pestaña de ajustes se pondría oscura y las otras dos no; sobre el TabView, las tres: el termostato, montado donde calienta toda la casa.

El tema es estado de toda la app: un solo dueño (el almacén de DataStore, registrado como single en Koin) y varios lectores. En Android viste lo mismo con dos SettingsViewModel; aquí pasa igual. Hay dos SettingsViewModel vivos, el de la raíz y el de la pantalla de ajustes, y no es un problema: los dos observan el mismo flujo del mismo almacén (la regla de Ajustes en el núcleo era «un solo DataStore por fichero», no «un solo lector»). Y es seguro usar el mismo owner para pedir el ViewModel de ajustes y el de favoritos: el owner guarda uno por cada tipo, sin mezclarlos.

Paso 4 · Comprueba

Compila una vez (Product → Build, Cmd+B) para que Xcode regenere el framework Shared con SettingsIosKt; si no, saldrá como «cannot find». Después ejecuta (Cmd+R) en el simulador.

  1. Ve a la pestaña Ajustes y elige «Oscuro» en el selector de Tema: las tres pestañas se oscurecen.
  2. Marca tres Pokémon con el corazón, por ejemplo el 4, el 1 y el 2 en ese orden. El globo de la pestaña Favoritos cuenta todos tus favoritos: con ninguno antes, un 3; si ya tenías, tu cifra más 3. Desmarca uno y baja en 1; quita todos y el globo desaparece.
  3. En Favoritos salen primero los últimos marcados (2, 1, 4) y debajo los que ya tenías. Marca «Ordenar por número» y mira la pestaña Favoritos: ahora van del número menor al mayor, con el 1, el 2 y el 4 en ese orden.
  4. Cierra y abre la app: en Xcode, detén la ejecución (Cmd+punto) y ejecútala otra vez (Cmd+R). El simulador conserva lo guardado, así que el tema y el orden siguen como los dejaste.

Para probar «Lo que diga el sistema», elígelo y cambia la apariencia del simulador desde la Terminal: xcrun simctl ui booted appearance dark la pone oscura y xcrun simctl ui booted appearance light clara (booted significa «el simulador que esté encendido ahora»); la app la sigue al momento.

Para dejar los tests verificados, en la Terminal, en la carpeta de tu proyecto, lanza los de las tres plataformas (cada comando cuenta los de una): ./gradlew :shared:jvmTest, :shared:testAndroidHostTest y :shared:iosSimulatorArm64Test. Esta lección no añade ninguno: siguen en 76, 40 y 43.

Punto de control

  • La app tiene tres pestañas y la de Ajustes se ve como un Form del sistema.
  • Elegir «Oscuro» oscurece las tres pestañas, no solo la de ajustes.
  • Con «Lo que diga el sistema», cambiar la apariencia del propio simulador cambia la de la app sin reabrirla: en la Terminal, xcrun simctl ui booted appearance dark (y light para volver).
  • La pestaña Favoritos muestra el número de favoritos, sin globo cuando no hay ninguno.
  • El interruptor de orden reordena la lista de la pestaña de Favoritos.
  • El tema elegido sigue puesto después de cerrar la app y volver a abrirla.
  • Esta lección no añade tests: ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 76 (JVM), 40 (Android host) y 43 (iOS).

Opcional · Provócalo: añade un cuarto tema y mira qué dice cada plataforma

Esto no hace falta para seguir con el curso, pero enseña una diferencia real entre las dos plataformas. Toca un fichero de tu proyecto, así que primero se guarda una copia y al final se restaura.

  1. En Android Studio abre shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/settings/Settings.kt, selecciona todo (Cmd+A), copia (Cmd+C) y pégalo en una nota nueva de la app Notas del Mac (o en un documento de TextEdit). No dupliques el fichero en su carpeta: dos Settings.kt declaran dos veces Tema y no compila.
  2. En Settings.kt añade un cuarto valor: enum class Tema { SISTEMA, CLARO, OSCURO, AMOLED }.
  3. Compila Android: en la Terminal, ./gradlew :androidApp:compileDebugKotlin.
  4. Compila iOS: en Xcode, Cmd+B.
  5. Cuando lo hayas visto, vuelve a Settings.kt, selecciona todo, pega la copia de la nota y borra la nota. Después vuelve a lanzar los tests (./gradlew :shared:jvmTest) para confirmar que el proyecto quedó como estaba: 76.

Android no compila, y te dice exactamente dónde. El error, traducido, es «la expresión when tiene que cubrir todos los casos: añade la rama AMOLED o una rama else». En el original sale así:

'when' expression must be exhaustive. Add the 'AMOLED' branch or an 'else' branch.

dos veces: en PokedexTheme (cómo se resuelve el tema) y en Tema.etiqueta (cómo se llama). Esos dos when están en androidApp, no en shared: por eso el módulo compartido compila bien y falla solo la app de Android. El compilador de Kotlin conoce todos los valores del enumerado y te obliga a decidir qué hace cada rama.

iOS compila sin una queja, precisamente porque shared compila (ahí no hay ningún when roto). El Picker enseña una cuarta fila (sale de Tema.entries) y la llama «Lo que diga el sistema», porque ese es el default del switch sobre el texto; y su esquema es nil. Funciona, pero hace algo que nadie ha decidido. Es la contrapartida de que el enumerado llegue como clase: Swift no puede comprobar que has cubierto todos los casos, y el default que exige lo tapa.

La regla práctica: cuando añadas un valor a un enumerado de shared, compila Android primero (te lista lo que falta) y revisa a mano los switch de Swift que lo recorren.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Cómo llega a Swift un enum class de Kotlin, y qué cambia al usarlo?

  2. 2.En Android, un when sobre Tema debe cubrir todos los valores; en iOS, el enumerado llega como clase y su switch lleva default. Si añades un cuarto valor a Tema en shared, ¿qué ocurre?

  3. 3.En .preferredColorScheme(ajustes.tema.esquema), ¿qué significa que esquema sea nil?

  4. 4.¿Por qué el .preferredColorScheme va en ContentView y no dentro de SettingsView?

  5. 5.La raíz y la pantalla de ajustes tienen cada una su SettingsViewModel, y el almacén de preferencias (DataStore) es uno solo. ¿Es un problema?

  6. 6.No hay ningún favorito y insignia vale nil. ¿Qué pasa con .badge(insignia) sobre la pestaña de Favoritos?

Faltan 6 de 6