# Kotlin Compose patterns — @fn_compose Pautas autoritativas para apps Android Kotlin Compose del registry. Mirror de `cpp/PATTERNS.md` (C++ ImGui) y `frontend/functions/ui/` (web Mantine). ## 1. Dark-first `FnTheme` aplica dark por defecto. Mirror del web stack donde `FnMantineProvider` hardcodea `defaultColorScheme="dark"`. ```kotlin setContent { FnTheme { // darkMode = true por default Surface(Modifier.fillMaxSize()) { ... } } } // Override explicito: FnTheme(darkMode = false) { ... } // forzar light FnTheme(darkMode = isSystemInDarkTheme()) { } // seguir sistema ``` NUNCA `MaterialTheme {}` directo. SIEMPRE `FnTheme {}`. ## 2. Tokens — fuente unica Todo valor visual via `fn.compose.theme.FnTokens` o sus alias directos: ``` FnColors paleta Mantine 50→950 (gray, brand) + status (red, green, yellow, blue) + pure FnSpacing xs=8 sm=12 md=16 lg=24 xl=32 xxl=48 (Dp) FnRadius none=0 xs=2 sm=4 md=8 lg=12 xl=16 full=9999 (Dp) FnTypography sizes xs..xl + h1..h6 + weights normal/medium/semibold/bold + ready TextStyles FnShadows none/xs/sm/md/lg/xl elevation (Dp) ``` **Regla:** si haces `Color(0xFF...)` o `8.dp` literal en codigo app, ESTA MAL. Usar tokens. Excepcion permitida: literales en helpers privados que SOLO retornan tokens (ej. `categoryColor(c) -> FnBadgeColor`). ## 3. Componentes propios, no Material3 directo ``` Material3 raw ← solo dentro de @fn_compose ↓ @fn_compose ← apps consumen estos ↓ App-specific UI ← logica del dominio ``` Apps importan `fn.compose.ui.FnButton`, NUNCA `androidx.compose.material3.Button`. Primitivas Compose foundation OK en apps: `Box`, `Column`, `Row`, `Spacer`, `Modifier`, `LazyColumn`. Preferir `FnStack`/`FnGroup` para layouts agregados. ## 4. Catalogo de componentes (28) ### Theming (8) - `FnTheme` — provider - `FnTokens` — agregador - `FnColors` / `FnSpacing` / `FnRadius` / `FnTypography` / `FnShadows` ### Layout (4) - `FnStack` — Column con gap - `FnGroup` — Row con gap - `FnPaper` — Surface con tokens - `FnAppShell` — Scaffold ### Display (5) - `FnText` — body xs..xl - `FnTitle` — h1..h6 - `FnCard` — variants Default/Borderless/Ghost - `FnBadge` — pill 6 colores semanticos - `FnAvatar` — circle con initials, Sm/Md/Lg ### Input (5) - `FnButton` — variants Filled/Outlined/Secondary/Ghost/Destructive/Link - `FnTextInput` — OutlinedTextField + label/error - `FnSelect` — ExposedDropdownMenu - `FnSwitch` / `FnCheckbox` ### Navigation (1) - `FnTabs` — TabRow + ScrollableTabRow ### Feedback (4) - `FnAlert` — Info/Success/Warning/Error theme-aware - `FnLoader` — CircularProgress Sm/Md/Lg - `FnSkeleton` — animated shimmer - `FnDialog` — AlertDialog wrapper + destructive variant ### Data (3) - `FnDataTable` — Column-based table generico - `FnKpiCard` — label + value + delta + sparkline - `FnPageHeader` — title + subtitle + actions slot - `FnEmptyState` — icon + title + description + action ### Charts (3) - `FnLineChart` — Canvas-based, con area fill opcional - `FnBarChart` — vertical bars + labels - `FnSparkline` — mini inline chart (KPIs, tablas) ## 5. Variants estandar (mirror Mantine) | Componente | Variants | |---|---| | `FnButton` | Filled, Outlined, Secondary, Ghost, Destructive, Link | | `FnCard` | Default (border+shadow), Borderless (bg only), Ghost (transparent) | | `FnBadge` | Brand, Gray, Green, Red, Yellow, Blue | | `FnAlert` | Info, Success, Warning, Error (theme-aware en dark+light) | | `FnAvatar` | Sm (28dp), Md (40dp), Lg (56dp) | | `FnLoader` | Sm (16dp), Md (32dp), Lg (56dp) | | `FnText` | size Xs/Sm/Md/Lg/Xl | | `FnTitle` | order 1..6 | ## 6. Layout patterns ### App shell estandar ```kotlin FnAppShell(title = "Mi App") { padding -> FnStack( modifier = Modifier.padding(padding).fillMaxSize(), gap = FnSpacing.md, ) { FnPageHeader("Seccion", subtitle = "Descripcion") // content... } } ``` Body slot recibe `PaddingValues` → aplicar SIEMPRE para no solapar topbar. ### Lista de items ```kotlin LazyColumn(verticalArrangement = Arrangement.spacedBy(FnSpacing.sm)) { items(data) { item -> ItemRow(item) } } ``` ### Tabs navegacion entre categorias ```kotlin var tab by remember { mutableStateOf(0) } FnTabs(tabs = categorias, selectedIndex = tab, onTabSelected = { tab = it }, scrollable = true) when (tab) { 0 -> ScreenA(); 1 -> ScreenB() ... } ``` ### Dashboard grid KPIs ```kotlin FnGroup(gap = FnSpacing.sm) { FnKpiCard(label = "Revenue", value = "€42k", sparklineData = ..., modifier = Modifier.weight(1f)) FnKpiCard(label = "Churn", value = "3.1%", sparklineData = ..., modifier = Modifier.weight(1f)) } ``` ### Tabla ```kotlin FnDataTable( rows = items, modifier = Modifier.fillMaxWidth(), columns = listOf( FnTableColumn(header = "Nombre", weight = 2f, cell = { FnText(it.name) }), FnTableColumn(header = "Estado", weight = 1f, cell = { FnBadge(it.status, color = ...) }), ), ) ``` NO anidar `FnDataTable` dentro de `Modifier.verticalScroll()` con muchas rows — usa `LazyColumn` arriba o limita altura. FnDataTable internamente usa Column estatica. ## 7. Programacion funcional - **Datos inmutables** — `data class` con `val` - **Pure helpers** — Composables stateless. Transformaciones `state → state` puras fuera de Composables - **State via mutableStateOf** — Compose lo requiere para reactividad - **Transformaciones** — `state = puraTransform(state, evento)`. NUNCA `state.add(...)` - **Side effects bordes** — solo en callbacks (`onClick`, `onValueChange`) - **Tests JVM puros** — `PureLogicTest.kt` valida helpers sin Compose Ejemplo canonico: ```kotlin internal fun appendMyMessage(messages: List, text: String): List = if (text.isBlank()) messages else messages + Message("Yo", text, "ahora", true) @Composable fun ChatScreen() { var msgs by remember { mutableStateOf(MOCK_MESSAGES) } var draft by remember { mutableStateOf("") } ChatContent( messages = msgs, draft = draft, onDraftChange = { draft = it }, onSend = { msgs = appendMyMessage(msgs, draft) // pure draft = "" }, ) } ``` ## 8. Testing — 3 niveles | Nivel | Donde | Que valida | Cuando | |---|---|---|---| | `PureLogicTest` | `src/test/.../` | Funciones puras JVM | Cada PR | | `ExampleScreenshotTest` (Roborazzi) | `src/test/.../` | Snapshots Composables light+dark+edge | Cada PR | | `MainActivityTest` (instrumented) | `src/androidTest/.../` | Real device/emulator clicks | E2E gate | Pattern Roborazzi: ```kotlin @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(qualifiers = "w360dp-h800dp-xhdpi") class ScreenshotTest { @get:Rule val composeTestRule = createComposeRule() @Test fun snapshotLight() { composeTestRule.setContent { FnTheme(darkMode = false) { Surface { ScreenContent(...) } } } composeTestRule.onRoot().captureRoboImage("src/test/snapshots/images/screen_light.png") } } ``` Comando record goldens: `./gradlew :app:recordRoborazziDebug`. Verify: `:app:verifyRoborazziDebug`. Goldens (.png) se commitean. ## 9. testTag para tests instrumented Componentes interactivos llevan `Modifier.testTag("nombre_corto")`: ```kotlin Button(modifier = Modifier.testTag("btn_submit"), ...) ``` Test: ```kotlin composeTestRule.onNodeWithTag("btn_submit").performClick() ``` NUNCA `onNodeWithText("Submit")` — rompe al traducir strings o si texto duplicado. Texto duplicado en tree → `onAllNodesWithText("X")[0]` en lugar de `onNodeWithText`. Detectado real: tests fallan con `Expected at most 1 node but found 2` si dos componentes muestran mismo string. ## 10. Visibilidad vs existencia - `assertIsDisplayed()` — visible en viewport. Falla si below-the-fold. - `assertExists()` — solo presente en tree. Para items en scroll containers. Regla: items below viewport en scroll → `assertExists()`. ## 11. e2e_checks declarado en app.md Toda app nueva (via scaffolder) genera bloque: ```yaml e2e_checks: - id: unit # gradle :app:test - id: screenshot # gradle :app:verifyRoborazziDebug - id: build # gradle :app:assembleDebug - id: emu_start # arrancar AVD - id: instrumented # gradle :app:connectedAndroidTest - id: emu_stop # cleanup ``` `fn-analizador` consume esto en fase 4 del bucle reactivo. ## 12. Composite build local Apps importan `@fn_compose` via `includeBuild("../../kotlin/functions/ui")` en `settings.gradle.kts`. NO maven artifact. Cambios en library → apps recompilan auto. ## 13. Scaffolder canonico (OBLIGATORIO) Apps Kotlin nuevas SIEMPRE via: ```bash ./fn run init_kotlin_app [--project

] [--package com.x.y] ``` NUNCA crear MainActivity + build.gradle.kts + app.md a mano. Si scaffolder no cubre un caso, modifica el scaffolder. Plantilla = codigo, no decoracion. ## 14. Equivalencias cross-stack ``` Concepto Web Mantine C++ ImGui Android Compose ───────────── ─────────────── ────────────── ─────────────── Provider fn::run_app cfg FnTheme {} Default theme dark ThemeMode::FnDark darkMode=true Tokens theme.colors fn_tokens::colors FnColors / FnTokens Container fn_ui::Card FnPaper / FnCard Button variants default/outline/... n/a Filled/Outlined/... Card variants default/borderless/... n/a Default/Borderless/Ghost Layout col ImGui::BeginGroup FnStack Layout row SameLine FnGroup Heading PushFont(big) FnTitle order=N Body text <Text size="md"> Text(...) FnText size=Md Badge <Badge> colored span FnBadge Alert <Alert> message box FnAlert Loader <Loader> spinner widget FnLoader Skeleton <Skeleton> n/a FnSkeleton App scaffold <AppShell> dockspace + menu FnAppShell Tabs <Tabs> ImGui::BeginTabBar FnTabs Modal <Dialog> modal_dialog FnDialog Avatar <Avatar> n/a FnAvatar Select <Select> ImGui::Combo FnSelect Toggle <Switch> ImGui::Checkbox FnSwitch Table <DataTable> fn_ui::table FnDataTable Line chart <LineChart> fn_viz::line_plot FnLineChart Bar chart <BarChart> fn_viz::bar_chart FnBarChart Sparkline <Sparkline> n/a FnSparkline KPI card <KPICard> n/a FnKpiCard Empty state <EmptyState> n/a FnEmptyState Page header <PageHeader> n/a FnPageHeader ``` Cambios paleta brand en un sitio → propagan cross-stack manualmente. Tracker drift en backlog. ## 15. Gallery showcase App `apps/gallery_kt/` muestra los 28 componentes organizados por categoria con FnTabs. Sirve como: - Regression visual gate via Roborazzi snapshots - Onboarding rapido para devs nuevos - Documentacion ejecutable Rebuilds y screenshots: `./fn run run_kotlin_app_tests apps/gallery_kt`. ## 16. Anti-patterns | Anti-patron | Por que mal | Sustituir por | |---|---|---| | `Color(0xFFAABBCC)` en app code | Bypass tokens | `FnColors.gray700` | | `8.dp` literal en app code | Bypass spacing | `FnSpacing.xs` | | `MaterialTheme {}` en app | No usa tokens design system | `FnTheme {}` | | `androidx.compose.material3.Button` directo | Sin variants ni shape consistente | `FnButton` | | `var msgs = mutableListOf<X>()` | Mutable state mutable | `var msgs by remember { mutableStateOf(emptyList<X>()) }` | | `state.add(x)` | Mutacion in-place | `state = state + x` | | Helpers @Composable solo para colores | Side-effect implicito | Funcion pura que retorna `FnBadgeColor` enum | | `LazyColumn` dentro de `verticalScroll` | Infinity constraint crash | `Column` o limitar altura | | `onNodeWithText` para tests | Romper en traducciones / duplicados | `testTag` + `onNodeWithTag` | | Logica de negocio inline en MainActivity | No testeable | Extraer helpers puros + `internal` para tests | | Scaffolder a mano | Drift entre apps | `fn run init_kotlin_app` | ## 17. Reglas de incorporacion de componentes nuevos 1. Buscar primero en registry — `mcp__registry__fn_search` por nombre/desc/code 2. Mirar el equivalente Mantine (`frontend/functions/ui/`) para inspirar API 3. Crear via `fn-constructor` con prompt tight (no inline en apps) 4. `lang: kt`, `domain: ui`, `kind: component`, `framework: compose`, `error_type: error_go_core` 5. Theme-aware: usar `MaterialTheme.colorScheme.*` para colores semanticos 6. Tokens primero: `FnSpacing.*`, `FnRadius.*`, `FnTypography.*` antes que valores raw 7. Stateless preferido — caller mantiene state via `remember` 8. Variants via `enum class` — no booleanos sueltos 9. `Modifier.testTag` en cualquier elemento interactivo 10. Update gallery_kt con seccion nueva tras crearlo