feat(kotlin-compose): design system + 33 components + gallery_kt + e2e android emulator + scaffolder fixes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user