Desarrollo móvil Intermedio / Avanzado

Jetpack Compose Pro: Creando una UI "Zen" con Canvas y MVVM (Caso Real)

Desarrollar 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 …

Publicado: 02/01/2026 Por: Juan Felipe Orozco Cortés Duración: 45 - 60 min Nivel: Intermedio / Avanzado
Contenido del tutorial

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.

🤖 Kotlin 🎨 Material 3 Custom 🚀 Jetpack Compose 🌊 Coroutines & Flow 🔐 Zero-Knowledge

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.

🏛️ Temple Mode

Un sistema inmersivo de pantalla completa con animaciones de partículas (estrellas) dibujadas en Canvas nativo.

🛡️ Privacidad Real

Implementación de un "Confesionario Digital" protegido por principios de Zero-Knowledge Encryption.

📚 Lectio UI

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), los UiEvents (acciones del usuario) y los UiEffects (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".

Nota Pro: Al usar 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).

En aplicaciones de alta privacidad, es vital configurar esto para evitar que un backup automático de Android exponga datos sensibles del usuario en la nube sin cifrado adicional.

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.
Este patrón es obligatorio para cualquier app profesional con Bottom Navigation en Jetpack Compose.

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.

Esta es una práctica estándar de seguridad: No dejar rastro de debug en la versión final.

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).

Este bloque es el puente entre la lógica reactiva (Kotlin Flows) y el mundo imperativo de la UI (mostrar un mensaje, cambiar de pantalla).
🧩 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:

  1. Estado Derivado (Toggling): Cómo invertir valores booleanos (Like/Save) basándose en el estado actual.
  2. 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":

  1. Usuario pulsa botón "Regresar" en TempleModeScreen.
  2. La Screen emite OnExit.
  3. El ViewModel recibe el evento y emite el efecto PopBack.
  4. 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.

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)
        )
    }
}

Estás viendo solo el 60% del contenido. Hazte Premium para acceder al tutorial completo.

Comunidad

Comentarios y valoraciones

No hay comentarios aún. ¡Sé el primero en opinar!