feat(kotlin-compose): design system fn.compose:ui + toolbelt android Linux-first

Design system Compose (kotlin/functions/ui, modulo Gradle `fn.compose:ui`):
- FnTokens + FnTheme con la paleta heredada al hex de cpp/DESIGN_SYSTEM.md
  (Mantine v9 dark + indigo), identica a la web @fn_library y a las apps C++.
- 26 componentes Compose (Layout/Display/Inputs/Feedback/Data/Charts) +
  FnTheme + FnTokens registrados en el registry (28 entradas kind=component
  lang=kt domain=ui), descubribles via fn_search. Habilitan init_kotlin_app.

Recuperacion: el commit cb6d9e6 habia anadido `kotlin/functions/ui/` al
.gitignore, por eso el design system nunca se versiono y se perdio del working
tree. Des-ignorado; el .gitignore interno del modulo ya excluye
build/.gradle/local.properties. La gallery (apps/gallery_kt) se recupero del
sub-repo Gitea y sus 27 componentes se reconstruyeron con su MainActivity como
contrato exacto.

Toolbelt Android Linux-first (antes asumia WSL2 + Windows):
- adb_wsl 1.1.0, android_emulator_start 1.1.0, android_emulator_list 1.1.0:
  resuelven adb/emulator nativos del SDK ($ANDROID_HOME), .exe solo fallback WSL2.
- android_emulator_start: fix `timeout adb_run wait-for-device` (timeout no puede
  ejecutar una funcion del shell; ahora invoca el binario $ADB directamente).
- install_android_sdk 1.0.1: fix licencias bajo pipefail (SIGPIPE de `yes`) +
  trap EXIT con variable unbound.
