cb6d9e61d1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
349 lines
13 KiB
Markdown
349 lines
13 KiB
Markdown
# 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<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:
|
|
```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 <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
|
|
|
|
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
|