Files

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 — 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

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 inmutablesdata class con val
  • Pure helpers — Composables stateless. Transformaciones state → state puras fuera de Composables
  • State via mutableStateOf — Compose lo requiere para reactividad
  • Transformacionesstate = puraTransform(state, evento). NUNCA state.add(...)
  • Side effects bordes — solo en callbacks (onClick, onValueChange)
  • Tests JVM purosPureLogicTest.kt valida 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.

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