Jetpack Compose Pro: Creando una UI "Zen" con Canvas y MVVM (Caso Real)
Inicia sesión para descargarDesarrollar apps con interfaces complejas suele terminar en código espagueti. (Problema). Esto dificulta el mantenimiento y crea bugs visuales constantes. (Agitación). En este tutorial, aprenderás a usar el sistema de diseño de …
Contenido del tutorial ⌄
- Arquitectura de Kairos App
- 1. La Estructura del Proyecto (MVVM + Clean)
- 2. Organización: Package by Feature
- 3. El Corazón de la Configuración: AndroidManifest.xml
- 4. El Entry Point: MainActivity y Navegación Global
- 5. Inicialización Global: KairosApp.kt
- 6. El Contrato MVI: State, Events y Effects
- 7. El Intermediario Inteligente: CommunityRoute
- 8. El Cerebro Lógico: CommunityViewModel
- 9. El Dashboard Personal: HomeContract
- 10. El Orquestador: HomeRoute
- 11. Gestión de Estado y Lógica: HomeViewModel
- 12. Lectura Profunda: LibraryContract
- 13. Interactuando con el Sistema: LibraryRoute
- 14. Lógica de Negocio: LibraryViewModel
- 15. Minimalismo Radical: TempleContract
- 16. El Retorno: TempleRoute
- 17. Lógica Minimalista: TempleViewModel
- 18. Coreografía Visual: KairosNavHost
- 19. El Mapa de Direcciones: KairosRoutes
- 20. El UI Kit: KairosComponents
- 21. Layout Personalizado: KairosScaffold
Arquitectura de Kairos App
¿Cómo se construye un "santuario digital" en Android? En este post destripamos el código de Kairos, una aplicación nativa diseñada para la serenidad. Veremos cómo implementar una UI inmersiva con Jetpack Compose, gestión de estado limpia con MVVM y transiciones fluidas entre modos de lectura y meditación.
El reto de Kairos no era solo técnico, sino atmosférico. La aplicación necesita transmitir paz a través de animaciones suaves (usando Canvas y Transitions) y una estructura de navegación que no abrume al usuario.
Un sistema inmersivo de pantalla completa con animaciones de partículas (estrellas) dibujadas en Canvas nativo.
Implementación de un "Confesionario Digital" protegido por principios de Zero-Knowledge Encryption.
Componentes de UI personalizados para lectura profunda, con tipografías Serif y layouts tipo libro.
1. La Estructura del Proyecto (MVVM + Clean)
Hemos organizado la app por features (home, community, library, temple) para mantener el código desacoplado y escalable. Cada pantalla tiene su propio ViewModel que expone un UiState inmutable.
2. Organización: Package by Feature
En lugar de la clásica organización por capas (adapters, viewmodels, fragments mezclados), en Kairos optamos por Package by Feature. Esto significa que todo lo relacionado con una funcionalidad (como "Temple" o "Library") vive junto.
Si observas el árbol de archivos a continuación, notarás un patrón repetitivo en cada carpeta de feature/:
- Contract: Define el
UiState(inmutable), losUiEvents(acciones del usuario) y losUiEffects(navegación/toast). Es el contrato entre la UI y la lógica. - ViewModel: Gestiona el estado y la lógica de negocio.
- Route: El Composable "inteligente" que conecta el ViewModel con la navegación y pasa los datos a la pantalla.
Por otro lado, la carpeta ui/ contiene componentes puros y pantallas "tontas" (stateless) que solo saben renderizar datos y emitir eventos, sin conocer el ViewModel. Esta separación es clave para poder previsualizar (Preview) y testear la UI aisladamente.
.
├── MainActivity.kt
├── app
│ └── KairosApp.kt
├── feature
│ ├── community
│ │ ├── CommunityContract.kt
│ │ ├── CommunityRoute.kt
│ │ └── CommunityViewModel.kt
│ ├── home
│ │ ├── HomeContract.kt
│ │ ├── HomeRoute.kt
│ │ └── HomeViewModel.kt
│ ├── library
│ │ ├── LibraryContract.kt
│ │ ├── LibraryRoute.kt
│ │ └── LibraryViewModel.kt
│ └── temple
│ ├── TempleContract.kt
│ ├── TempleRoute.kt
│ └── TempleViewModel.kt
├── navigation
│ ├── KairosNavHost.kt
│ └── KairosRoutes.kt
└── ui
├── components
│ └── KairosComponents.kt
├── layout
│ ├── KairosScaffold.kt
│ └── PhoneFrame.kt
├── modals
│ └── SeedModal.kt
├── screens
│ ├── CommunityScreen.kt
│ ├── HomeScreen.kt
│ ├── LibraryScreen.kt
│ └── TempleModeScreen.kt
└── theme
├── Color.kt
├── Shapes.kt
├── Theme.kt
├── Tokens.kt
└── Type.kt
3. El Corazón de la Configuración: AndroidManifest.xml
Antes de escribir código Kotlin, necesitamos configurar el punto de entrada de la aplicación. En Android, el AndroidManifest.xml es el documento de identidad de tu app.
Aquí definimos el nombre, el icono, el tema principal y, lo más importante, declaramos nuestra Activity principal que servirá como contenedor para todo nuestro contenido Compose.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:name=".app.KairosApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Kairos"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Kairos">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
📦 1) Configuración de la Aplicación (.app.KairosApp)
El tag <application> es donde ocurre la magia global:
• Application Class: android:name=".app.KairosApp"
Aquí le decimos a Android que use nuestra clase personalizada KairosApp (que hereda de Application) en lugar de la clase por defecto. Esto es crucial para inicializar librerías globales como Timber (para logs) o inyección de dependencias (Hilt/Koin) antes de que se muestre cualquier pantalla.
• Tema Global: android:theme="@style/Theme.Kairos"
Aunque Compose maneja sus propios temas en código, necesitamos definir un tema base en XML para el "Launch Screen" (la pantalla de carga inicial) para que no haya un flash blanco antes de que cargue nuestro contenido oscuro.
🚀 2) Single Activity Architecture (MainActivity)
En el desarrollo moderno con Jetpack Compose, seguimos el patrón Single Activity. Observa que solo hay un tag <activity> declarado:
android:name=".MainActivity"
Ya no declaramos una actividad por cada pantalla (HomeActivity, SettingsActivity, etc.). En su lugar, MainActivity es simplemente un contenedor vacío que alojará nuestro NavHost de Compose.
• Intent Filter:
El bloque <intent-filter> con ACTION_MAIN y CATEGORY_LAUNCHER le dice al sistema operativo: "Esta es la puerta de entrada. Pon un icono en el menú de aplicaciones que abra esta actividad".
exported="true", permitimos que el launcher del sistema pueda iniciar esta actividad. Si fuera false, la app no podría abrirse desde el menú principal.
🛡️ 3) Backup y Datos (dataExtractionRules)
Kairos es una app centrada en la privacidad ("Zero-Knowledge"). Por eso, la configuración de backup es delicada:
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
Estas líneas apuntan a reglas XML específicas donde definimos explícitamente qué datos se pueden subir a la nube de Google (Google Drive Backup) y qué datos nunca deben salir del dispositivo (como las llaves privadas de cifrado o la base de datos local del diario).
4. El Entry Point: MainActivity y Navegación Global
En Kairos, la MainActivity es ligera. No contiene lógica de negocio, solo configuración. Su única responsabilidad es inicializar el entorno de Jetpack Compose y delegar el control a un composable de alto nivel llamado KairosRoot.
Aquí implementamos Edge-to-Edge (dibujar detrás de las barras de sistema) para lograr esa estética inmersiva moderna, y configuramos el "cerebro" de nuestra navegación para manejar transiciones entre los modos de la app.
package co.holguin.kairos
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import co.holguin.kairos.navigation.KairosNavHost
import co.holguin.kairos.navigation.KairosRoutes
import co.holguin.kairos.ui.layout.KairosScaffold
import co.holguin.kairos.ui.layout.KairosTab
import co.holguin.kairos.ui.layout.PhoneFrame
import co.holguin.kairos.ui.theme.KairosTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
KairosTheme {
KairosRoot()
}
}
}
}
@Composable
private fun KairosRoot() {
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val destination = backStackEntry?.destination
val isTemple = destination?.route == KairosRoutes.TEMPLE
val selectedTab =
when {
destination?.hierarchy?.any { it.route == KairosRoutes.MAP } == true -> KairosTab.Map
destination?.hierarchy?.any { it.route == KairosRoutes.LIBRARY } == true -> KairosTab.Library
else -> KairosTab.Home
}
PhoneFrame {
KairosScaffold(
selectedTab = selectedTab,
showBottomBar = !isTemple,
onTabSelected = { tab ->
navController.navigate(tab.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
) { padding ->
KairosNavHost(navController = navController, padding = padding)
}
}
}
📱 1) Edge-to-Edge: Modernizando la UI
La llamada a enableEdgeToEdge() es fundamental en Android 15+.
Esto le indica al sistema que nuestra app quiere dibujar contenido detrás de la barra de estado (donde está la hora) y la barra de navegación (los gestos).
Sin esto, tendríamos barras negras o grises arriba y abajo. Al activarlo, nuestro fondo "Stone" o el cielo estrellado del modo Templo cubren el 100% de los píxeles físicos del dispositivo.
🧘 2) Lógica Reactiva: El Modo Inmersivo (isTemple)
¿Cómo sabe el Scaffold cuándo ocultar la barra de navegación inferior? Observando el estado del navController:
val isTemple = destination?.route == KairosRoutes.TEMPLE
Esta variable booleana reactiva se pasa al parámetro showBottomBar = !isTemple.
Gracias a esto, cuando el usuario navega a la pantalla "Templo", Compose detecta el cambio de ruta, recalcula isTemple a true, y el Scaffold elimina suavemente la barra inferior, dejando al usuario en completa inmersión.
🧭 3) Buenas Prácticas de Navegación (Bottom Bar)
Al hacer clic en un tab inferior, no hacemos un navigate simple. Usamos un bloque de configuración robusto:
popUpTo(...saveState = true): Al cambiar de tab, no destruimos la pantalla anterior, guardamos su estado (scroll, inputs). Si el usuario vuelve a "Lectio", sigue donde lo dejó.launchSingleTop = true: Evita que si el usuario toca "Home" 10 veces, se apilen 10 pantallas de Home en la memoria.restoreState = true: Restaura el estado guardado previamente.
5. Inicialización Global: KairosApp.kt
En Android, la clase Application es el primer componente que se instancia al abrir la app, incluso antes que la primera actividad. Es el lugar perfecto para configurar librerías que deben vivir durante todo el ciclo de vida de la aplicación.
Para Kairos, mantenemos esta clase minimalista. Su única responsabilidad actual es configurar nuestro sistema de logs (registro de eventos) para que sean útiles durante el desarrollo pero invisibles (y seguros) en producción.
package co.holguin.kairos.app
import android.app.Application
import co.holguin.kairos.BuildConfig
import timber.log.Timber
class KairosApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
}
}
🌲 1) ¿Por qué Timber y no Log.d?
Usamos la librería Timber en lugar de la clase nativa Log de Android por dos razones de productividad:
1. Tags Automáticos: Con Log.d("TAG", "msg") debes definir un tag manualmente. Timber detecta automáticamente el nombre de la clase desde donde llamas al log, ahorrándote código repetitivo.
2. Limpieza Automática: Timber funciona plantando "árboles" (Trees). Si no plantas ningún árbol, los logs no se escriben. Esto facilita la gestión entre entornos (Debug vs Release).
🔒 2) Privacidad y Seguridad en Producción
Observa la condición crítica:
if (BuildConfig.DEBUG) { ... }
Esta línea asegura que Timber.DebugTree() solo se plante cuando estás desarrollando (Debug). Cuando generas el APK/AAB firmado para la Play Store (Release), BuildConfig.DEBUG es falso.
¿Por qué es vital en Kairos?
Al ser una app tipo "diario personal", es inaceptable que los logs del sistema muestren información privada del usuario si alguien conecta el teléfono a un ordenador vía USB (logcat). Al bloquear el logging en producción, cerramos esa posible fuga de datos.
6. El Contrato MVI: State, Events y Effects
En Kairos, seguimos rigurosamente el patrón de Flujo de Datos Unidireccional (UDF). Para cada pantalla (Feature), definimos un "Contrato" que establece las reglas del juego antes de escribir una sola línea de UI o lógica.
Este archivo CommunityContract.kt no contiene lógica; solo contiene definiciones. Es el vocabulario común que compartirán nuestro ViewModel y nuestra Vista (Compose). Se divide en tres pilares: Estado (lo que se ve), Eventos (lo que el usuario hace) y Efectos (reacciones de un solo uso).
package co.holguin.kairos.feature.community
data class CommunityUiState(
val headerTitle: String = "Comunidad",
val headerSubtitle: String = "Encuentra tu lugar de paz.",
val mapTopRightLabel: String = "San José",
val temples: List<TempleItemUi> = listOf(
TempleItemUi(
id = "san_jose",
name = "Parroquia San José",
statusLabel = "Abierto",
detail = "• Misa 18:00"
),
TempleItemUi(
id = "santa_maria",
name = "Capilla Santa María",
statusLabel = "Abierto",
detail = "• Rosario 19:00"
)
)
)
data class TempleItemUi(
val id: String,
val name: String,
val statusLabel: String,
val detail: String
)
sealed interface CommunityUiEvent {
data object OnMarkerClick : CommunityUiEvent
data class OnTempleClick(val id: String) : CommunityUiEvent
}
sealed interface CommunityUiEffect {
// Reservado para K6/K9: abrir detalle, check-in, etc.
data class ShowMessage(val text: String) : CommunityUiEffect
}
📸 1) UiState: La "Foto" de la UI
CommunityUiState es una data class inmutable. Representa una "foto instantánea" de la pantalla en un momento dado.
¿Por qué inmutable?
Jetpack Compose redibuja (recomposes) la UI solo cuando detecta cambios. Al usar val (valores de solo lectura), obligamos a que cualquier cambio (como cargar nuevos templos) genere una copia nueva del estado (state.copy(...)). Esto elimina bugs donde la UI muestra datos viejos mezclados con nuevos.
📢 2) UiEvent: La Voz del Usuario
En lugar de que la UI llame directamente a funciones del ViewModel (ej: viewModel.loadTemples()), la UI emite "Eventos".
Usamos una sealed interface para listar exhaustivamente todo lo que el usuario puede hacer:
• Clic en un marcador (OnMarkerClick).
• Clic en una tarjeta de templo (OnTempleClick).
Esto desacopla la UI de la lógica. La UI solo dice "Hey, hicieron clic aquí", y el ViewModel decide qué hacer con esa información.
⚡ 3) UiEffect: Acciones de un solo disparo
Hay cosas que no son estado. Por ejemplo: mostrar un mensaje "Templo guardado" (Toast) o navegar a otra pantalla.
Si pusiéramos un mensaje de error dentro del UiState, al rotar la pantalla, el estado se restauraría y el mensaje de error volvería a aparecer (un bug clásico).
Los Effects son efímeros: se disparan una vez, la UI los consume (los muestra), y desaparecen para siempre.
7. El Intermediario Inteligente: CommunityRoute
En una arquitectura limpia con Jetpack Compose, nunca deberíamos inyectar el ViewModel directamente dentro de nuestros componentes visuales (los que dibujan textos y botones). Si lo hiciéramos, nuestras Previews (@Preview) dejarían de funcionar porque el ViewModel necesita el ciclo de vida de Android.
La solución es crear un "Route Composable". Este archivo actúa como un controlador: obtiene el ViewModel, recolecta los efectos secundarios (como navegación o logs) y pasa solamente los datos puros (el State) a la pantalla visual.
package co.holguin.kairos.feature.community
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import co.holguin.kairos.ui.screens.CommunityScreen
import timber.log.Timber
@Composable
fun CommunityRoute(
navController: NavHostController,
vm: CommunityViewModel = viewModel()
) {
LaunchedEffect(Unit) {
vm.effects.collect { eff ->
when (eff) {
is CommunityUiEffect.ShowMessage -> Timber.d("Community: ${eff.text}")
}
}
}
CommunityScreen(
state = vm.state,
onEvent = vm::onEvent
)
}
⚡ 1) LaunchedEffect: Escuchando el canal de efectos
Los efectos (como un Toast o navegar) solo deben ocurrir una vez. No queremos que se disparen de nuevo si rotamos la pantalla.
Usamos LaunchedEffect(Unit) para lanzar una corrutina que vive tanto tiempo como este Composable esté en pantalla. Dentro, nos suscribimos al flujo de efectos del ViewModel (vm.effects.collect).
🧩 2) State Hoisting (Elevación de Estado)
Fíjate que CommunityScreen no instancia el ViewModel. Recibe dos parámetros simples:
state: CommunityUiState(Datos puros).onEvent: (CommunityUiEvent) -> Unit(Una función callback).
Al hacer esto, desacoplamos la vista. Podríamos usar CommunityScreen en un panel de Storybook o en una Preview con datos falsos sin necesidad de crear un ViewModel complejo ni mockear bases de datos.
🎯 3) Referencia a Funciones (Method Reference)
En Kotlin, en lugar de escribir una lambda redundante como:
onEvent = { event -> vm.onEvent(event) }
Podemos pasar la referencia directa a la función usando :::
onEvent = vm::onEvent
Esto hace el código más limpio y legible, conectando directamente la salida de la UI con la entrada del ViewModel.
8. El Cerebro Lógico: CommunityViewModel
El CommunityViewModel es el único componente autorizado para modificar el estado de la pantalla. Aquí es donde ocurre la magia de Jetpack Compose simplificada: en lugar de usar complejos LiveData o StateFlow para el estado de la UI, utilizamos la delegación nativa de Compose.
Este componente cumple dos funciones vitales: mantener el UiState a salvo (encapsulado) y procesar los eventos que llegan desde la UI a través del método onEvent.
package co.holguin.kairos.feature.community
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
class CommunityViewModel : ViewModel() {
var state by mutableStateOf(CommunityUiState())
private set
private val _effects = Channel<CommunityUiEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()
fun onEvent(event: CommunityUiEvent) {
when (event) {
CommunityUiEvent.OnMarkerClick -> {
viewModelScope.launch { _effects.send(CommunityUiEffect.ShowMessage("Marker: templo seleccionado")) }
}
is CommunityUiEvent.OnTempleClick -> {
viewModelScope.launch { _effects.send(CommunityUiEffect.ShowMessage("Templo: ${event.id}")) }
}
}
}
}
✨ 1) mutableStateOf vs StateFlow
Verás que usamos: var state by mutableStateOf(...).
En la comunidad Android hay un debate eterno entre usar esto o StateFlow. Para el estado de la UI (que es sincrónico), preferimos mutableStateOf porque elimina el "ruido" de tener que usar .collectAsState() en la vista.
Compose observa automáticamente esta variable. Si hacemos state = state.copy(loading = true), la UI se repinta sola. El modificador private set es crucial: protege la integridad de los datos impidiendo que nadie fuera de esta clase los corrompa.
📡 2) Channels para Efectos
Para los efectos (navegación, mensajes) usamos un Channel.
A diferencia del estado, los eventos no deben persistir.
Si usáramos StateFlow para mostrar un error, y el usuario rota la pantalla, el error volvería a aparecer porque el "estado" de error sigue ahí. Con Channel, el evento se envía, se consume una vez en el LaunchedEffect de la ruta, y desaparece. Es el mecanismo perfecto para acciones de "fuego y olvido".
🎛️ 3) onEvent: El único punto de entrada
Observa que solo hay una función pública: onEvent(event).
No tenemos funciones como onMarkerClicked() o onTempleSelected() dispersas. Todo pasa por el embudo de onEvent. Esto hace que debuggear sea trivial: si algo sale mal, solo tienes que poner un log en el when de esta función para ver exactamente qué intención tuvo el usuario antes del fallo.
9. El Dashboard Personal: HomeContract
La pantalla de inicio (Home) es el centro de mando del usuario. Su contrato es interesante porque no solo muestra datos estáticos, sino que gestiona estado efímero de la interfaz, como la visibilidad de diálogos de seguridad.
Aquí definimos el estado para el saludo diario ("Paz y Bien"), los contadores de progreso espiritual y, lo más crítico, el flujo de seguridad para mostrar la "Seed Phrase" (Frase Semilla) de recuperación.
package co.holguin.kairos.feature.home
data class HomeUiState(
val dayLabel: String = "Martes, VI Semana",
val greeting: String = "Paz y Bien",
val introCount: Int = 4,
val obrasCount: Int = 2,
val showSeedModal: Boolean = false,
val seedPhrase: String = "ocean • lantern • faith • mountain • whisper • stone • grace",
val nearTempleTitle: String = "Parroquia San José",
val nearTempleSubtitle: String = "A solo unos pasos de silencio."
)
sealed interface HomeUiEvent {
data object OnShieldClick : HomeUiEvent
data object OnDismissSeed : HomeUiEvent
data object OnConfirmSeedSaved : HomeUiEvent
data object OnPrayerClick : HomeUiEvent
data object OnCharityClick : HomeUiEvent
data object OnOpenTempleClick : HomeUiEvent
}
sealed interface HomeUiEffect {
data object NavigateToTemple : HomeUiEffect
}
🪟 1) Control de Modales en MVI
Observa la propiedad showSeedModal: Boolean en el estado.
Una duda común en Compose es: "¿Uso remember { mutableStateOf(false) } dentro de la vista para abrir un diálogo?".
En la arquitectura MVI estricta, la respuesta es NO. La visibilidad del modal es parte del estado de la aplicación.
1. El usuario hace click en el escudo → Emite OnShieldClick.
2. El ViewModel pone showSeedModal = true.
3. La UI reacciona y pinta el modal.
Esto permite testear la lógica de apertura/cierre sin necesidad de un emulador.
🎮 2) Gamificación Sutil (Contadores)
El estado incluye introCount y obrasCount. Estos enteros simples alimentan la UI para mostrar el progreso diario.
Al definirlos en el UiState con valores por defecto inmutables, garantizamos que la pantalla siempre tenga algo que mostrar (Zero-Loading State), mejorando la percepción de velocidad de la app.
🧭 3) Objetos vs Clases para Eventos
Fíjate que usamos data object para eventos que no llevan parámetros (como OnShieldClick) y data class solo cuando necesitamos pasar información (como IDs).
Esto no es solo azúcar sintáctico; evita instanciar objetos innecesarios en memoria cada vez que el usuario hace clic en un botón simple, optimizando el rendimiento del recolector de basura (GC).
10. El Orquestador: HomeRoute
Al igual que en la pantalla de Comunidad, necesitamos un componente HomeRoute que actúe de pegamento entre el HomeViewModel y la UI visual.
La responsabilidad principal de este archivo es escuchar el deseo del usuario de "entrar al templo" (un efecto emitido por el ViewModel) y traducirlo en una acción de navegación real usando el NavHostController.
package co.holguin.kairos.feature.home
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import co.holguin.kairos.navigation.KairosRoutes
import co.holguin.kairos.ui.screens.HomeScreen
@Composable
fun HomeRoute(
navController: NavHostController,
vm: HomeViewModel = viewModel()
) {
LaunchedEffect(Unit) {
vm.effects.collect { eff ->
when (eff) {
HomeUiEffect.NavigateToTemple -> navController.navigate(KairosRoutes.TEMPLE)
}
}
}
HomeScreen(
state = vm.state,
onEvent = vm::onEvent
)
}
🚦 1) Navegación Desacoplada
Este es un punto clave de arquitectura limpia: El ViewModel NO debe conocer el navController.
Si pasáramos el navController al ViewModel, estaríamos rompiendo la regla de no tener dependencias de Android en la capa de presentación lógica. Además, dificultaría los Unit Tests.
En su lugar, el ViewModel emite un efecto abstracto NavigateToTemple, y es el HomeRoute (que sí pertenece a la capa de UI/Compose) quien decide qué hacer con ese efecto (navegar a la ruta KairosRoutes.TEMPLE).
🔄 2) El ciclo de LaunchedEffect
Usamos LaunchedEffect(Unit) para asegurar que la suscripción al canal de efectos ocurra una sola vez cuando este Composable entra en la composición.
Si el usuario navega al templo y luego presiona "Atrás", HomeRoute se volverá a componer, el LaunchedEffect se reactivará y volverá a escuchar nuevos eventos. El ciclo de vida de la suscripción está atado perfectamente al ciclo de vida de la pantalla.
11. Gestión de Estado y Lógica: HomeViewModel
El HomeViewModel es el responsable de reaccionar a cada clic del usuario. Aquí vemos la potencia de las Data Classes de Kotlin: para actualizar la UI, nunca modificamos variables individuales; creamos una copia nueva del estado completo.
Este enfoque garantiza que la UI siempre tenga una fuente de verdad consistente. Además, observa cómo manejamos la visibilidad del modal de seguridad ("Seed Modal") como una simple variable booleana en el estado, eliminando la necesidad de manejar DialogFragments complejos.
package co.holguin.kairos.feature.home
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
class HomeViewModel : ViewModel() {
var state by mutableStateOf(HomeUiState())
private set
private val _effects = Channel<HomeUiEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()
fun onEvent(event: HomeUiEvent) {
when (event) {
HomeUiEvent.OnShieldClick -> {
state = state.copy(showSeedModal = true)
}
HomeUiEvent.OnDismissSeed -> {
state = state.copy(showSeedModal = false)
}
HomeUiEvent.OnConfirmSeedSaved -> {
state = state.copy(showSeedModal = false)
}
HomeUiEvent.OnPrayerClick -> {
state = state.copy(introCount = state.introCount + 1)
}
HomeUiEvent.OnCharityClick -> {
state = state.copy(obrasCount = state.obrasCount + 1)
}
HomeUiEvent.OnOpenTempleClick -> {
viewModelScope.launch { _effects.send(HomeUiEffect.NavigateToTemple) }
}
}
}
}
🧬 1) El poder de .copy()
Kotlin genera automáticamente el método copy() para las data classes. Esto es vital para MVI.
state = state.copy(showSeedModal = true)
Esta línea dice: "Toma el estado actual, mantén todos los valores exactamente iguales (títulos, contadores, textos), PERO cambia showSeedModal a true". Esto dispara la recomposición en Compose de forma eficiente y segura.
⚖️ 2) Estado vs. Efecto (Diferencia clave)
Mira la diferencia entre OnPrayerClick y OnOpenTempleClick:
- Prayer Click: Actualiza
state. El contador sube a 5. Si rotas la pantalla, el contador sigue en 5. Esto es persistente. - Open Temple: Envía un evento al
Channel. No hay variable "isNavigating" en el estado. Esto es transitorio.
Separar estas dos naturalezas de datos es lo que hace que la app sea robusta y libre de bugs de navegación fantasma.
🧵 3) ViewModelScope y Corrutinas
Para enviar el efecto usamos:
viewModelScope.launch { _effects.send(...) }
Enviar datos a un canal es una operación de suspensión (suspend function). Necesitamos una corrutina. Usamos viewModelScope porque está atado a la vida del ViewModel: si el usuario cierra la pantalla mientras intentamos navegar, la corrutina se cancela automáticamente, evitando fugas de memoria.
12. Lectura Profunda: LibraryContract
La pantalla de Biblioteca ("Library") es donde el usuario pasa más tiempo leyendo. El contrato aquí debe ser eficiente para manejar textos largos (como pasajes bíblicos o reflexiones) y metadatos de interacción.
Fíjate cómo el estado mezcla contenido estático (título, cuerpo del texto) con flags dinámicos (favorito, guardado). Además, introducimos un efecto específico para interactuar con el mundo exterior: abrir enlaces web.
package co.holguin.kairos.feature.library
data class LibraryUiState(
val sourceTitle: String = "Evangelio según San Mateo",
val sourceDomain: String = "vatican.va",
val body: String =
"En aquel tiempo, al ver Jesús el gentío, subió al monte, se sentó y se acercaron sus discípulos; " +
"y, abriendo su boca, les enseñaba diciendo:\n\n" +
"«Bienaventurados los pobres en el espíritu, porque de ellos es el reino de los cielos...»",
val isFavorite: Boolean = false,
val isSaved: Boolean = false
)
sealed interface LibraryUiEvent {
data object ToggleFavorite : LibraryUiEvent
data object ToggleSaved : LibraryUiEvent
data object OpenSource : LibraryUiEvent
}
sealed interface LibraryUiEffect {
data class OpenUrl(val url: String) : LibraryUiEffect
}
🔄 1) La lógica del "Toggle"
Observa los eventos ToggleFavorite y ToggleSaved. Son objetos sin parámetros.
¿Por qué no pasar un booleano? (ej: SetFavorite(true))
Porque en MVI, la Vista no debe saber el "nuevo valor", solo debe comunicar la "intención de cambiar". Es responsabilidad del ViewModel saber que si estaba en true ahora pasa a false. Esto centraliza la lógica de negocio y evita inconsistencias.
🔗 2) Efectos con Payload (Datos)
A diferencia de los efectos anteriores que eran objetos simples (NavigateToTemple), aquí tenemos una data class:
data class OpenUrl(val url: String) : LibraryUiEffect
Esto permite que el ViewModel calcule dinámicamente la URL (quizás basándose en el idioma del usuario o en la configuración) y se la envíe a la UI lista para usar. La UI no tiene que construir la URL, solo ejecutarla.
📖 3) UI States Mixtos
Este UiState demuestra que no pasa nada por tener Strings largos en el estado.
Al ser inmutable, Compose es lo suficientemente inteligente para saber que si solo cambió isFavorite, no necesita volver a procesar el String body, simplemente repinta el icono del corazón. La inmutabilidad es la clave del rendimiento en pantallas de contenido denso.
13. Interactuando con el Sistema: LibraryRoute
A veces, nuestra app necesita salir de su propia burbuja y pedirle al sistema operativo que haga algo, como abrir una página web en el navegador.
Aquí es donde el patrón de Route brilla. El ViewModel no tiene acceso al Context (y no debería tenerlo para evitar fugas de memoria), y la UI visual no debería manejar Intents. El Route, al estar en el árbol de composición, tiene acceso seguro al LocalContext y actúa como el ejecutor de estas acciones del sistema.
package co.holguin.kairos.feature.library
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import co.holguin.kairos.ui.screens.LibraryScreen
import timber.log.Timber
@Composable
fun LibraryRoute(
navController: NavHostController,
vm: LibraryViewModel = viewModel()
) {
val ctx = LocalContext.current
LaunchedEffect(Unit) {
vm.effects.collect { eff ->
when (eff) {
is LibraryUiEffect.OpenUrl -> {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(eff.url))
ctx.startActivity(intent)
}.onFailure {
Timber.e(it, "No se pudo abrir URL: ${eff.url}")
}
}
}
}
}
LibraryScreen(
state = vm.state,
onEvent = vm::onEvent
)
}
📱 1) LocalContext.current
En Jetpack Compose, no tenemos métodos como getActivity() o getContext() disponibles directamente.
Usamos el CompositionLocal LocalContext.current para obtener una referencia al contexto de la Actividad actual. Es vital capturar esta variable fuera del LaunchedEffect o de las lambdas asíncronas para asegurar que siempre tengamos una referencia válida al momento de componer.
🛡️ 2) runCatching: Programación Defensiva
Lanzar un Intent externo siempre es un riesgo. ¿Qué pasa si el usuario no tiene ningún navegador instalado (raro, pero posible en dispositivos industriales) o si la URL está mal formada?
Normalmente, la app se cerraría inesperadamente (Crash). Al envolver la llamada en runCatching { ... }, capturamos cualquier excepción, evitamos el cierre forzoso y registramos el error silenciosamente con Timber. Esto es calidad de producción.
🏗️ 3) Arquitectura Limpia
Observa que el ViewModel solo emitió un String: OpenUrl("vatican.va"). No sabe qué es un Intent, ni un Context, ni una Activity.
Esto permite testear el ViewModel en una JVM simple sin necesidad de emulador Android (Robolectric o Instrumented Tests), ya que no hay dependencias del framework de Android en la lógica de negocio.
14. Lógica de Negocio: LibraryViewModel
El LibraryViewModel demuestra dos patrones fundamentales en el desarrollo de apps modernas:
- Estado Derivado (Toggling): Cómo invertir valores booleanos (Like/Save) basándose en el estado actual.
- Preparación de Datos: Cómo el ViewModel es responsable de construir la información completa (en este caso, una URL con protocolo HTTPS) antes de enviarla a la vista para su consumo.
package co.holguin.kairos.feature.library
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
class LibraryViewModel : ViewModel() {
var state by mutableStateOf(LibraryUiState())
private set
private val _effects = Channel<LibraryUiEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()
fun onEvent(event: LibraryUiEvent) {
when (event) {
LibraryUiEvent.ToggleFavorite -> {
state = state.copy(isFavorite = !state.isFavorite)
}
LibraryUiEvent.ToggleSaved -> {
state = state.copy(isSaved = !state.isSaved)
}
LibraryUiEvent.OpenSource -> {
val url = "https://${state.sourceDomain}"
viewModelScope.launch { _effects.send(LibraryUiEffect.OpenUrl(url)) }
}
}
}
}
🔀 1) Toggling Seguro
state = state.copy(isFavorite = !state.isFavorite)
Esta línea parece simple, pero es arquitectura pura. La UI envió ToggleFavorite (sin argumentos). El ViewModel mira su propio estado actual, lo invierte y emite el nuevo estado.
Si hubiéramos dejado que la UI enviara SetFavorite(true), podríamos tener condiciones de carrera si el usuario pulsa muy rápido. Al centralizar la lógica de inversión aquí, garantizamos consistencia.
🛠️ 2) Formateo de Datos (Business Logic)
Mira cómo construimos la URL:
val url = "https://${state.sourceDomain}"
El estado solo tiene el dominio ("vatican.va"), pero el navegador necesita el protocolo ("https://").
Es responsabilidad del ViewModel preparar los datos para que sean consumibles. La UI o el Route no deberían tener que concatenar strings; simplemente reciben una URL válida y la ejecutan.
💾 3) Persistencia (El siguiente paso)
En este tutorial MVP, el cambio de estado es en memoria. Si cierras la app, el "Favorito" se pierde.
En una app real, dentro del bloque viewModelScope.launch, llamaríamos a un repositorio:
repository.saveFavorite(id).
Gracias a la estructura MVI, agregar esa persistencia no requiere cambiar ni una sola línea de código en la UI.
15. Minimalismo Radical: TempleContract
El "Modo Templo" es una experiencia inmersiva diseñada para la contemplación. Por definición, debe tener distracciones cero.
Esto se refleja directamente en su arquitectura. El contrato es extremadamente simple: el estado solo contiene textos estáticos (citas inspiradoras) y la única interacción permitida es salir. Aquí, la complejidad no está en los datos, sino en la atmósfera visual que veremos más adelante.
package co.holguin.kairos.feature.temple
data class TempleUiState(
val title: String = "Silentium",
val quote: String = "“En el silencio es donde mejor se escucha la voz que importa.”",
val zoneActiveLabel: String = "ZONA SAGRADA ACTIVA"
)
sealed interface TempleUiEvent {
data object OnExit : TempleUiEvent
}
sealed interface TempleUiEffect {
data object PopBack : TempleUiEffect
}
🧘 1) Estado Ambiental
TempleUiState no tiene listas, ni booleanos de carga, ni errores.
Esto es intencional. En arquitectura de software, modelamos solo lo necesario. Si la pantalla es estática y meditativa, el estado debe ser igual de ligero. No agregamos complejidad innecesaria "por si acaso".
🔙 2) PopBack vs Navigate
Fíjate en el efecto PopBack. No estamos navegando a "Home". Estamos diciendo "sácame de aquí".
En la pila de navegación (Back Stack), esto significa destruir la pantalla actual y mostrar lo que había antes. Es crucial para mantener el estado de la Home intacto (por ejemplo, si tenías un modal abierto en Home, al hacer pop desde el Templo, el modal seguirá ahí).
🚪 3) Evento Único (Single Responsibility)
OnExit es el único evento. Esto define la UX desde el código: el usuario entra al modo Templo y se queda ahí. No hay botones de "Like", no hay "Compartir", no hay menú. El código obliga a la interfaz a ser restrictiva para fomentar el enfoque.
16. El Retorno: TempleRoute
El TempleRoute es el encargado de sacar al usuario del modo inmersivo.
A diferencia de los otros Routes donde usábamos navigate() para ir hacia adelante, aquí utilizamos popBackStack(). Esto es crucial para la experiencia de usuario: al salir del templo, queremos que el usuario regrese exactamente al punto donde estaba en la Home o la Biblioteca, sin perder su progreso ni recargar la pantalla anterior.
package co.holguin.kairos.feature.temple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import co.holguin.kairos.ui.screens.TempleModeScreen
@Composable
fun TempleRoute(
navController: NavHostController,
vm: TempleViewModel = viewModel()
) {
LaunchedEffect(Unit) {
vm.effects.collect { eff ->
when (eff) {
TempleUiEffect.PopBack -> navController.popBackStack()
}
}
}
TempleModeScreen(
state = vm.state,
onEvent = vm::onEvent
)
}
↩️ 1) popBackStack: La máquina del tiempo
navController.popBackStack() es la operación inversa a navigate().
En lugar de poner una nueva hoja de papel encima de la pila, quita la hoja actual y la tira a la basura. Esto revela la hoja que estaba debajo (la pantalla anterior). Es la forma correcta de manejar botones de "Atrás" o "Cerrar" en Android para no acumular memoria infinita.
⭕ 2) El ciclo completo MVI
Mira la vuelta que da la información para una acción tan simple como "Salir":
- Usuario pulsa botón "Regresar" en
TempleModeScreen. - La Screen emite
OnExit. - El ViewModel recibe el evento y emite el efecto
PopBack. - El Route recibe el efecto y llama a
popBackStack().
Parece mucho trabajo ("boilerplate"), pero garantiza que la lógica de navegación esté controlada 100% por el ViewModel. Si mañana quieres que al salir se muestre un diálogo de "¿Seguro?", solo cambias el ViewModel, no la UI.
📐 3) Consistencia Arquitectónica
Podríamos haber pasado el navController a la pantalla y llamar a popBackStack directamente en el onClick. Hubiera sido más rápido de escribir.
Pero al usar un Route incluso para esto, mantenemos el patrón consistente en toda la app. Todos los ViewModels funcionan igual, todas las Screens son "tontas" y todos los Routes manejan la navegación. La predictibilidad es clave en el mantenimiento a largo plazo.
17. Lógica Minimalista: TempleViewModel
A primera vista, este ViewModel parece innecesario. ¿Por qué crear una clase entera solo para manejar un evento de salida?
La respuesta es escalabilidad y consistencia. Este ViewModel actúa como un "pasamanos": recibe la intención del usuario y la convierte en un efecto de navegación. Aunque ahora no modifica el estado visual (porque el templo es estático), esta estructura nos permite en el futuro añadir lógica compleja (ej: "Registrar tiempo de meditación al salir") sin tocar ni una línea de la UI.
package co.holguin.kairos.feature.temple
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
class TempleViewModel : ViewModel() {
var state by mutableStateOf(TempleUiState())
private set
private val _effects = Channel<TempleUiEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()
fun onEvent(event: TempleUiEvent) {
when (event) {
TempleUiEvent.OnExit -> {
viewModelScope.launch { _effects.send(TempleUiEffect.PopBack) }
}
}
}
}
🗿 1) Estado Estático
Observa que nunca llamamos a state = state.copy(...).
Aunque el estado es técnicamente mutable (var), en la práctica funciona como una constante. MVI no te obliga a cambiar el estado en cada interacción. A veces, la UI es solo un decorado estático, y el ViewModel solo gestiona el flujo de control, no los datos.
📈 2) Preparado para el Futuro (Analytics)
Imagina que el cliente pide: "Queremos trackear cuánto tiempo pasa el usuario en el templo".
Gracias a que tenemos este ViewModel, solo tendríamos que agregar una línea en el bloque OnExit:
analytics.logEvent("temple_session_end", duration)
Si hubiéramos puesto la navegación directamente en el onClick de la UI, tendríamos que refactorizar la pantalla entera para añadir esa lógica.
✅ 3) Testing Trivial
Probar este ViewModel es lo más fácil del mundo:
1. Envía evento OnExit.
2. Aserción: Verifica que el canal effects emitió PopBack.
Esto nos da una cobertura de código del 100% para la lógica de navegación de esta feature.
18. Coreografía Visual: KairosNavHost
El KairosNavHost es el director de orquesta. Aquí es donde asociamos cada "URL" (ruta) con su pantalla correspondiente.
Un detalle crucial de diseño en Kairos es la ausencia de animaciones bruscas. No queremos que las pantallas "entren volando" desde los lados. Configurar transiciones globales de fadeIn y fadeOut ayuda a que la aplicación se sienta más como un libro digital que respira, y menos como una herramienta de productividad frenética.
package co.holguin.kairos.navigation
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import co.holguin.kairos.feature.community.CommunityRoute
import co.holguin.kairos.feature.home.HomeRoute
import co.holguin.kairos.feature.library.LibraryRoute
import co.holguin.kairos.feature.temple.TempleRoute
@Composable
fun KairosNavHost(
navController: NavHostController,
padding: PaddingValues,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = KairosRoutes.HOME,
modifier = modifier.padding(padding),
enterTransition = { fadeIn(tween(160)) },
exitTransition = { fadeOut(tween(160)) },
popEnterTransition = { fadeIn(tween(160)) },
popExitTransition = { fadeOut(tween(160)) }
) {
composable(KairosRoutes.HOME) { HomeRoute(navController = navController) }
composable(KairosRoutes.MAP) { CommunityRoute(navController = navController) }
composable(KairosRoutes.LIBRARY) { LibraryRoute(navController = navController) }
composable(KairosRoutes.TEMPLE) { TempleRoute(navController = navController) }
}
}
🌫️ 1) Transiciones Crossfade (160ms)
Por defecto, Navigation Compose usa animaciones de deslizamiento o empuje.
Aquí sobrescribimos enterTransition y exitTransition con un simple fadeIn/fadeOut.
El valor tween(160) es clave: 160 milisegundos es lo suficientemente rápido para sentirse fluido (casi instantáneo), pero lo suficientemente lento para evitar un cambio brusco ("flicker"). Es un ajuste de micro-interacción que define la personalidad de la app.
📐 2) Manejo del Padding del Scaffold
Observa el parámetro padding: PaddingValues.
El Scaffold (que contiene la barra de navegación inferior) vive en un nivel superior. Ese Scaffold calcula cuánto espacio ocupa la barra y nos lo pasa.
Es obligatorio aplicar este padding al NavHost (modifier.padding(padding)). Si no lo hiciéramos, el contenido de nuestras pantallas quedaría oculto detrás de la barra de navegación inferior.
🔗 3) Route Composition
Gracias a que encapsulamos la lógica en componentes Route (ej: HomeRoute), el NavHost queda muy limpio.
No hay instanciación de ViewModels aquí, ni lógica de argumentos complejos. El NavHost solo se preocupa de mapear rutas a componentes. Esto mantiene el archivo legible incluso si la app crece a 50 pantallas.
19. El Mapa de Direcciones: KairosRoutes
En el desarrollo de software, odiamos los "Magic Strings" (cadenas de texto sueltas por el código). Son propensas a errores tipográficos y difíciles de refactorizar.
KairosRoutes es un Singleton (Object) que centraliza todas las direcciones posibles dentro de la app. Si mañana decidimos cambiar la ruta interna de "library" a "lectio-divina", solo tenemos que cambiarlo en este archivo y el resto de la app se actualizará automáticamente.
package co.holguin.kairos.navigation
object KairosRoutes {
const val HOME = "home"
const val MAP = "map"
const val LIBRARY = "library"
const val TEMPLE = "temple"
}
📍 1) Rutas Planas vs. Profundas
Aunque técnicamente todas son strings, conceptualmente las distinguimos:
- Tabs (Home, Map, Library): Son destinos de primer nivel. El usuario espera ver la barra de navegación inferior al estar aquí.
- Deep Route (Temple): Es un destino que rompe la estructura habitual. Al navegar aquí, ocultamos la barra (como vimos en
MainActivity) para indicar que hemos entrado en un contexto diferente.
💎 2) const val: Eficiencia en Compilación
Usamos const val en lugar de val.
Esto indica que son constantes de tiempo de compilación. El compilador de Kotlin reemplazará cualquier referencia a KairosRoutes.HOME directamente por el string "home" en el bytecode final. Es una micro-optimización que hace el acceso a estas rutas instantáneo, sin necesidad de buscar en la memoria del objeto en tiempo de ejecución.
🔮 3) Escalabilidad (Argumentos)
Para este MVP, las rutas son simples. Pero si necesitáramos pasar un ID (ej: ir al detalle del templo #5), este archivo evolucionaría.
Podríamos agregar funciones utilitarias aquí mismo:
fun templeDetail(id: String) = "temple/$id"
Esto mantendría la lógica de construcción de URLs centralizada y segura.
20. El UI Kit: KairosComponents
Para mantener una consistencia visual perfecta y evitar código repetido, creamos nuestros propios componentes base. En lugar de usar Card o Button de Material 3 directamente en las pantallas, envolvemos estos elementos en componentes semánticos como KairosCard o KairosModal.
Esto nos permite aplicar globalmente nuestra identidad de diseño (bordes finos "hairline", sombras suaves, esquinas redondeadas específicas) sin ensuciar el código de las pantallas principales.
package co.holguin.kairos.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import co.holguin.kairos.ui.theme.KairosDimens
import co.holguin.kairos.ui.theme.KairosElevation
import co.holguin.kairos.ui.theme.KairosStroke
@Composable
fun KairosCard(
modifier: Modifier = Modifier,
contentPadding: androidx.compose.ui.unit.Dp = KairosDimens.lg,
header: (@Composable RowScope.() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = KairosElevation.card),
border = BorderStroke(KairosStroke.hairline, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.large
) {
Column(modifier = Modifier.padding(contentPadding)) {
if (header != null) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
content = header
)
Spacer(modifier = Modifier.size(KairosDimens.md))
}
content()
}
}
}
@Composable
fun KairosPill(
text: String,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
shape = CircleShape,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp,
border = BorderStroke(KairosStroke.hairline, MaterialTheme.colorScheme.outlineVariant)
) {
Text(
text = text.uppercase(),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
fun KairosIconCircleButton(
icon: ImageVector,
contentDescription: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interaction = remember { MutableInteractionSource() }
Surface(
modifier =
modifier
.size(40.dp)
.clip(CircleShape)
.clickable(
interactionSource = interaction,
indication = null,
onClick = onClick
),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(KairosStroke.hairline, MaterialTheme.colorScheme.outlineVariant),
tonalElevation = 0.dp
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
fun KairosModal(
title: String,
icon: ImageVector,
onDismiss: () -> Unit,
primaryCtaText: String,
onPrimaryCta: () -> Unit,
body: @Composable ColumnScope.() -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Card(
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.background),
elevation = CardDefaults.cardElevation(defaultElevation = 14.dp),
border = BorderStroke(KairosStroke.hairline, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.padding(KairosDimens.xl)) {
// Header
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.size(KairosDimens.sm))
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.weight(1f))
KairosIconCircleButton(
icon = Icons.Outlined.Close,
contentDescription = "Cerrar",
onClick = onDismiss
)
}
Spacer(modifier = Modifier.size(KairosDimens.lg))
// Body
body()
Spacer(modifier = Modifier.size(KairosDimens.lg))
Divider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.size(KairosDimens.lg))
// CTA
Surface(
modifier =
Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable { onPrimaryCta() },
color = MaterialTheme.colorScheme.onBackground
) {
Text(
text = primaryCtaText,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 14.dp),
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.background,
maxLines = 1,
textAlign = TextAlign.Center
)
}
}
}
}
}
🧩 1) Slot API: Flexibilidad Total
Mira la firma de KairosCard:
content: @Composable ColumnScope.() -> Unit
Este patrón se llama Slot API. En lugar de pasar un String fijo para el contenido, permitimos que el componente padre inyecte cualquier Composable dentro (imágenes, textos complejos, listas).
Al usar ColumnScope como receptor, permitimos que el contenido interno acceda a modificadores de columna (como align(CenterHorizontally)) sin necesidad de envolverlo manualmente en otra Columna. Es ergonomía pura para el desarrollador.
🌊 2) Eliminando el Ripple Effect
En KairosIconCircleButton, verás:
indication = null
Por defecto, Android muestra una onda expansiva (ripple) al tocar cualquier elemento interactivo. Para Kairos, que busca una estética de "papel digital" calmado y sólido, desactivamos este efecto visual. El botón responde inmediatamente (se activa el onClick), pero visualmente permanece quieto, reforzando la sensación de serenidad.
📐 3) Centralizando Tokens
No usamos números mágicos como 16.dp o colores directos Color.Red.
Usamos referencias a tokens: KairosDimens.lg, KairosStroke.hairline.
Esto facilita el mantenimiento: si mañana queremos que todos los bordes finos sean de 2dp en lugar de 1dp, cambiamos una sola línea en Tokens.kt y toda la app se actualiza instantáneamente.
21. Layout Personalizado: KairosScaffold
El componente Scaffold de Material 3 es excelente, pero a veces su slot bottomBar es demasiado rígido. En Kairos, queríamos que la navegación flotara sobre el contenido, como una cápsula suspendida, en lugar de estar pegada al borde inferior de la pantalla.
Para lograr esto, creamos KairosScaffold. Este componente envuelve el Scaffold estándar pero gestiona manualmente los WindowInsets (áreas seguras del sistema) y calcula el relleno (padding) exacto para que el último elemento de una lista no quede oculto detrás de nuestra barra flotante.
package co.holguin.kairos.ui.layout
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Book
import androidx.compose.material.icons.outlined.Place
import androidx.compose.material.icons.outlined.Spa
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import co.holguin.kairos.navigation.KairosRoutes
import co.holguin.kairos.ui.theme.KairosDimens
import co.holguin.kairos.ui.theme.KairosElevation
import co.holguin.kairos.ui.theme.KairosStroke
enum class KairosTab(val route: String) {
Home(KairosRoutes.HOME),
Map(KairosRoutes.MAP),
Library(KairosRoutes.LIBRARY)
}
@Composable
fun KairosScaffold(
selectedTab: KairosTab,
onTabSelected: (KairosTab) -> Unit,
modifier: Modifier = Modifier,
floatingBarBottomMargin: Dp = 24.dp,
showBottomBar: Boolean = true,
content: @Composable (PaddingValues) -> Unit
) {
val layoutDirection = LocalLayoutDirection.current
val navBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val barHeight = 64.dp
val contentBottomPadding =
if (showBottomBar) barHeight + floatingBarBottomMargin + navBottom
else navBottom
Scaffold(
modifier = modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background,
contentWindowInsets = WindowInsets.systemBars
) { inner ->
val contentPadding =
PaddingValues(
start = inner.calculateStartPadding(layoutDirection),
top = inner.calculateTopPadding(),
end = inner.calculateEndPadding(layoutDirection),
bottom = maxOf(inner.calculateBottomPadding(), contentBottomPadding)
)
Box(Modifier.fillMaxSize()) {
content(contentPadding)
if (showBottomBar) {
KairosFloatingBottomBar(
selected = selectedTab,
onSelected = onTabSelected,
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = floatingBarBottomMargin + navBottom)
.padding(horizontal = KairosDimens.lg)
)
}
}
}
}
@Composable
private fun KairosFloatingBottomBar(
selected: KairosTab,
onSelected: (KairosTab) -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier
.height(64.dp)
.fillMaxWidth(),
shape = CircleShape,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
shadowElevation = KairosElevation.floatingBar,
border = BorderStroke(KairosStroke.hairline, MaterialTheme.colorScheme.outlineVariant)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
BarItem(
icon = Icons.Outlined.Spa,
contentDescription = "Home",
selected = selected == KairosTab.Home,
onClick = { onSelected(KairosTab.Home) }
)
BarSeparator()
BarItem(
icon = Icons.Outlined.Place,
contentDescription = "Comunidad",
selected = selected == KairosTab.Map,
onClick = { onSelected(KairosTab.Map) }
)
BarSeparator()
BarItem(
icon = Icons.Outlined.Book,
contentDescription = "Biblioteca",
selected = selected == KairosTab.Library,
onClick = { onSelected(KairosTab.Library) }
)
}
}
}
@Composable
private fun BarSeparator() {
Box(
modifier = Modifier
.height(22.dp)
.width(1.dp)
.background(MaterialTheme.colorScheme.outlineVariant)
)
}
@Composable
private fun BarItem(
icon: ImageVector,
contentDescription: String?,
selected: Boolean,
onClick: () -> Unit
) {
val scale by animateFloatAsState(
targetValue = if (selected) 1.12f else 1.0f,
label = "scale"
)
val tint =
if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.70f)
val interaction = remember { MutableInteractionSource() }
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.clickable(
interactionSource = interaction,
indication = null,
onClick = onClick
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = tint,
modifier = Modifier.size(22.dp * scale)
)
}
}
Comentarios y valoraciones
No hay comentarios aún. ¡Sé el primero en opinar!