Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
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".
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— providerFnTokens— agregadorFnColors/FnSpacing/FnRadius/FnTypography/FnShadows
Layout (4)
FnStack— Column con gapFnGroup— Row con gapFnPaper— Surface con tokensFnAppShell— Scaffold
Display (5)
FnText— body xs..xlFnTitle— h1..h6FnCard— variants Default/Borderless/GhostFnBadge— pill 6 colores semanticosFnAvatar— circle con initials, Sm/Md/Lg
Input (5)
FnButton— variants Filled/Outlined/Secondary/Ghost/Destructive/LinkFnTextInput— OutlinedTextField + label/errorFnSelect— ExposedDropdownMenuFnSwitch/FnCheckbox
Navigation (1)
FnTabs— TabRow + ScrollableTabRow
Feedback (4)
FnAlert— Info/Success/Warning/Error theme-awareFnLoader— CircularProgress Sm/Md/LgFnSkeleton— animated shimmerFnDialog— AlertDialog wrapper + destructive variant
Data (3)
FnDataTable— Column-based table genericoFnKpiCard— label + value + delta + sparklineFnPageHeader— title + subtitle + actions slotFnEmptyState— icon + title + description + action
Charts (3)
FnLineChart— Canvas-based, con area fill opcionalFnBarChart— vertical bars + labelsFnSparkline— 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
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
LazyColumn(verticalArrangement = Arrangement.spacedBy(FnSpacing.sm)) {
items(data) { item -> ItemRow(item) }
}
Tabs navegacion entre categorias
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
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
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 classconval - Pure helpers — Composables stateless. Transformaciones
state → statepuras fuera de Composables - State via mutableStateOf — Compose lo requiere para reactividad
- Transformaciones —
state = puraTransform(state, evento). NUNCAstate.add(...) - Side effects bordes — solo en callbacks (
onClick,onValueChange) - Tests JVM puros —
PureLogicTest.ktvalida helpers sin Compose
Ejemplo canonico:
internal fun appendMyMessage(messages: List<Message>, text: String): List<Message> =
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:
@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"):
Button(modifier = Modifier.testTag("btn_submit"), ...)
Test:
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:
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:
./fn run init_kotlin_app <name> [--project <p>] [--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 <MantineProvider> fn::run_app cfg FnTheme {}
Default theme dark ThemeMode::FnDark darkMode=true
Tokens theme.colors fn_tokens::colors FnColors / FnTokens
Container <Paper> fn_ui::Card FnPaper / FnCard
Button variants default/outline/... n/a Filled/Outlined/...
Card variants default/borderless/... n/a Default/Borderless/Ghost
Layout col <Stack> ImGui::BeginGroup FnStack
Layout row <Group> SameLine FnGroup
Heading <Title order={N}> 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
- Buscar primero en registry —
mcp__registry__fn_searchpor nombre/desc/code - Mirar el equivalente Mantine (
frontend/functions/ui/) para inspirar API - Crear via
fn-constructorcon prompt tight (no inline en apps) lang: kt,domain: ui,kind: component,framework: compose,error_type: error_go_core- Theme-aware: usar
MaterialTheme.colorScheme.*para colores semanticos - Tokens primero:
FnSpacing.*,FnRadius.*,FnTypography.*antes que valores raw - Stateless preferido — caller mantiene state via
remember - Variants via
enum class— no booleanos sueltos Modifier.testTagen cualquier elemento interactivo- Update gallery_kt con seccion nueva tras crearlo