- docs/capabilities/android.md regenerado Linux-first + seccion design system.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 23:43:59 +02:00
parent c65f1698ae
commit efc9911925
55 changed files with 3170 additions and 109 deletions
-1
View File
@@ -81,7 +81,6 @@ Thumbs.db
broken_paths.txt
imgui.ini
prompts/
kotlin/functions/ui/
# Module versioning auto-generated headers (written by `fn index`, issue 0097)
**/version_generated.h
+42 -44
View File
@@ -3,17 +3,17 @@ name: adb_wsl
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "source adb_wsl.sh [ADB=<path>] [ANDROID_SDK_WIN=<sdk_root>]"
description: "Wrapper sourceable para usar adb.exe Windows desde WSL2. Resuelve binario, convierte paths, espera boot del emulador."
tags: ["android", "adb", "wsl", "windows"]
signature: "source adb_wsl.sh [ADB=<path>] [ANDROID_HOME=<sdk_root>]"
description: "Wrapper sourceable para resolver e invocar adb. Linux-first: usa el adb nativo del Android SDK ($ANDROID_HOME) o del PATH; fallback a adb.exe solo si detecta WSL2. Expone adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot."
tags: ["android", "adb", "linux", "emulator", "wsl"]
params:
- name: ADB
desc: "Env var opcional. Path absoluto a adb.exe. Si no se fija, se construye desde ANDROID_SDK_WIN o el default /mnt/c/Users/lucas/AppData/Local/Android/Sdk."
- name: ANDROID_SDK_WIN
desc: "Env var opcional. Raiz del Android SDK montado en WSL. Default: /mnt/c/Users/lucas/AppData/Local/Android/Sdk."
output: "Source-able shell helpers: adb_run, adb_devices, adb_wsl_to_win, adb_wait_boot. Define ADB env var apuntando a Windows adb.exe via ANDROID_SDK_WIN."
desc: "Env var opcional. Path absoluto al binario adb (override explicito). Si no se fija, se resuelve Linux-first: $ANDROID_HOME/platform-tools/adb, luego adb del PATH, luego adb.exe si WSL2."
- name: ANDROID_HOME
desc: "Env var opcional. Raiz del Android SDK nativo. Si esta presente, se usa $ANDROID_HOME/platform-tools/adb. Tambien se acepta ANDROID_SDK_ROOT."
output: "Source-able shell helpers: adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot, adb_wsl_to_win. Resuelve y fija la env var ADB al binario adb disponible."
uses_functions: []
uses_types: []
returns: []
@@ -26,24 +26,33 @@ test_file_path: ""
file_path: "bash/functions/infra/adb_wsl.sh"
---
## Uso
## Cuando usarla
Sourcéala como capa base de cualquier script que hable con un device o emulador Android via adb. Es la dependencia comun de todo el toolbelt android del registry (`android_screenshot`, `android_input_*`, `android_logcat`, `android_app_*`, `android_push/pull`). En Linux nativo resuelve el adb del SDK automaticamente; no hace falta configurar nada si `ANDROID_HOME` esta exportado (o `adb` esta en el PATH).
## Ejemplo
```bash
# Sourcear (usa SDK default)
# Linux nativo: con el SDK instalado y ANDROID_HOME exportado, resuelve solo.
source ~/android-sdk/env.sh
source bash/functions/infra/adb_wsl.sh
adb_devices
# List of devices attached
# emulator-5554 device
# Sourcear con SDK custom
ANDROID_SDK_WIN=/mnt/d/Android/Sdk source bash/functions/infra/adb_wsl.sh
# Fijar binario adb explicito (override)
ADB=/opt/android/platform-tools/adb source bash/functions/infra/adb_wsl.sh
# Sourcear con binario fijo
ADB=/mnt/c/my/tools/adb.exe source bash/functions/infra/adb_wsl.sh
# Smoke test
bash bash/functions/infra/adb_wsl.sh --self-test
# Android Debug Bridge version 1.0.41
```
## Funciones expuestas
### `adb_run "<args...>"`
Ejecuta `$ADB` con los argumentos dados. Retorna el exit code de `adb.exe`.
Ejecuta `$ADB` con los argumentos dados. Retorna el exit code de adb.
```bash
adb_run shell ls /sdcard/
@@ -54,45 +63,34 @@ adb_run install app.apk
Alias de `adb_run devices`. Lista dispositivos/emuladores conectados.
```bash
adb_devices
# List of devices attached
# emulator-5554 device
```
### `adb_pick_serial [--serial <S>] [...]`
### `adb_wsl_to_win <path_wsl>`
Convierte un path WSL a formato Windows con `wslpath -w`. Si `wslpath` no está disponible retorna el path sin convertir.
Resuelve el serial a usar (multi-device). Lee `--serial X` de los args y setea los globals `ADB_PICK_SERIAL` y `ADB_PICK_REST`. Si no se pasa, autoselecciona el primer device/emulador conectado.
```bash
win_path=$(adb_wsl_to_win /home/lucas/proyecto/app.apk)
# C:\Users\lucas\AppData\Local\... (o la ruta Windows equivalente)
adb_run install "$win_path"
adb_pick_serial "$@" || { echo "no device" >&2; exit 3; }
serial="$ADB_PICK_SERIAL"; set -- "${ADB_PICK_REST[@]}"
```
### `adb_s <serial> <args...>`
Atajo de `adb_run -s <serial> <args...>` para multi-device.
### `adb_wait_boot [timeout_s]`
Espera a que el emulador/dispositivo complete el boot (`sys.boot_completed = 1`). Útil tras lanzar un AVD en CI.
Espera a que el emulador/dispositivo complete el boot (`sys.boot_completed = 1`). Polling cada 3s. Retorna `0` si bootó, `1` si timeout (default 120s).
```bash
adb_wait_boot # timeout 120s
adb_wait_boot 60 # timeout 60s
```
### `adb_wsl_to_win <path_wsl>`
Retorna `0` si el boot se completó, `1` si expiró el timeout.
Legacy WSL: convierte path WSL→Windows con `wslpath -w`. En Linux nativo (sin `wslpath`) devuelve el path tal cual.
## Smoke test
## Gotchas
```bash
bash bash/functions/infra/adb_wsl.sh --self-test
# OK
```
- **Linux-first.** El default ya NO es Windows. Resolucion: `$ADB``$ANDROID_HOME/platform-tools/adb``adb` del PATH → (solo si `/proc/version` indica WSL2) `adb.exe`. En un PC Linux con el SDK instalado funciona sin configurar nada.
- **Necesita el SDK o adb en PATH.** Si no encuentra adb aborta con mensaje a stderr. Instala con `fn run install_android_sdk_bash_infra` y exporta `ANDROID_HOME` (o `source ~/android-sdk/env.sh`).
- **`ADB` se resuelve una sola vez al sourcing.** Cambiar el SDK despues requiere re-sourcear.
- **Sourcéala con bash, no zsh.** Los consumidores usan `${BASH_SOURCE[0]}` para localizar este archivo; ejecutarlos con `bash <file>` (no `zsh`/`source` desde zsh) resuelve el path correctamente.
## Notas
## Capability growth log
- El script es **source-able**: define funciones en el shell actual, no crea subshell.
- `ADB` se resuelve una sola vez al sourcing. Si el binario no existe en disco, la carga falla con mensaje en stderr y `return 1` / `exit 1`.
- `adb_wait_boot` hace polling cada 3 segundos. Ajustar `interval` si el emulador es especialmente lento.
- En WSL2 `wslpath` siempre está disponible; el fallback existe para entornos Linux puros que accidentalmente sourceen el archivo.
- Si el emulador requiere `-s <serial>`, pasar el flag directamente a `adb_run`: `adb_run -s emulator-5554 shell ...`.
---
- v1.1.0 (2026-06-03) — Linux-first: la resolucion de adb ahora prioriza el adb nativo del SDK (`$ANDROID_HOME/platform-tools/adb`) y del PATH; el adb.exe de Windows queda como fallback legacy solo bajo WSL2. Se elimina el default hardcodeado `/mnt/c/Users/lucas/...`. Todo el toolbelt android (~20 funciones) pasa a funcionar en Linux nativo sin preexportar `ADB`.
+25 -10
View File
@@ -1,20 +1,35 @@
#!/usr/bin/env bash
# adb_wsl — Wrapper sourceable para usar adb.exe Windows desde WSL2.
# adb_wsl — Wrapper sourceable para resolver e invocar adb.
# Linux-first: usa el adb nativo del Android SDK o del PATH. Conserva un
# fallback a adb.exe SOLO cuando se detecta WSL2 (legacy). El nombre del
# archivo se mantiene por compatibilidad con sus consumidores del registry.
# Uso: source bash/functions/infra/adb_wsl.sh
# Smoke test: bash bash/functions/infra/adb_wsl.sh --self-test
# ---------------------------------------------------------------------------
# Resolver ADB
# Resolver ADB (Linux-first; fallback WSL legacy)
# ---------------------------------------------------------------------------
# El caller puede fijar ADB antes de sourcing para apuntar a otro binario.
# Prioridad de resolucion:
# 1. $ADB preexportada por el caller (override explicito).
# 2. adb nativo del Android SDK ($ANDROID_HOME / $ANDROID_SDK_ROOT).
# 3. adb del PATH.
# 4. (legacy) adb.exe de Windows, solo si corremos dentro de WSL2.
if [[ -z "${ADB:-}" ]]; then
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
ADB="${_sdk_root}/platform-tools/adb.exe"
unset _sdk_root
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/platform-tools/adb" ]]; then
ADB="$_sdk/platform-tools/adb"
elif command -v adb &>/dev/null; then
ADB="$(command -v adb)"
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
_sdk_win="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}"
ADB="${_sdk_win}/platform-tools/adb.exe"
unset _sdk_win
fi
unset _sdk
fi
if [[ ! -f "$ADB" ]]; then
echo "adb_wsl: ADB no encontrado en '$ADB'. Fija ADB= o ANDROID_SDK_WIN= antes de sourcear." >&2
if [[ -z "${ADB:-}" ]] || ! command -v "$ADB" &>/dev/null; then
echo "adb_wsl: adb no encontrado. Instala el SDK (fn run install_android_sdk_bash_infra), exporta ANDROID_HOME, o fija ADB= antes de sourcear." >&2
# Solo abortamos si el script se ejecuta directamente; si se sourcea,
# permitimos continuar para que el caller maneje el error.
return 1 2>/dev/null || exit 1
@@ -22,8 +37,8 @@ fi
# ---------------------------------------------------------------------------
# adb_run "<args...>"
# Ejecuta el ADB Windows con los argumentos dados.
# Retorna el exit code de adb.exe.
# Ejecuta adb (el binario resuelto en $ADB) con los argumentos dados.
# Retorna el exit code de adb.
# ---------------------------------------------------------------------------
adb_run() {
"$ADB" "$@"
+20 -14
View File
@@ -3,11 +3,11 @@ name: android_emulator_list
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "android_emulator_list([--json])"
description: "Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2."
tags: [android, emulator, wsl]
description: "Lista los AVDs disponibles. Linux-first: usa el emulator nativo del Android SDK ($ANDROID_HOME); fallback a emulator.exe solo bajo WSL2."
tags: [android, emulator, linux, avd, wsl]
uses_functions: []
uses_types: []
returns: []
@@ -17,35 +17,41 @@ imports: []
params:
- name: "--json"
desc: "Optional flag, outputs JSON array instead of newline-separated names"
output: "Lista de AVDs disponibles en el SDK Windows. Una por linea, o JSON array con --json."
output: "Lista de AVDs disponibles en el SDK. Una por linea, o JSON array con --json."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/android_emulator_list.sh"
notes: "Lee env var EMULATOR o ANDROID_SDK_WIN. Default Windows path: /mnt/c/Users/lucas/AppData/Local/Android/Sdk/emulator/emulator.exe. Exit 0 si lista (incluso vacia). Exit 1 solo si el binario no existe o no es ejecutable."
notes: "Resuelve el binario emulator Linux-first ($ANDROID_HOME/emulator/emulator -> emulator del PATH -> emulator.exe si WSL2). Override con EMULATOR=. Exit 0 si lista (incluso vacia). Exit 1 solo si el binario no existe."
---
## Ejemplo
```bash
source ~/android-sdk/env.sh # exporta ANDROID_HOME
# Listar AVDs (una por linea)
android_emulator_list
# Pixel_API34
# Listar AVDs en formato JSON
android_emulator_list --json
# ["Pixel_7_API_34","Pixel_4_API_30"]
# ["Pixel_API34"]
# Sobreescribir ruta del emulador
EMULATOR="/custom/path/emulator.exe" android_emulator_list
# Sobreescribir SDK base
ANDROID_SDK_WIN="/mnt/d/Android/Sdk" android_emulator_list
EMULATOR="/opt/android/emulator/emulator" android_emulator_list
```
## Notas
## Cuando usarla
El script es ejecutable directamente (`chmod +x`) o invocable con `bash android_emulator_list.sh`.
Antes de arrancar un emulador, para validar que el AVD existe (lo hace `deploy_capacitor_to_emulator` y `run_kotlin_app_tests` internamente). Útil también para listar qué AVDs hay creados en la máquina.
`emulator.exe -list-avds` imprime warnings a stderr que se descartan con `2>/dev/null`. La captura con `mapfile` filtra ademas lineas vacias para producir una lista limpia.
## Gotchas
La variable `EMULATOR` tiene prioridad sobre `ANDROID_SDK_WIN`. Si ninguna esta definida se usa el path Windows por defecto de Lucas.
- **Linux-first.** El default ya no es Windows. Resuelve `$ANDROID_HOME/emulator/emulator`, luego `emulator` del PATH, y solo bajo WSL2 cae a `emulator.exe`.
- `emulator -list-avds` imprime warnings a stderr que se descartan con `2>/dev/null`. La captura con `mapfile` filtra líneas vacías.
- Override del binario con `EMULATOR=`; override del SDK con `ANDROID_HOME=`.
## Capability growth log
- v1.1.0 (2026-06-03) — Linux-first: resuelve el emulator nativo del SDK (`$ANDROID_HOME`) y del PATH antes que `emulator.exe`; se elimina el default hardcodeado `/mnt/c/Users/lucas/...`.
+16 -5
View File
@@ -1,12 +1,23 @@
#!/usr/bin/env bash
# android_emulator_list — Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2.
# android_emulator_list — Lista los AVDs disponibles. Linux-first: usa el
# emulator nativo del Android SDK; fallback a emulator.exe solo bajo WSL2.
set -euo pipefail
# Resolve emulator binary
EMULATOR="${EMULATOR:-${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}/emulator/emulator.exe}"
# Resolve emulator binary (Linux-first; WSL fallback)
if [[ -z "${EMULATOR:-}" ]]; then
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/emulator/emulator" ]]; then
EMULATOR="$_sdk/emulator/emulator"
elif command -v emulator &>/dev/null; then
EMULATOR="$(command -v emulator)"
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
EMULATOR="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/emulator/emulator.exe"
fi
unset _sdk
fi
if [[ ! -x "$EMULATOR" ]]; then
echo "error: emulator binary not found or not executable: $EMULATOR" >&2
if [[ -z "${EMULATOR:-}" ]] || ! command -v "$EMULATOR" &>/dev/null; then
echo "error: emulator no encontrado. Instala el SDK (fn run install_android_sdk_bash_infra) + el paquete 'emulator', exporta ANDROID_HOME, o fija EMULATOR=." >&2
exit 1
fi
+23 -13
View File
@@ -3,14 +3,14 @@ name: android_emulator_start
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "android_emulator_start(avd_name: string, timeout_s: int) -> string"
description: "Arranca un AVD en background y espera a que termine de bootear. Idempotente: si ya hay emulador corriendo no lanza otro."
tags: [android, emulator, wsl]
description: "Arranca un AVD Android en background y espera a que termine de bootear. Linux-first: resuelve el emulator/adb nativos del SDK; fallback a binarios .exe solo bajo WSL2. Idempotente: si ya hay un emulador corriendo, imprime 'already running' y su serial sin lanzar otro."
tags: [android, emulator, linux, avd, wsl]
params:
- name: avd_name
desc: "Nombre del AVD a arrancar (visible con android_emulator_list o `emulator.exe -list-avds`)"
desc: "Nombre del AVD a arrancar (visible con android_emulator_list o `emulator -list-avds`)"
- name: timeout_s
desc: "Timeout total en segundos para esperar el boot completo. Opcional, default 180"
output: "Serial del device emulado (ej. emulator-5554) en stdout. Exit 0 = boot completo, exit 1 = timeout o emulador murio."
@@ -29,21 +29,31 @@ file_path: "bash/functions/infra/android_emulator_start.sh"
## Ejemplo
```bash
source ~/android-sdk/env.sh # exporta ANDROID_HOME -> resuelve emulator/adb nativos
source bash/functions/infra/android_emulator_start.sh
# Arrancar AVD con timeout por defecto (180s)
serial=$(android_emulator_start "Pixel_6_API_34")
serial=$(android_emulator_start "Pixel_API34")
echo "Emulador listo: $serial" # emulator-5554
# Con timeout personalizado
serial=$(android_emulator_start "Pixel_6_API_34" 300)
serial=$(android_emulator_start "Pixel_API34" 300)
```
## Notas
Para ver la ventana del emulador en un escritorio Linux, exporta `DISPLAY` (y `XAUTHORITY`) antes de invocar.
- Sourcea `adb_wsl.sh` del mismo directorio si existe (provee `ADB`, `adb_run`, `adb_wait_boot`). Si no, usa implementacion inline.
- Resuelve `EMULATOR` y `ADB` desde `ANDROID_SDK_WIN` (default `/mnt/c/Users/lucas/AppData/Local/Android/Sdk`) o desde las variables de entorno `EMULATOR=` / `ADB=` si ya están fijadas.
- Idempotente: si `adb devices` ya muestra un `emulator-*`, imprime "already running" + el serial y sale con exit 0 sin lanzar un segundo proceso.
- Log del emulador en `/tmp/emulator_<avd>.log`. PID en `/tmp/emulator_<avd>.pid`.
- El timeout total se reparte: primera mitad para `adb wait-for-device`, segunda mitad para esperar `sys.boot_completed=1`.
- Diseñado para WSL2 con Android SDK instalado en Windows. En Linux nativo basta cambiar las rutas de los binarios via `EMULATOR=` y `ADB=`.
## Cuando usarla
Cuando un script necesita un emulador booteado antes de instalar un APK o correr tests instrumentados (`gradle_instrumented_test`, `run_kotlin_app_tests`). Es idempotente, así que se puede llamar al principio de cualquier pipeline sin comprobar antes si ya hay uno arriba.
## Gotchas
- **Linux-first.** Resuelve `EMULATOR`/`ADB` desde `$ANDROID_HOME/{emulator/emulator, platform-tools/adb}` o del PATH; `emulator.exe`/`adb.exe` solo como fallback bajo WSL2. Override manual con `EMULATOR=`/`ADB=`.
- **Necesita `DISPLAY` para ventana.** Sin un servidor X accesible el emulador puede fallar al abrir ventana. Para headless/CI añade `-no-window` (editar la función o lanzar el emulador aparte).
- **Aceleración KVM.** Requiere acceso a `/dev/kvm` (grupo `kvm` o ACL). Sin ella el boot es lentísimo o falla.
- Log del emulador en `/tmp/emulator_<avd>.log`, PID en `/tmp/emulator_<avd>.pid`.
- El timeout total se reparte: primera mitad para `adb wait-for-device`, segunda para `sys.boot_completed=1`.
## Capability growth log
- v1.1.0 (2026-06-03) — Linux-first: resuelve emulator/adb nativos del SDK (`$ANDROID_HOME`) antes que los `.exe` de Windows (ahora solo fallback WSL2); se elimina el default hardcodeado `/mnt/c/Users/lucas/...`. fix: `timeout <n> adb_run wait-for-device` fallaba siempre porque `timeout` no puede ejecutar la función shell `adb_run`; ahora invoca el binario `"$ADB"` directamente.
+29 -14
View File
@@ -11,11 +11,17 @@ if [[ -f "$_ADB_WSL_SH" ]]; then
# shellcheck source=adb_wsl.sh
source "$_ADB_WSL_SH"
else
# Fallback inline: resolver ADB
# Fallback inline: resolver ADB (Linux-first; WSL fallback)
if [[ -z "${ADB:-}" ]]; then
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
ADB="${_sdk_root}/platform-tools/adb.exe"
unset _sdk_root
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/platform-tools/adb" ]]; then
ADB="$_sdk/platform-tools/adb"
elif command -v adb &>/dev/null; then
ADB="$(command -v adb)"
else
ADB="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/platform-tools/adb.exe"
fi
unset _sdk
fi
adb_run() { "$ADB" "$@"; }
adb_wait_boot() {
@@ -33,12 +39,18 @@ else
fi
# ---------------------------------------------------------------------------
# Resolver EMULATOR
# Resolver EMULATOR (Linux-first; WSL fallback)
# ---------------------------------------------------------------------------
if [[ -z "${EMULATOR:-}" ]]; then
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
EMULATOR="${_sdk_root}/emulator/emulator.exe"
unset _sdk_root
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/emulator/emulator" ]]; then
EMULATOR="$_sdk/emulator/emulator"
elif command -v emulator &>/dev/null; then
EMULATOR="$(command -v emulator)"
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
EMULATOR="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/emulator/emulator.exe"
fi
unset _sdk
fi
# ---------------------------------------------------------------------------
@@ -49,12 +61,12 @@ android_emulator_start() {
local timeout_s="${2:-180}"
# Validaciones de entorno
if [[ ! -f "$EMULATOR" ]]; then
echo "android_emulator_start: emulator.exe no encontrado en '$EMULATOR'. Fija EMULATOR= o ANDROID_SDK_WIN=." >&2
if [[ -z "${EMULATOR:-}" ]] || ! command -v "$EMULATOR" &>/dev/null; then
echo "android_emulator_start: emulator no encontrado. Instala el SDK + paquete 'emulator', exporta ANDROID_HOME, o fija EMULATOR=." >&2
return 1
fi
if [[ ! -f "$ADB" ]]; then
echo "android_emulator_start: adb.exe no encontrado en '$ADB'. Fija ADB= o ANDROID_SDK_WIN=." >&2
if [[ -z "${ADB:-}" ]] || ! command -v "$ADB" &>/dev/null; then
echo "android_emulator_start: adb no encontrado. Instala platform-tools, exporta ANDROID_HOME, o fija ADB=." >&2
return 1
fi
@@ -74,9 +86,12 @@ android_emulator_start() {
local emu_pid=$!
echo "$emu_pid" > "$pid_file"
# Esperar a que el dispositivo aparezca en adb
# Esperar a que el dispositivo aparezca en adb.
# Usamos el binario "$ADB" directamente (no la funcion adb_run): `timeout`
# ejecuta un comando externo y no puede ver funciones del shell, asi que
# `timeout ... adb_run` fallaba siempre con "command not found".
local wait_timeout=$(( timeout_s / 2 ))
if ! timeout "$wait_timeout" adb_run wait-for-device 2>/dev/null; then
if ! timeout "$wait_timeout" "$ADB" wait-for-device 2>/dev/null; then
echo "android_emulator_start: timeout esperando que el dispositivo aparezca en adb (${wait_timeout}s)." >&2
return 1
fi
+16 -1
View File
@@ -3,7 +3,7 @@ name: install_android_sdk
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.0.1"
purity: impure
signature: "install_android_sdk() -> void"
description: "Descarga e instala Android SDK command-line tools y JDK 17 localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk). Idempotente: detecta instalacion existente y sale sin hacer nada. Genera env.sh con JAVA_HOME, ANDROID_HOME y PATH listos para hacer source."
@@ -50,6 +50,17 @@ ANDROID_SDK_DIR=/opt/android source install_android_sdk.sh
source ~/android-sdk/env.sh
```
## Cuando usarla
Cuando necesites un Android SDK funcional en una maquina Linux sin permisos de root: CI, contenedores, o un PC de desarrollo donde quieras un SDK aislado en `$HOME`. Instala la base minima para compilar (cmdline-tools + JDK 17 + platform-tools + API 34 + build-tools). Hazle `source` para tener `sdkmanager`/`avdmanager`/`adb` en el PATH antes de invocar `gradle_run`, `gradle_assemble_debug` o `capacitor_build_apk`.
## Gotchas
- **No instala `emulator` ni system images.** Solo la base de compilacion. Para correr un AVD: tras hacer `source env.sh`, instala `emulator` y una imagen (`sdkmanager "emulator" "system-images;android-34;google_apis;x86_64"`) y crea el AVD con `avdmanager create avd`.
- **Aceleracion KVM:** el emulador necesita acceso a `/dev/kvm`. Verifica con `[ -w /dev/kvm ]`; si no, anade tu usuario al grupo `kvm` (`sudo usermod -aG kvm $USER` + re-login) o concede ACL.
- **URL de cmdline-tools clavada** a la build 11076708 (2024). Si Google la retira, actualizar `tools_url` en el `.sh`.
- **Idempotente:** re-ejecutar no reinstala; detecta `sdkmanager` existente y sale en 0.
## Notas
Requiere `curl` y `unzip` (disponibles en la mayoria de distros Linux). No requiere root ni sudo.
@@ -61,3 +72,7 @@ La reorganizacion del zip es necesaria porque Google distribuye cmdline-tools co
El archivo `env.sh` generado en `$ANDROID_SDK_DIR/env.sh` contiene las variables de entorno necesarias (`JAVA_HOME`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `PATH`) y puede hacerse source desde `.bashrc`, `.zshrc` o desde scripts de CI.
Paquetes instalados: `platform-tools` (adb, fastboot), `platforms;android-34` (API 34), `build-tools;34.0.0`.
## Capability growth log
- v1.0.1 (2026-06-03) — fix: `yes | sdkmanager --licenses` daba falso negativo bajo `pipefail` (SIGPIPE de `yes`, exit 141) abortando una instalacion exitosa; ahora se desactiva `pipefail` solo en ese pipe. fix: el trap `EXIT` referenciaba `$tmp_dir` (variable `local`) fuera del scope de la funcion → "unbound variable" con `set -u`; ahora es global con expansion defensiva.
+13 -3
View File
@@ -5,11 +5,14 @@ set -euo pipefail
install_android_sdk() {
local sdk_dir="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
local tmp_dir
# tmp_dir es global a proposito: el trap EXIT se dispara al terminar el
# script (fuera del scope de la funcion), donde una variable `local` ya no
# existiria y `set -u` la marcaria como unbound. La expansion defensiva
# ${tmp_dir:-} evita el fallo aunque el trap corra antes de la asignacion.
tmp_dir="$(mktemp -d)"
# Limpia temporales al salir
trap 'rm -rf "$tmp_dir"' EXIT
trap 'rm -rf "${tmp_dir:-}"' EXIT
# 1. Verifica si ya está instalado
if [[ -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
@@ -103,11 +106,18 @@ install_android_sdk() {
export PATH="$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:$PATH"
# 4. Acepta licencias e instala paquetes necesarios
# `yes` recibe SIGPIPE (exit 141) cuando sdkmanager termina de leer y cierra
# el pipe; bajo `set -o pipefail` eso convierte un exito real en falso
# negativo. Desactivamos pipefail solo aqui para que el exit del pipeline
# refleje el de sdkmanager (ultimo comando), no el SIGPIPE de `yes`.
echo "Aceptando licencias de Android SDK..."
if ! yes | "$sdkmanager" --licenses; then
set +o pipefail
if ! yes | "$sdkmanager" --licenses >/dev/null 2>&1; then
set -o pipefail
echo "ERROR: fallo al aceptar licencias de Android SDK" >&2
return 1
fi
set -o pipefail
echo "Instalando platform-tools, platforms;android-34, build-tools;34.0.0..."
if ! "$sdkmanager" "platform-tools" "platforms;android-34" "build-tools;34.0.0"; then
+8 -4
View File
@@ -1,12 +1,14 @@
# Capability: android
Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon Windows), AVD emulator management (list/start/stop/wait, geo-fix), APK lifecycle (`android_apk_install`, `android_app_clear`, `android_app_launch`, `android_uninstall`), Capacitor build pipelines (`capacitor_build_apk`, `deploy_capacitor_to_emulator`), logcat streaming. WSL2 -> Windows adb daemon, no requiere Android Studio.
Toolbelt Android **Linux-first** (con fallback WSL2 legacy). Cubre: ADB (`adb_wsl` resuelve el adb nativo del SDK), AVD emulator management (list/start/stop/wait, geo-fix), APK lifecycle (`android_apk_install`, `android_app_clear`, `android_app_launch`, `android_uninstall`), Capacitor build pipelines (`capacitor_build_apk`, `deploy_capacitor_to_emulator`), build Gradle nativo (`gradle_*`, `init_kotlin_app`, `run_kotlin_app_tests`), logcat streaming. Usa el SDK nativo en `~/android-sdk` (via `install_android_sdk`); el adb/emulator de Windows solo se usa como fallback cuando se detecta WSL2.
Design system Compose: las apps Kotlin nativas (`init_kotlin_app`) heredan `FnTheme` + `FnTokens` del módulo `kotlin/functions/ui` (`fn.compose:ui`), con la paleta exacta de Mantine v9 dark + indigo (misma que web `@fn_library` y C++ `fn_tokens`).
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `adb_wsl_bash_infra` | `source adb_wsl.sh [ADB=<path>] [ANDROID_SDK_WIN=<sdk_root>]` | Wrapper sourceable para usar adb.exe Windows desde WSL2. Resuelve binario, convierte paths, espera boot del emulador. |
| `adb_wsl_bash_infra` | `source adb_wsl.sh [ADB=<path>] [ANDROID_HOME=<sdk_root>]` | Wrapper sourceable para resolver e invocar adb. Linux-first: usa el adb nativo del Android SDK ($ANDROID_HOME) o del PATH; fallback a adb.exe solo si detecta WSL2. Expone adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot. |
| `android_apk_install_bash_infra` | `android_apk_install([--serial S], apk_path: string, package_name?: string, activity_name?: string) -> void` | Instala APK en device/emulador via adb y opcionalmente lanza la app. Multi-emulator via --serial. |
| `android_app_clear_bash_infra` | `android_app_clear([--serial <S>], package: string) -> void` | Wipe app data + cache via pm clear. App keeps installed but factory-state. Multi-emulator via --serial. |
| `android_app_info_bash_infra` | `android_app_info([--serial <S>], package, [--json]) -> stdout` | Inspect installed app: version, target SDK, activities via dumpsys package. |
@@ -16,8 +18,8 @@ Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon
| `android_emu_battery_bash_infra` | `android_emu_battery([--serial <S>], level: int, [--charging <true\|false>]) -> void` | Simulate battery state on emulator (level + charging). Emulator-only. |
| `android_emu_geo_fix_bash_infra` | `android_emu_geo_fix([--serial <S>], longitude: string, latitude: string, [altitude: string]) -> void` | Fake GPS location on Android emulator via emu geo fix. Emulator-only (not physical devices). |
| `android_emu_rotate_bash_infra` | `android_emu_rotate([--serial <S>] [portrait\|landscape\|0\|90\|180\|270])` | Rotate emulator screen. Empty=toggle, or fixed orientation. Locks autorotate. |
| `android_emulator_list_bash_infra` | `android_emulator_list([--json])` | Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2. |
| `android_emulator_start_bash_infra` | `android_emulator_start(avd_name: string, timeout_s: int) -> string` | Arranca un AVD en background y espera a que termine de bootear. Idempotente: si ya hay emulador corriendo no lanza otro. |
| `android_emulator_list_bash_infra` | `android_emulator_list([--json])` | Lista los AVDs disponibles. Linux-first: usa el emulator nativo del Android SDK ($ANDROID_HOME); fallback a emulator.exe solo bajo WSL2. |
| `android_emulator_start_bash_infra` | `android_emulator_start(avd_name: string, timeout_s: int) -> string` | Arranca un AVD Android en background y espera a que termine de bootear. Linux-first: resuelve el emulator/adb nativos del SDK; fallback a binarios .exe solo bajo WSL2. Idempotente: si ya hay un emulador corriendo, imprime 'already running' y su serial sin lanzar otro. |
| `android_emulator_stop_bash_infra` | `android_emulator_stop(serial?: string) -> void` | Para uno o todos los emuladores Android via adb emu kill. Si serial esta vacio, detecta todos los emulator-* activos y los para. Idempotente: exit 0 aunque no haya nada que matar. |
| `android_input_keyevent_bash_infra` | `android_input_keyevent([--serial <S>] key: string)` | Send key event via adb shell input keyevent. Accepts aliases (BACK, HOME, POWER, ENTER, MENU, RECENT_APPS, VOLUME_UP, VOLUME_DOWN), raw numeric codes, or explicit KEYCODE_* names. |
| `android_input_swipe_bash_infra` | `android_input_swipe([--serial <S>], x1: int, y1: int, x2: int, y2: int, [duration_ms: int])` | Send swipe gesture between two points with duration. |
@@ -31,6 +33,8 @@ Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon
| `android_shell_bash_infra` | `android_shell([--serial <S>], cmd ...args)` | Execute arbitrary shell command on Android device. Multi-emulator via --serial. |
| `capacitor_build_apk_bash_pipelines` | `capacitor_build_apk(web_app_dir: string, [app_id: string], [app_name: string]) -> void` | Pipeline que convierte una web app en un APK de Android usando Capacitor. Valida el entorno (ANDROID_HOME, Java 17+), construye el bundle web si no existe dist/, inicializa Capacitor si no está configurado, añade la plataforma Android, sincroniza y compila el APK con Gradle. El APK final queda en el directorio raíz de la web app. |
| `deploy_capacitor_to_emulator_bash_pipelines` | `deploy_capacitor_to_emulator(app_dir: string, avd_name?: string, package_name?: string) -> void` | Pipeline end-to-end: build Capacitor APK + arranca AVD + instala + opcionalmente lanza la app. Valida que el AVD existe, construye el APK con capacitor_build_apk, arranca el emulador de forma idempotente, instala el APK y lanza la app si se da package_name. Imprime comando logcat sugerido al final. |
| `fn_theme_kt_ui` | `@Composable fun FnTheme(darkMode: Boolean = true, content: @Composable () -> Unit)` | Provider raiz del design system Compose del registry (@fn_compose). Envuelve MaterialTheme con un ColorScheme derivado de FnColors (Mantine v9 dark + indigo). Dark por defecto, mirror de FnMantineProvider (web) y fn::run_app ThemeMode::FnDark (C++). Toda app del registry envuelve su contenido en FnTheme. |
| `fn_tokens_kt_ui` | `object FnTokens { colors; spacing; radius; typography; shadows }` | Design tokens del design system Compose del registry (@fn_compose). Paleta heredada exacta (mismos hex) de cpp/DESIGN_SYSTEM.md / Mantine v9 dark + indigo: FnColors, FnSpacing (Dp), FnRadius (Dp), FnTypography (sp + weights), FnShadows (Dp). Fuente unica de valores visuales para apps Android del registry. |
| `gradle_assemble_debug_bash_infra` | `gradle_assemble_debug(project_dir: string, module: string) -> string` | Build APK debug de un modulo Android via gradlew assembleDebug. |
| `gradle_clean_bash_infra` | `gradle_clean(project_dir: string) -> int` | Limpia build artifacts de un proyecto Android (gradle clean + rm .gradle + rm build). |
| `gradle_instrumented_test_bash_infra` | `gradle_instrumented_test(project_dir: string, module: string) -> int` | Corre instrumented tests Compose en emulador/device Android conectado. |
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
local.properties
*.iml
.idea/
.cxx/
+43
View File
@@ -0,0 +1,43 @@
plugins {
id("com.android.library") version "8.4.0"
id("org.jetbrains.kotlin.android") version "1.9.22"
}
// group:name must match the `fn.compose:ui` module notation that apps substitute.
group = "fn.compose"
version = "0.1.0"
android {
namespace = "fn.compose.ui"
compileSdk = 34
defaultConfig {
minSdk = 24
consumerProguardFiles("consumer-rules.pro")
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// `api` so consuming apps inherit Compose transitively through fn.compose:ui.
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
api("androidx.compose.ui:ui")
api("androidx.compose.ui:ui-graphics")
api("androidx.compose.ui:ui-text")
api("androidx.compose.foundation:foundation")
api("androidx.compose.material3:material3")
api("androidx.compose.ui:ui-tooling-preview")
}
+1
View File
@@ -0,0 +1 @@
# No consumer ProGuard rules required yet for the fn.compose:ui design system.
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+21
View File
@@ -0,0 +1,21 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
// Single-project Android library build. The root project IS the `fn.compose:ui`
// library. Apps consume it via composite build:
// includeBuild("../../kotlin/functions/ui") {
// dependencySubstitution { substitute(module("fn.compose:ui")).using(project(":")) }
// }
rootProject.name = "ui"
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,79 @@
package fn.compose.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
/**
* Material 3 [androidx.compose.material3.ColorScheme] derived from [FnColors].
*
* This is the registry's canonical dark identity (Mantine v9 dark + indigo).
* Equivalent to `FnMantineProvider` (web) and `fn::run_app` with
* `ThemeMode::FnDark` (C++).
*/
private val FnDarkColorScheme = darkColorScheme(
primary = FnColors.primary,
onPrimary = FnColors.white,
primaryContainer = FnColors.primaryActive,
onPrimaryContainer = FnColors.white,
secondary = FnColors.primaryLight,
onSecondary = FnColors.white,
background = FnColors.bg,
onBackground = FnColors.text,
surface = FnColors.surface,
onSurface = FnColors.text,
surfaceVariant = FnColors.surfaceHover,
onSurfaceVariant = FnColors.textMuted,
error = FnColors.error,
onError = FnColors.white,
outline = FnColors.border,
outlineVariant = FnColors.borderStrong,
)
private val FnLightColorScheme = lightColorScheme(
primary = FnColors.primary,
onPrimary = FnColors.white,
primaryContainer = FnColors.primaryLight,
onPrimaryContainer = FnColors.white,
secondary = FnColors.primaryActive,
onSecondary = FnColors.white,
background = FnColors.lightBg,
onBackground = FnColors.lightText,
surface = FnColors.lightSurface,
onSurface = FnColors.lightText,
surfaceVariant = FnColors.lightSurface,
onSurfaceVariant = FnColors.lightTextMuted,
error = FnColors.error,
onError = FnColors.white,
outline = FnColors.lightBorder,
)
/**
* Root theme provider for every registry Android app. Dark by default, mirroring
* the web frontend's `defaultColorScheme="dark"`.
*
* Usage:
* ```
* setContent {
* FnTheme {
* Surface(Modifier.fillMaxSize()) { /* ... */ }
* }
* }
* ```
*
* Override the scheme with `FnTheme(darkMode = false) { ... }` or follow the
* system with `FnTheme(darkMode = isSystemInDarkTheme()) { ... }`.
*
* Apps must NOT call `MaterialTheme {}` directly always go through `FnTheme`.
*/
@Composable
fun FnTheme(
darkMode: Boolean = true,
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkMode) FnDarkColorScheme else FnLightColorScheme,
content = content,
)
}
@@ -0,0 +1,59 @@
---
name: fn_theme
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnTheme(darkMode: Boolean = true, content: @Composable () -> Unit)"
description: "Provider raiz del design system Compose del registry (@fn_compose). Envuelve MaterialTheme con un ColorScheme derivado de FnColors (Mantine v9 dark + indigo). Dark por defecto, mirror de FnMantineProvider (web) y fn::run_app ThemeMode::FnDark (C++). Toda app del registry envuelve su contenido en FnTheme."
tags: [compose, android, ui, theme, provider, material3, design-system]
props: []
emits: []
uses_functions: [fn_tokens_kt_ui]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Aplica el ColorScheme del registry (dark o light) al arbol Composable hijo via MaterialTheme. No retorna valor; emite UI."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/theme/FnTheme.kt"
---
## Ejemplo
```kotlin
import fn.compose.theme.FnTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
FnTheme { // darkMode = true por defecto
Surface(modifier = Modifier.fillMaxSize()) {
Text("Mi app")
}
}
}
}
}
// Forzar light, o seguir el sistema:
FnTheme(darkMode = false) { /* ... */ }
FnTheme(darkMode = isSystemInDarkTheme()) { /* ... */ }
```
## Cuando usarla
Una vez por app, en el `setContent {}` de la `MainActivity` (o en cualquier `@Preview`/test de Roborazzi), envolviendo todo el árbol de UI. Es el equivalente Compose de `FnMantineProvider`. Sin `FnTheme`, los componentes caen al Material3 stock (morado) y pierden la identidad del registry.
## Gotchas
- **NUNCA `MaterialTheme {}` directo en una app.** Siempre `FnTheme {}`. `MaterialTheme` stock no aplica los tokens del registry.
- **Dark-first.** El default es `darkMode = true`, igual que el frontend web. Pasar `darkMode = false` solo para capturas light o si la app lo requiere explícitamente.
- **Composite build requerido.** Las apps consumen `FnTheme` via `includeBuild("../../kotlin/functions/ui")` con substitución `fn.compose:ui`. No es un artifact Maven publicado; cambios en el módulo recompilan las apps automáticamente.
## Capability growth log
- v0.1.0 (2026-06-03) — versión inicial. Provider MaterialTheme dark/light con ColorScheme derivado de FnColors (paleta heredada exacta de C++/Mantine). Desbloquea el scaffolder `init_kotlin_app`.
@@ -0,0 +1,143 @@
package fn.compose.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
/**
* Design tokens for the registry's Android Compose apps.
*
* The color palette is inherited *exactly* (same hex literals) from the C++
* design system (`cpp/DESIGN_SYSTEM.md` section 4.1), which in turn mirrors
* Mantine v9 dark + indigo used by the web frontend (`@fn_library`). A button
* rendered here must look identical to the same button on web and on the C++
* ImGui apps. Do not invent new colors — extend this object and keep it aligned
* with the canonical Mantine values.
*/
object FnColors {
// --- Indigo brand scale (Mantine indigo) ---
val indigo4 = Color(0xFF748FFC)
val indigo5 = Color(0xFF5C7CFA)
val indigo6 = Color(0xFF4C6EF5)
val indigo7 = Color(0xFF4263EB)
// --- Dark gray scale (Mantine dark) ---
val dark0 = Color(0xFFC1C2C5)
val dark1 = Color(0xFFA6A7AB)
val dark2 = Color(0xFF909296)
val dark3 = Color(0xFF5C5F66)
val dark4 = Color(0xFF373A40)
val dark5 = Color(0xFF2C2E33)
val dark6 = Color(0xFF25262B)
val dark7 = Color(0xFF1A1B1E)
val dark8 = Color(0xFF141517)
val dark9 = Color(0xFF101113)
// --- Status colors (Mantine *.6) ---
val green6 = Color(0xFF40C057)
val yellow6 = Color(0xFFFAB005)
val red6 = Color(0xFFFA5252)
val blue6 = Color(0xFF228BE6)
// --- Pure ---
val white = Color(0xFFFFFFFF)
val black = Color(0xFF000000)
// --- Semantic aliases (match fn_tokens::colors:: in C++) ---
val primary = indigo6 // #4C6EF5
val primaryHover = indigo5 // #5C7CFA
val primaryLight = indigo4 // #748FFC
val primaryActive = indigo7 // #4263EB
val success = green6 // #40C057
val warning = yellow6 // #FAB005
val error = red6 // #FA5252
val info = blue6 // #228BE6
val bg = dark7 // #1A1B1E
val surface = dark6 // #25262B
val surfaceHover = dark5 // #2C2E33
val surfaceActive = dark4 // #373A40
val border = dark4 // #373A40
val borderStrong = dark3 // #5C5F66
val text = dark0 // #C1C2C5
val textMuted = dark2 // #909296
val textDim = dark3 // #5C5F66
// --- Light scheme companions (for FnTheme(darkMode = false)) ---
val lightBg = Color(0xFFFFFFFF)
val lightSurface = Color(0xFFF8F9FA) // gray.0
val lightBorder = Color(0xFFDEE2E6) // gray.3
val lightText = Color(0xFF212529) // gray.9
val lightTextMuted = Color(0xFF868E96) // gray.6
}
/**
* Spacing scale in [Dp]. Slightly denser than CSS Mantine, aligned to the
* value set documented in `kotlin/PATTERNS.md` section 2.
*/
object FnSpacing {
val xs: Dp = 8.dp
val sm: Dp = 12.dp
val md: Dp = 16.dp
val lg: Dp = 24.dp
val xl: Dp = 32.dp
val xxl: Dp = 48.dp
}
/** Corner radius scale in [Dp]. Identical to the C++ `fn_tokens::radius`. */
object FnRadius {
val none: Dp = 0.dp
val xs: Dp = 2.dp
val sm: Dp = 4.dp
val md: Dp = 8.dp // Mantine defaultRadius
val lg: Dp = 12.dp
val xl: Dp = 16.dp
val full: Dp = 9999.dp
}
/** Font sizes (sp) and weights mirroring the web/C++ typography scale. */
object FnTypography {
val xs: TextUnit = 11.sp
val sm: TextUnit = 13.sp
val md: TextUnit = 15.sp
val lg: TextUnit = 18.sp
val xl: TextUnit = 22.sp
// Heading sizes h1..h6
val h1: TextUnit = 32.sp
val h2: TextUnit = 26.sp
val h3: TextUnit = 22.sp
val h4: TextUnit = 18.sp
val h5: TextUnit = 15.sp
val h6: TextUnit = 13.sp
val normal: FontWeight = FontWeight.Normal
val medium: FontWeight = FontWeight.Medium
val semibold: FontWeight = FontWeight.SemiBold
val bold: FontWeight = FontWeight.Bold
}
/** Elevation tokens in [Dp]. No direct C++ analogue (ImGui is flat). */
object FnShadows {
val none: Dp = 0.dp
val xs: Dp = 1.dp
val sm: Dp = 2.dp
val md: Dp = 4.dp
val lg: Dp = 8.dp
val xl: Dp = 16.dp
}
/** Aggregator so a single import (`FnTokens`) exposes every token group. */
object FnTokens {
val colors = FnColors
val spacing = FnSpacing
val radius = FnRadius
val typography = FnTypography
val shadows = FnShadows
}
@@ -0,0 +1,48 @@
---
name: fn_tokens
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: pure
signature: "object FnTokens { colors; spacing; radius; typography; shadows }"
description: "Design tokens del design system Compose del registry (@fn_compose). Paleta heredada exacta (mismos hex) de cpp/DESIGN_SYSTEM.md / Mantine v9 dark + indigo: FnColors, FnSpacing (Dp), FnRadius (Dp), FnTypography (sp + weights), FnShadows (Dp). Fuente unica de valores visuales para apps Android del registry."
tags: [compose, android, ui, tokens, theme, design-system, mantine]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Objetos Kotlin de solo lectura: FnColors (Color), FnSpacing/FnRadius/FnShadows (Dp), FnTypography (TextUnit + FontWeight), y el agregador FnTokens."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/theme/FnTokens.kt"
---
## Ejemplo
```kotlin
import fn.compose.theme.FnColors
import fn.compose.theme.FnSpacing
import fn.compose.theme.FnRadius
Box(
modifier = Modifier
.background(FnColors.surface, RoundedCornerShape(FnRadius.md))
.padding(FnSpacing.md)
) {
Text("Hola", color = FnColors.primary) // #4C6EF5 — mismo indigo que web y C++
}
```
## Cuando usarla
Siempre que escribas un Composable en una app del registry y necesites un color, espaciado, radio, tamaño de fuente o elevación. NUNCA pongas `Color(0xFF...)` o `8.dp` literal en código de app — usa el token. Garantiza que la app se vea idéntica a su equivalente web (Mantine) y C++ (ImGui), porque los tres comparten los mismos valores.
## Gotchas
- **Paleta heredada, no inventada.** Los hex coinciden 1:1 con `cpp/DESIGN_SYSTEM.md §4.1` (Mantine v9 dark + indigo). Si cambias un color aquí, debe cambiarse también en web y C++ para no romper la coherencia cross-platform.
- **Spacing más denso que CSS.** `FnSpacing` usa la escala de `kotlin/PATTERNS.md` (8/12/16/24/32/48 dp), no los px de CSS Mantine. Es intencional (densidad de pantalla móvil).
- **Es `object`, no `@Composable`.** Se consume directo (`FnColors.primary`), no via `MaterialTheme.colorScheme`. Para colores semánticos theme-aware en componentes propios, prefiere `MaterialTheme.colorScheme.*` (lo aplica `fn_theme_kt_ui`).
@@ -0,0 +1,219 @@
package fn.compose.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fn.compose.theme.FnColors
// ---------------------------------------------------------------------------
// Data class
// ---------------------------------------------------------------------------
/** A single bar in [FnBarChart]. */
data class FnBarItem(val label: String, val value: Float)
// ---------------------------------------------------------------------------
// FnLineChart
// ---------------------------------------------------------------------------
/**
* Canvas-based line chart. Draws a polyline connecting [data] points
* normalised to [min, max], plus a semi-transparent fill below the line.
*
* Handles empty lists and single-point lists without crashing.
*/
@Composable
fun FnLineChart(
data: List<Float>,
modifier: Modifier = Modifier,
) {
val primaryColor = FnColors.primary
val fillColor = FnColors.primary.copy(alpha = 0.15f)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.then(modifier),
) {
if (data.size < 2) return@Canvas
val minVal = data.min()
val maxVal = data.max()
val range = (maxVal - minVal).takeIf { it > 0f } ?: 1f
val strokePx = 2.dp.toPx()
val points = buildOffsets(data, minVal, range, size.width, size.height)
// --- fill path ---
val fillPath = Path().apply {
moveTo(points.first().x, size.height)
points.forEach { lineTo(it.x, it.y) }
lineTo(points.last().x, size.height)
close()
}
drawPath(fillPath, color = fillColor, style = Fill)
// --- polyline ---
drawPolyline(points, primaryColor, strokePx)
}
}
// ---------------------------------------------------------------------------
// FnBarChart
// ---------------------------------------------------------------------------
/**
* Canvas-based vertical bar chart. Bars are drawn proportional to the
* maximum value in [data]. Labels are rendered below each bar using
* [Text] (no FnText dependency to keep Charts.kt standalone).
*
* Handles empty lists without crashing.
*/
@Composable
fun FnBarChart(
data: List<FnBarItem>,
modifier: Modifier = Modifier,
) {
if (data.isEmpty()) return
val primaryColor = FnColors.primary
val labelColor = FnColors.textMuted
val maxVal = data.maxOf { it.value }.takeIf { it > 0f } ?: 1f
val barGapDp: Dp = 4.dp
Column(modifier = modifier) {
// --- bar canvas ---
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(160.dp),
) {
val count = data.size
val totalGapPx = barGapDp.toPx() * (count - 1)
val barWidth = (size.width - totalGapPx) / count
data.forEachIndexed { index, item ->
val barHeight = (item.value / maxVal) * size.height
val left = index * (barWidth + barGapDp.toPx())
val top = size.height - barHeight
drawRect(
color = primaryColor,
topLeft = Offset(left, top),
size = androidx.compose.ui.geometry.Size(barWidth, barHeight),
)
}
}
// --- labels row ---
Row(modifier = Modifier.fillMaxWidth()) {
data.forEach { item ->
Text(
text = item.label,
modifier = Modifier.weight(1f),
color = labelColor,
fontSize = 10.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
maxLines = 1,
)
}
}
}
}
// ---------------------------------------------------------------------------
// FnSparkline
// ---------------------------------------------------------------------------
/**
* Compact mini line chart — no axes, no fill, no labels.
* Ideal for embedding in KPI cards or table cells.
*
* Handles empty lists and single-point lists without crashing.
*/
@Composable
fun FnSparkline(
data: List<Float>,
modifier: Modifier = Modifier,
) {
val primaryColor = FnColors.primary
Canvas(
modifier = Modifier
.width(80.dp)
.height(24.dp)
.then(modifier),
) {
if (data.size < 2) return@Canvas
val minVal = data.min()
val maxVal = data.max()
val range = (maxVal - minVal).takeIf { it > 0f } ?: 1f
val strokePx = 1.5.dp.toPx()
val points = buildOffsets(data, minVal, range, size.width, size.height)
drawPolyline(points, primaryColor, strokePx)
}
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/**
* Converts a list of raw values into [Offset] positions scaled to the
* given [canvasWidth] x [canvasHeight].
*/
private fun buildOffsets(
data: List<Float>,
minVal: Float,
range: Float,
canvasWidth: Float,
canvasHeight: Float,
): List<Offset> {
val count = data.size
return data.mapIndexed { index, value ->
val x = if (count == 1) 0f else index / (count - 1).toFloat() * canvasWidth
val y = canvasHeight - ((value - minVal) / range) * canvasHeight
Offset(x, y)
}
}
/**
* Draws connected line segments between consecutive [points] using the
* given [color] and [strokeWidthPx].
*/
private fun DrawScope.drawPolyline(
points: List<Offset>,
color: Color,
strokeWidthPx: Float,
) {
for (i in 0 until points.lastIndex) {
drawLine(
color = color,
start = points[i],
end = points[i + 1],
strokeWidth = strokeWidthPx,
cap = StrokeCap.Round,
)
}
}
@@ -0,0 +1,252 @@
package fn.compose.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import fn.compose.theme.FnColors
import fn.compose.theme.FnRadius
import fn.compose.theme.FnSpacing
import fn.compose.theme.FnTypography
/**
* Page-level header with title, optional subtitle and trailing action slot.
*
* Layout: SpaceBetween Row with CenterVertically alignment.
* Left side: Column with title + optional subtitle.
* Right side: optional actions lambda.
*/
@Composable
fun FnPageHeader(
title: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
actions: (@Composable () -> Unit)? = null,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
fontSize = FnTypography.lg,
fontWeight = FnTypography.bold,
color = MaterialTheme.colorScheme.onSurface,
)
if (subtitle != null) {
Text(
text = subtitle,
fontSize = FnTypography.sm,
fontWeight = FnTypography.normal,
color = FnColors.textMuted,
)
}
}
if (actions != null) {
actions()
}
}
}
/**
* KPI metric card: label, large value, optional delta badge and optional
* inline sparkline drawn with Canvas (no external dependency).
*
* The [modifier] is forwarded to the outermost Surface so callers can pass
* `Modifier.weight(1f)` inside a Row (as the gallery does).
*/
@Composable
fun FnKpiCard(
label: String,
value: String,
modifier: Modifier = Modifier,
delta: String? = null,
deltaPositive: Boolean = true,
sparklineData: List<Float>? = null,
) {
Surface(
modifier = modifier
.border(
width = 1.dp,
color = FnColors.border,
shape = RoundedCornerShape(FnRadius.md),
),
shape = RoundedCornerShape(FnRadius.md),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
Column(
modifier = Modifier.padding(FnSpacing.md),
verticalArrangement = Arrangement.spacedBy(FnSpacing.xs / 2),
) {
// Label
Text(
text = label,
fontSize = FnTypography.sm,
fontWeight = FnTypography.normal,
color = FnColors.textMuted,
)
// Primary value
Text(
text = value,
fontSize = FnTypography.xl,
fontWeight = FnTypography.bold,
color = MaterialTheme.colorScheme.onSurface,
)
// Delta badge
if (delta != null) {
Text(
text = delta,
fontSize = FnTypography.xs,
fontWeight = FnTypography.medium,
color = if (deltaPositive) FnColors.success else FnColors.error,
)
}
// Inline sparkline — drawn with Canvas, no external component
if (!sparklineData.isNullOrEmpty()) {
val primaryColor = FnColors.primary
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(32.dp),
) {
val data = sparklineData
if (data.size < 2) return@Canvas
val minVal = data.min()
val maxVal = data.max()
val range = (maxVal - minVal).let { if (it == 0f) 1f else it }
val w = size.width
val h = size.height
val stepX = w / (data.size - 1).toFloat()
// Build path points
val points = data.mapIndexed { index, v ->
val x = index * stepX
// Normalize: high value -> low y (top of canvas)
val y = h - ((v - minVal) / range) * h
Offset(x, y)
}
// Draw polyline segment by segment
for (i in 0 until points.size - 1) {
drawLine(
color = primaryColor,
start = points[i],
end = points[i + 1],
strokeWidth = 2.dp.toPx(),
cap = StrokeCap.Round,
)
}
}
}
}
}
}
/**
* Column descriptor for [FnDataTable].
*
* @param T row data type.
* @param header Column header label.
* @param weight Relative width weight passed to [Modifier.weight].
* @param cell Composable that renders a single cell for a given row value.
*/
data class FnTableColumn<T>(
val header: String,
val weight: Float,
val cell: @Composable (T) -> Unit,
)
/**
* Static data table.
*
* Uses a non-lazy [Column] so it can be embedded inside a parent
* `verticalScroll` (as the gallery does). Do not wrap in LazyColumn.
*
* Layout:
* - Header row: one Text per column, weighted, bold, muted color.
* - [HorizontalDivider] below the header.
* - One Row per data entry: one [Box] per column, weighted.
* - [HorizontalDivider] between data rows (not after the last).
*/
@Composable
fun <T> FnDataTable(
rows: List<T>,
columns: List<FnTableColumn<T>>,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
// Header row
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = FnSpacing.sm,
vertical = FnSpacing.xs,
),
verticalAlignment = Alignment.CenterVertically,
) {
columns.forEach { col ->
Text(
text = col.header,
modifier = Modifier.weight(col.weight),
fontSize = FnTypography.sm,
fontWeight = FontWeight.Bold,
color = FnColors.textMuted,
)
}
}
HorizontalDivider(color = FnColors.border, thickness = 1.dp)
// Data rows
rows.forEachIndexed { rowIndex, row ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = FnSpacing.sm,
vertical = FnSpacing.xs,
),
verticalAlignment = Alignment.CenterVertically,
) {
columns.forEach { col ->
Box(modifier = Modifier.weight(col.weight)) {
col.cell(row)
}
}
}
// Divider between rows, not after the last
if (rowIndex < rows.lastIndex) {
HorizontalDivider(color = FnColors.border, thickness = 1.dp)
}
}
}
}
@@ -0,0 +1,283 @@
package fn.compose.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import fn.compose.theme.FnColors
import fn.compose.theme.FnRadius
import fn.compose.theme.FnShadows
import fn.compose.theme.FnSpacing
import fn.compose.theme.FnTypography
// ---------------------------------------------------------------------------
// FnText
// ---------------------------------------------------------------------------
/** Body text size scale. Maps directly to [FnTypography] sp values. */
enum class FnTextSize { Xs, Sm, Md, Lg, Xl }
/**
* Stateless body text following the registry typography scale.
*
* @param text String to display.
* @param size One of [FnTextSize] — maps to FnTypography.xs … xl.
* @param color Text color. Defaults to [MaterialTheme.colorScheme.onSurface]
* when [Color.Unspecified] is passed.
*/
@Composable
fun FnText(
text: String,
modifier: Modifier = Modifier,
size: FnTextSize = FnTextSize.Md,
color: Color = Color.Unspecified,
) {
val fontSize = when (size) {
FnTextSize.Xs -> FnTypography.xs
FnTextSize.Sm -> FnTypography.sm
FnTextSize.Md -> FnTypography.md
FnTextSize.Lg -> FnTypography.lg
FnTextSize.Xl -> FnTypography.xl
}
val resolvedColor = if (color == Color.Unspecified) {
MaterialTheme.colorScheme.onSurface
} else {
color
}
Text(
text = text,
modifier = modifier,
color = resolvedColor,
fontSize = fontSize,
fontWeight = FnTypography.normal,
)
}
// ---------------------------------------------------------------------------
// FnTitle
// ---------------------------------------------------------------------------
/**
* Heading text from order 1 (largest) to order 6 (smallest).
*
* Orders 12 use [FontWeight.Bold]; orders 36 use [FontWeight.SemiBold].
* Font size maps to [FnTypography.h1] … [FnTypography.h6].
*/
@Composable
fun FnTitle(
text: String,
modifier: Modifier = Modifier,
order: Int = 1,
) {
val clamped = order.coerceIn(1, 6)
val fontSize = when (clamped) {
1 -> FnTypography.h1
2 -> FnTypography.h2
3 -> FnTypography.h3
4 -> FnTypography.h4
5 -> FnTypography.h5
else -> FnTypography.h6
}
val fontWeight = if (clamped <= 2) FnTypography.bold else FnTypography.semibold
Text(
text = text,
modifier = modifier,
color = MaterialTheme.colorScheme.onSurface,
fontSize = fontSize,
fontWeight = fontWeight,
)
}
// ---------------------------------------------------------------------------
// FnCard
// ---------------------------------------------------------------------------
/** Visual style variant for [FnCard]. */
enum class FnCardVariant { Default, Borderless, Ghost }
/**
* Container card with three visual variants.
*
* - **Default**: surface background, 1 dp border ([FnColors.border]),
* [FnRadius.md] corners, [FnShadows.sm] elevation, [FnSpacing.md] padding.
* - **Borderless**: surface background, no border, no shadow,
* [FnSpacing.md] padding.
* - **Ghost**: transparent background, no border, no shadow,
* [FnSpacing.md] padding.
*/
@Composable
fun FnCard(
modifier: Modifier = Modifier,
variant: FnCardVariant = FnCardVariant.Default,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(FnRadius.md)
when (variant) {
FnCardVariant.Default -> {
Surface(
modifier = modifier,
shape = shape,
color = MaterialTheme.colorScheme.surface,
shadowElevation = FnShadows.sm,
tonalElevation = 0.dp,
) {
Column(
modifier = Modifier
.border(width = 1.dp, color = FnColors.border, shape = shape)
.padding(FnSpacing.md),
content = content,
)
}
}
FnCardVariant.Borderless -> {
Surface(
modifier = modifier,
shape = shape,
color = MaterialTheme.colorScheme.surface,
shadowElevation = 0.dp,
tonalElevation = 0.dp,
) {
Column(
modifier = Modifier.padding(FnSpacing.md),
content = content,
)
}
}
FnCardVariant.Ghost -> {
Surface(
modifier = modifier,
shape = shape,
color = Color.Transparent,
shadowElevation = 0.dp,
tonalElevation = 0.dp,
) {
Column(
modifier = Modifier.padding(FnSpacing.md),
content = content,
)
}
}
}
}
// ---------------------------------------------------------------------------
// FnBadge
// ---------------------------------------------------------------------------
/** Semantic color options for [FnBadge]. */
enum class FnBadgeColor { Brand, Gray, Green, Red, Yellow, Blue }
/**
* Pill-shaped label using a semitransparent background derived from the base
* color (alpha 0.18) and the base color as text color.
*
* Color mapping:
* - Brand → [FnColors.primary]
* - Gray → [FnColors.textMuted]
* - Green → [FnColors.success]
* - Red → [FnColors.error]
* - Yellow → [FnColors.warning]
* - Blue → [FnColors.info]
*/
@Composable
fun FnBadge(
text: String,
modifier: Modifier = Modifier,
color: FnBadgeColor = FnBadgeColor.Brand,
) {
val baseColor = when (color) {
FnBadgeColor.Brand -> FnColors.primary
FnBadgeColor.Gray -> FnColors.textMuted
FnBadgeColor.Green -> FnColors.success
FnBadgeColor.Red -> FnColors.error
FnBadgeColor.Yellow -> FnColors.warning
FnBadgeColor.Blue -> FnColors.info
}
val bgColor = baseColor.copy(alpha = 0.18f)
val shape = RoundedCornerShape(FnRadius.full)
Box(
modifier = modifier
.background(color = bgColor, shape = shape)
.padding(horizontal = 8.dp, vertical = 3.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = baseColor,
fontSize = FnTypography.xs,
fontWeight = FnTypography.medium,
)
}
}
// ---------------------------------------------------------------------------
// FnAvatar
// ---------------------------------------------------------------------------
/** Size variants for [FnAvatar]. */
enum class FnAvatarSize { Sm, Md, Lg }
/**
* Circular avatar displaying up to two initials on a [FnColors.primary]
* background.
*
* Size mapping:
* - Sm → 28 dp
* - Md → 40 dp
* - Lg → 56 dp
*
* Text size scales proportionally (xs / sm / md) so the initials always fit.
*/
@Composable
fun FnAvatar(
initials: String,
modifier: Modifier = Modifier,
size: FnAvatarSize = FnAvatarSize.Md,
) {
val sizeDp = when (size) {
FnAvatarSize.Sm -> 28.dp
FnAvatarSize.Md -> 40.dp
FnAvatarSize.Lg -> 56.dp
}
val fontSize = when (size) {
FnAvatarSize.Sm -> FnTypography.xs
FnAvatarSize.Md -> FnTypography.sm
FnAvatarSize.Lg -> FnTypography.md
}
// Truncate to at most two characters so the avatar never overflows.
val label = initials.take(2).uppercase()
Box(
modifier = modifier
.size(sizeDp)
.background(color = FnColors.primary, shape = CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = FnColors.white,
fontSize = fontSize,
fontWeight = FnTypography.semibold,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,260 @@
package fn.compose.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fn.compose.theme.FnColors
import fn.compose.theme.FnRadius
import fn.compose.theme.FnSpacing
import fn.compose.theme.FnTypography
// ---------------------------------------------------------------------------
// FnTabs
// ---------------------------------------------------------------------------
/**
* Tab bar backed by [TabRow] or [ScrollableTabRow] depending on [scrollable].
*
* Each item in [tabs] becomes a [Tab] whose selected state is determined by
* comparing its index with [selectedIndex]. Selecting a tab triggers
* [onTabSelected] with the tapped index.
*/
@Composable
fun FnTabs(
tabs: List<String>,
selectedIndex: Int,
onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
scrollable: Boolean = false,
) {
val tabContent: @Composable () -> Unit = {
tabs.forEachIndexed { index, label ->
Tab(
selected = index == selectedIndex,
onClick = { onTabSelected(index) },
text = { Text(label) },
)
}
}
if (scrollable) {
ScrollableTabRow(
selectedTabIndex = selectedIndex,
modifier = modifier,
) { tabContent() }
} else {
TabRow(
selectedTabIndex = selectedIndex,
modifier = modifier,
) { tabContent() }
}
}
// ---------------------------------------------------------------------------
// FnAlert
// ---------------------------------------------------------------------------
/** Semantic intent of an [FnAlert]. Maps to a status color from [FnColors]. */
enum class FnAlertVariant { Info, Success, Warning, Error }
/**
* Inline alert banner.
*
* The background is the variant's status color at 15 % opacity; the border is
* the same color at full opacity (1 dp). When [title] is supplied it is
* rendered in bold above [text].
*/
@Composable
fun FnAlert(
text: String,
modifier: Modifier = Modifier,
title: String? = null,
variant: FnAlertVariant = FnAlertVariant.Info,
) {
val colorBase: Color = when (variant) {
FnAlertVariant.Info -> FnColors.info
FnAlertVariant.Success -> FnColors.success
FnAlertVariant.Warning -> FnColors.warning
FnAlertVariant.Error -> FnColors.error
}
val shape = RoundedCornerShape(FnRadius.sm)
val onSurface = MaterialTheme.colorScheme.onSurface
Box(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(colorBase.copy(alpha = 0.15f))
.border(width = 1.dp, color = colorBase, shape = shape)
.padding(FnSpacing.md),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (title != null) {
Text(
text = title,
color = colorBase,
fontWeight = FontWeight.Bold,
fontSize = FnTypography.sm,
)
}
Text(
text = text,
color = onSurface,
fontSize = FnTypography.sm,
)
}
}
}
// ---------------------------------------------------------------------------
// FnLoader
// ---------------------------------------------------------------------------
/** Visual size of an [FnLoader] spinner. */
enum class FnLoaderSize { Sm, Md, Lg }
/**
* Indeterminate circular progress indicator using [FnColors.primary].
*
* [size] maps to 16 dp (Sm), 32 dp (Md), or 56 dp (Lg). The stroke width
* scales proportionally (2 / 3 / 5 dp).
*/
@Composable
fun FnLoader(
modifier: Modifier = Modifier,
size: FnLoaderSize = FnLoaderSize.Md,
) {
val (sizeDp, strokeDp) = when (size) {
FnLoaderSize.Sm -> 16.dp to 2.dp
FnLoaderSize.Md -> 32.dp to 3.dp
FnLoaderSize.Lg -> 56.dp to 5.dp
}
CircularProgressIndicator(
modifier = modifier.size(sizeDp),
color = FnColors.primary,
strokeWidth = strokeDp,
)
}
// ---------------------------------------------------------------------------
// FnSkeleton
// ---------------------------------------------------------------------------
/**
* Shimmer placeholder rectangle.
*
* Fills the available width to [height] dp with a rounded shape and an
* animated alpha oscillating between [FnColors.surfaceHover] and
* [FnColors.surfaceActive], simulating a loading shimmer without external
* dependencies.
*/
@Composable
fun FnSkeleton(
modifier: Modifier = Modifier,
height: Dp = 16.dp,
) {
val infiniteTransition = rememberInfiniteTransition(label = "skeleton_shimmer")
val alpha by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "skeleton_alpha",
)
// Interpolate between surfaceHover and surfaceActive using the animated alpha.
val shimmerColor = androidx.compose.ui.graphics.lerp(
FnColors.surfaceHover,
FnColors.surfaceActive,
alpha,
)
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(FnRadius.sm))
.background(shimmerColor),
)
}
// ---------------------------------------------------------------------------
// FnEmptyState
// ---------------------------------------------------------------------------
/**
* Centred empty-state panel with optional icon, title, description, and
* action slot.
*
* Layout (top to bottom, horizontally centred):
* 1. [icon] rendered as large emoji/text (40 sp) — only if non-null.
* 2. [title] in bold body size.
* 3. [description] in [FnColors.textMuted].
* 4. [action] composable slot — only if non-null.
*
* Padding is [FnSpacing.lg] on all sides; vertical gaps use [FnSpacing.sm].
*/
@Composable
fun FnEmptyState(
title: String,
description: String,
modifier: Modifier = Modifier,
icon: String? = null,
action: (@Composable () -> Unit)? = null,
) {
Column(
modifier = modifier.padding(FnSpacing.lg),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(FnSpacing.sm),
) {
if (icon != null) {
Text(
text = icon,
fontSize = 40.sp,
)
}
Text(
text = title,
fontWeight = FontWeight.Bold,
fontSize = FnTypography.md,
)
Text(
text = description,
color = FnColors.textMuted,
fontSize = FnTypography.sm,
)
action?.invoke()
}
}
@@ -0,0 +1,318 @@
package fn.compose.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import fn.compose.theme.FnColors
import fn.compose.theme.FnRadius
import fn.compose.theme.FnSpacing
// ---------------------------------------------------------------------------
// Button
// ---------------------------------------------------------------------------
/** Variants for [FnButton]. Maps to Material3 button styles + custom colors. */
enum class FnButtonVariant {
Filled,
Outlined,
Secondary,
Ghost,
Destructive,
Link,
}
private val buttonShape = RoundedCornerShape(FnRadius.sm)
/**
* Design-system button with six visual variants. Stateless — the caller
* drives [onClick].
*
* - Filled: primary container color.
* - Outlined: border only, no fill.
* - Secondary: filled with [FnColors.surfaceActive].
* - Ghost: text-only, no chrome.
* - Destructive: filled with [FnColors.error].
* - Link: text-only with [FnColors.primary] color and underline decoration.
*/
@Composable
fun FnButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
variant: FnButtonVariant = FnButtonVariant.Filled,
) {
val shape = buttonShape
when (variant) {
FnButtonVariant.Filled -> Button(
onClick = onClick,
modifier = modifier,
shape = shape,
) {
Text(text)
}
FnButtonVariant.Outlined -> OutlinedButton(
onClick = onClick,
modifier = modifier,
shape = shape,
) {
Text(text)
}
FnButtonVariant.Secondary -> Button(
onClick = onClick,
modifier = modifier,
shape = shape,
colors = ButtonDefaults.buttonColors(
containerColor = FnColors.surfaceActive,
contentColor = FnColors.text,
),
) {
Text(text)
}
FnButtonVariant.Ghost -> TextButton(
onClick = onClick,
modifier = modifier,
shape = shape,
) {
Text(text)
}
FnButtonVariant.Destructive -> Button(
onClick = onClick,
modifier = modifier,
shape = shape,
colors = ButtonDefaults.buttonColors(
containerColor = FnColors.error,
contentColor = FnColors.white,
),
) {
Text(text)
}
FnButtonVariant.Link -> TextButton(
onClick = onClick,
modifier = modifier,
shape = shape,
) {
Text(
text = text,
color = FnColors.primary,
textDecoration = TextDecoration.Underline,
)
}
}
}
// ---------------------------------------------------------------------------
// Text input
// ---------------------------------------------------------------------------
/**
* Single-line text field with optional label, placeholder and inline error.
* Stateless — the caller owns [value] / [onValueChange].
*
* When [error] is non-null, the field enters error state and the supporting
* text slot shows the error message in [FnColors.error].
*/
@Composable
fun FnTextInput(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
placeholder: String? = null,
error: String? = null,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
label = label?.let { { Text(it) } },
placeholder = placeholder?.let { { Text(it) } },
isError = error != null,
singleLine = true,
supportingText = error?.let {
{
Text(
text = it,
color = FnColors.error,
)
}
},
)
}
// ---------------------------------------------------------------------------
// Select (dropdown)
// ---------------------------------------------------------------------------
/**
* Exposed dropdown menu that lets the caller pick one item from [options].
* Stateless — the caller drives [selected] / [onSelected].
*
* Annotated with [ExperimentalMaterial3Api] because [ExposedDropdownMenuBox]
* is part of the experimental Material3 API surface in BOM 2024.02.00.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FnSelect(
options: List<String>,
selected: String?,
onSelected: (String) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = modifier,
) {
OutlinedTextField(
value = selected ?: "",
onValueChange = {},
readOnly = true,
label = label?.let { { Text(it) } },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
onSelected(option)
expanded = false
},
)
}
}
}
}
// ---------------------------------------------------------------------------
// Switch
// ---------------------------------------------------------------------------
/**
* Labeled toggle. The [label] is rendered to the right of the [Switch] when
* non-null. Stateless — the caller owns [checked] / [onCheckedChange].
*/
@Composable
fun FnSwitch(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(FnSpacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
)
if (label != null) {
Text(text = label)
}
}
}
// ---------------------------------------------------------------------------
// Checkbox
// ---------------------------------------------------------------------------
/**
* Labeled checkbox. The [label] is rendered to the right of the [Checkbox]
* when non-null. Stateless — the caller owns [checked] / [onCheckedChange].
*/
@Composable
fun FnCheckbox(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(FnSpacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
)
if (label != null) {
Text(text = label)
}
}
}
// ---------------------------------------------------------------------------
// Dialog
// ---------------------------------------------------------------------------
/**
* Modal confirmation dialog.
*
* Rendered only when [open] is true. Provides "Confirmar" (calls [onConfirm])
* and "Cancelar" (calls [onDismiss]) text buttons. The caller is responsible
* for updating the [open] flag in response to those callbacks.
*/
@Composable
fun FnDialog(
open: Boolean,
onDismiss: () -> Unit,
title: String,
description: String,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
if (open) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(description) },
confirmButton = {
TextButton(onClick = onConfirm) { Text("Confirmar") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancelar") }
},
modifier = modifier,
)
}
}
@@ -0,0 +1,133 @@
package fn.compose.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import fn.compose.theme.FnColors
import fn.compose.theme.FnRadius
import fn.compose.theme.FnShadows
import fn.compose.theme.FnSpacing
/**
* Vertical layout primitive. Wraps a [Column] with [Arrangement.spacedBy] using [gap].
*
* Equivalent to the web `<Stack>` component from `@fn_library` (Mantine Stack).
* Use to stack items with consistent vertical spacing without inline padding.
*
* @param modifier Modifier applied to the outer [Column].
* @param gap Space between children. Defaults to [FnSpacing.md] (16 dp).
* @param content Composable children provided in [ColumnScope].
*/
@Composable
fun FnStack(
modifier: Modifier = Modifier,
gap: Dp = FnSpacing.md,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(gap),
content = content,
)
}
/**
* Horizontal layout primitive. Wraps a [Row] with [Arrangement.spacedBy] using [gap]
* and vertically centers children via [Alignment.CenterVertically].
*
* Equivalent to the web `<Group>` component from `@fn_library` (Mantine Group).
* Use to place items side-by-side with consistent horizontal spacing.
*
* @param modifier Modifier applied to the outer [Row].
* @param gap Space between children. Defaults to [FnSpacing.sm] (12 dp).
* @param content Composable children provided in [RowScope].
*/
@Composable
fun FnGroup(
modifier: Modifier = Modifier,
gap: Dp = FnSpacing.sm,
content: @Composable RowScope.() -> Unit,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(gap),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
/**
* Elevated container surface. Wraps a [Surface] with [FnColors.surface] background,
* rounded corners ([FnRadius.md]) and a subtle tonal elevation ([FnShadows.xs]).
*
* Equivalent to the web `<Paper>` component from `@fn_library` (Mantine Paper).
* Use when content needs a visual separation from the background without a heavy border.
*
* @param modifier Modifier applied to the [Surface].
* @param content Composable children provided in [ColumnScope] with [FnSpacing.md] padding.
*/
@Composable
fun FnPaper(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
modifier = modifier,
color = FnColors.surface,
shape = RoundedCornerShape(FnRadius.md),
tonalElevation = FnShadows.xs,
) {
Column(
modifier = Modifier.padding(FnSpacing.md),
content = content,
)
}
}
/**
* Root application shell with a top app bar and a content slot that receives
* the inner padding from [Scaffold].
*
* Equivalent to the web `<AppShell>` component from `@fn_library`. Place this
* at the top of every screen composable so the system status bar and navigation
* insets are handled consistently.
*
* The [TopAppBar] is annotated with [ExperimentalMaterial3Api] as required by
* Compose Material 3 (BOM 2024.02.00). The opt-in is scoped to this function.
*
* @param title Text displayed in the top app bar.
* @param modifier Modifier applied to the [Scaffold].
* @param content Screen content. Receives [PaddingValues] from the scaffold and
* is responsible for applying them (e.g. via [Modifier.padding]).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FnAppShell(
title: String,
modifier: Modifier = Modifier,
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = title) },
)
},
content = content,
)
}
@@ -0,0 +1,32 @@
---
name: fn_alert
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnAlert(text: String, modifier: Modifier = Modifier, title: String? = null, variant: FnAlertVariant = FnAlertVariant.Info)"
description: "Bloque de aviso con 4 variantes semanticas (Info/Success/Warning/Error), fondo y borde del color."
tags: [compose, android, ui, feedback, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Feedback.kt"
---
## Ejemplo
```kotlin
FnAlert("Guardado", title = "Success", variant = FnAlertVariant.Success)
```
## Cuando usarla
Para mensajes contextuales de estado dentro de una pantalla. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,34 @@
---
name: fn_app_shell
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnAppShell(title: String, modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit)"
description: "Scaffold con TopAppBar (titulo) que envuelve el contenido principal de una pantalla. Equivalente de <AppShell> de Mantine."
tags: [compose, android, ui, layout, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Layout.kt"
---
## Ejemplo
```kotlin
FnAppShell(title = "Mi App") { padding ->
FnStack(Modifier.padding(padding)) { /* ... */ }
}
```
## Cuando usarla
Como contenedor raiz de cada pantalla de la app, una vez dentro de FnTheme. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_avatar
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnAvatar(initials: String, modifier: Modifier = Modifier, size: FnAvatarSize = FnAvatarSize.Md)"
description: "Circulo con iniciales sobre fondo primary, tamanos Sm (28dp), Md (40dp), Lg (56dp)."
tags: [compose, android, ui, display, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Display.kt"
---
## Ejemplo
```kotlin
FnAvatar("EG", size = FnAvatarSize.Md)
```
## Cuando usarla
Para representar usuarios o entidades por iniciales cuando no hay imagen. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_badge
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnBadge(text: String, modifier: Modifier = Modifier, color: FnBadgeColor = FnBadgeColor.Brand)"
description: "Pill de estado con 6 colores semanticos (Brand/Gray/Green/Red/Yellow/Blue), fondo tenue + texto del color."
tags: [compose, android, ui, display, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Display.kt"
---
## Ejemplo
```kotlin
FnBadge("ACTIVE", color = FnBadgeColor.Green)
```
## Cuando usarla
Para etiquetar estados, categorias o tags cortos. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_bar_chart
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnBarChart(data: List<FnBarItem>, modifier: Modifier = Modifier)"
description: "Grafico de barras verticales Canvas con labels. Define FnBarItem(label, value)."
tags: [compose, android, ui, charts, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Charts.kt"
---
## Ejemplo
```kotlin
FnBarChart(data = listOf(FnBarItem("Lun", 12f), FnBarItem("Mar", 19f)))
```
## Cuando usarla
Para comparar valores discretos por categoria. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,33 @@
---
name: fn_button
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, variant: FnButtonVariant = FnButtonVariant.Filled)"
description: "Boton con 6 variantes: Filled, Outlined, Secondary, Ghost, Destructive, Link. Mirror de los variants de Mantine."
tags: [compose, android, ui, inputs, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Inputs.kt"
---
## Ejemplo
```kotlin
FnButton("Enviar", onClick = { /* ... */ })
FnButton("Borrar", onClick = {}, variant = FnButtonVariant.Destructive)
```
## Cuando usarla
Para cualquier accion; elige variant segun jerarquia/semantica. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,34 @@
---
name: fn_card
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnCard(modifier: Modifier = Modifier, variant: FnCardVariant = FnCardVariant.Default, content: @Composable ColumnScope.() -> Unit)"
description: "Tarjeta contenedora con tres variantes: Default (borde + sombra), Borderless (solo fondo), Ghost (transparente)."
tags: [compose, android, ui, display, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Display.kt"
---
## Ejemplo
```kotlin
FnCard(variant = FnCardVariant.Default) {
FnText("Dentro de la card")
}
```
## Cuando usarla
Para agrupar contenido relacionado con enfasis visual. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_checkbox
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, label: String? = null)"
description: "Checkbox con label opcional. Stateless."
tags: [compose, android, ui, inputs, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Inputs.kt"
---
## Ejemplo
```kotlin
FnCheckbox(checked = ok, onCheckedChange = { ok = it }, label = "Acepto")
```
## Cuando usarla
Para seleccion booleana en formularios y listas. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,35 @@
---
name: fn_data_table
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun <T> FnDataTable(rows: List<T>, columns: List<FnTableColumn<T>>, modifier: Modifier = Modifier)"
description: "Tabla generica por columnas (FnTableColumn: header, weight, cell). Column estatica, cada celda es un Composable. Define FnTableColumn<T>."
tags: [compose, android, ui, data, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Data.kt"
---
## Ejemplo
```kotlin
FnDataTable(rows = users, columns = listOf(
FnTableColumn("Nombre", 2f) { FnText(it.name) },
FnTableColumn("Estado", 1f) { FnBadge(it.status) },
))
```
## Cuando usarla
Para mostrar datos tabulares con celdas personalizadas. No anidar en verticalScroll con muchas filas. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_dialog
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnDialog(open: Boolean, onDismiss: () -> Unit, title: String, description: String, onConfirm: () -> Unit, modifier: Modifier = Modifier)"
description: "Modal AlertDialog con titulo, descripcion y botones Confirmar/Cancelar. Se renderiza solo si open."
tags: [compose, android, ui, inputs, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Inputs.kt"
---
## Ejemplo
```kotlin
FnDialog(open = show, onDismiss = { show = false }, title = "Confirmar", description = "Seguro?", onConfirm = { show = false })
```
## Cuando usarla
Para confirmaciones y dialogos modales bloqueantes. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_empty_state
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnEmptyState(title: String, description: String, modifier: Modifier = Modifier, icon: String? = null, action: (@Composable () -> Unit)? = null)"
description: "Estado vacio centrado con icono (emoji), titulo, descripcion y accion opcional."
tags: [compose, android, ui, feedback, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Feedback.kt"
---
## Ejemplo
```kotlin
FnEmptyState(title = "Sin datos", description = "Anade el primero", icon = "📭")
```
## Cuando usarla
Para listas/pantallas sin contenido todavia. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,35 @@
---
name: fn_group
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnGroup(modifier: Modifier = Modifier, gap: Dp = FnSpacing.sm, content: @Composable RowScope.() -> Unit)"
description: "Row con separacion uniforme entre hijos y alineacion vertical centrada. Equivalente Compose de <Group> de Mantine."
tags: [compose, android, ui, layout, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Layout.kt"
---
## Ejemplo
```kotlin
FnGroup {
FnButton("Guardar", onClick = {})
FnButton("Cancelar", onClick = {}, variant = FnButtonVariant.Ghost)
}
```
## Cuando usarla
Para alinear elementos en fila (botones, badges) con separacion uniforme. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_kpi_card
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnKpiCard(label: String, value: String, modifier: Modifier = Modifier, delta: String? = null, deltaPositive: Boolean = true, sparklineData: List<Float>? = null)"
description: "Tarjeta de metrica con label, valor grande, delta coloreado y sparkline inline opcional."
tags: [compose, android, ui, data, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Data.kt"
---
## Ejemplo
```kotlin
FnKpiCard(label = "Revenue", value = "42k", delta = "+12%", sparklineData = listOf(1f,3f,2f,5f))
```
## Cuando usarla
Para dashboards: mostrar un KPI con tendencia. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_line_chart
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnLineChart(data: List<Float>, modifier: Modifier = Modifier)"
description: "Grafico de linea Canvas con relleno de area, sin dependencias externas. Normaliza la serie automaticamente."
tags: [compose, android, ui, charts, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Charts.kt"
---
## Ejemplo
```kotlin
FnLineChart(data = listOf(10f,14f,12f,18f,22f), modifier = Modifier.fillMaxWidth())
```
## Cuando usarla
Para series temporales o tendencias continuas. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_loader
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnLoader(modifier: Modifier = Modifier, size: FnLoaderSize = FnLoaderSize.Md)"
description: "Spinner CircularProgressIndicator en color primary, tamanos Sm/Md/Lg."
tags: [compose, android, ui, feedback, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Feedback.kt"
---
## Ejemplo
```kotlin
FnLoader(size = FnLoaderSize.Md)
```
## Cuando usarla
Para indicar carga indeterminada. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_page_header
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnPageHeader(title: String, modifier: Modifier = Modifier, subtitle: String? = null, actions: (@Composable () -> Unit)? = null)"
description: "Cabecera de seccion con titulo, subtitulo opcional y slot de acciones a la derecha."
tags: [compose, android, ui, data, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Data.kt"
---
## Ejemplo
```kotlin
FnPageHeader("Usuarios", subtitle = "Listado activo", actions = { FnButton("Nuevo", onClick = {}) })
```
## Cuando usarla
Al inicio de cada seccion/pantalla para titular y exponer acciones. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,34 @@
---
name: fn_paper
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnPaper(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit)"
description: "Contenedor Surface minimal con radius y sombra leves, fondo surface del tema. Mas ligero que FnCard."
tags: [compose, android, ui, layout, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Layout.kt"
---
## Ejemplo
```kotlin
FnPaper(modifier = Modifier.fillMaxWidth()) {
FnText("Contenido")
}
```
## Cuando usarla
Para agrupar contenido en una superficie elevada sin el borde marcado de FnCard. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_select
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnSelect(options: List<String>, selected: String?, onSelected: (String) -> Unit, modifier: Modifier = Modifier, label: String? = null)"
description: "Dropdown ExposedDropdownMenu sobre una lista de opciones string. Stateless respecto a la seleccion."
tags: [compose, android, ui, inputs, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Inputs.kt"
---
## Ejemplo
```kotlin
FnSelect(options = listOf("A","B"), selected = sel, onSelected = { sel = it }, label = "Tipo")
```
## Cuando usarla
Para elegir una opcion de una lista cerrada. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_skeleton
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnSkeleton(modifier: Modifier = Modifier, height: Dp = 16.dp)"
description: "Placeholder animado (shimmer) para contenido en carga. Interpola entre surfaceHover y surfaceActive."
tags: [compose, android, ui, feedback, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Feedback.kt"
---
## Ejemplo
```kotlin
FnSkeleton(height = 20.dp)
```
## Cuando usarla
Para esqueletos de carga antes de que lleguen los datos. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_sparkline
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnSparkline(data: List<Float>, modifier: Modifier = Modifier)"
description: "Mini grafico de linea inline (sin ejes ni labels) para incrustar en KPIs, tablas o filas."
tags: [compose, android, ui, charts, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Charts.kt"
---
## Ejemplo
```kotlin
FnSparkline(listOf(20f,35f,28f,42f,38f))
```
## Cuando usarla
Para tendencias compactas dentro de otra fila/tarjeta. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,35 @@
---
name: fn_stack
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnStack(modifier: Modifier = Modifier, gap: Dp = FnSpacing.md, content: @Composable ColumnScope.() -> Unit)"
description: "Column con separacion uniforme entre hijos (Arrangement.spacedBy(gap)). Equivalente Compose de <Stack> de Mantine."
tags: [compose, android, ui, layout, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Layout.kt"
---
## Ejemplo
```kotlin
FnStack(gap = FnSpacing.md) {
FnText("Linea 1")
FnText("Linea 2")
}
```
## Cuando usarla
Para apilar elementos en vertical con separacion consistente sin repetir Spacers. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_switch
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnSwitch(checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, label: String? = null)"
description: "Toggle Switch con label opcional a la derecha. Stateless."
tags: [compose, android, ui, inputs, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Inputs.kt"
---
## Ejemplo
```kotlin
FnSwitch(checked = on, onCheckedChange = { on = it }, label = "Activado")
```
## Cuando usarla
Para opciones booleanas de activado/desactivado. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_tabs
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnTabs(tabs: List<String>, selectedIndex: Int, onTabSelected: (Int) -> Unit, modifier: Modifier = Modifier, scrollable: Boolean = false)"
description: "Barra de pestanas TabRow (o ScrollableTabRow si scrollable). Stateless respecto al indice activo."
tags: [compose, android, ui, feedback, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Feedback.kt"
---
## Ejemplo
```kotlin
FnTabs(tabs = cats, selectedIndex = tab, onTabSelected = { tab = it }, scrollable = true)
```
## Cuando usarla
Para navegar entre secciones/categorias dentro de una pantalla. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_text
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnText(text: String, modifier: Modifier = Modifier, size: FnTextSize = FnTextSize.Md, color: Color = Color.Unspecified)"
description: "Texto de cuerpo con escala de tamanos Xs/Sm/Md/Lg/Xl via FnTypography. Color onSurface por defecto."
tags: [compose, android, ui, display, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Display.kt"
---
## Ejemplo
```kotlin
FnText("Hola", size = FnTextSize.Lg)
```
## Cuando usarla
Para todo texto de cuerpo; no uses Text de Material directo. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,33 @@
---
name: fn_text_input
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnTextInput(value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, label: String? = null, placeholder: String? = null, error: String? = null)"
description: "Campo de texto OutlinedTextField con label, placeholder y estado de error opcionales. Stateless."
tags: [compose, android, ui, inputs, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Inputs.kt"
---
## Ejemplo
```kotlin
var v by remember { mutableStateOf("") }
FnTextInput(value = v, onValueChange = { v = it }, label = "Nombre")
```
## Cuando usarla
Para entrada de texto de una linea con validacion visual. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_title
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnTitle(text: String, modifier: Modifier = Modifier, order: Int = 1)"
description: "Encabezado con jerarquia order 1..6 (h1..h6 de FnTypography), peso bold/semibold."
tags: [compose, android, ui, display, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Display.kt"
---
## Ejemplo
```kotlin
FnTitle("Seccion", order = 2)
```
## Cuando usarla
Para titulos de seccion y headings jerarquicos. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.