feat(kotlin-compose): design system + 33 components + gallery_kt + e2e android emulator + scaffolder fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 16:29:04 +02:00
parent 96c9a5ec3c
commit ac5e623620
2 changed files with 370 additions and 7 deletions
@@ -1,7 +1,9 @@
package com.fnregistry.gallery_kt
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
@@ -15,9 +17,35 @@ class MainActivityTest {
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun appLaunchesAndShowsReadyText() {
composeTestRule
.onNodeWithText("gallery_kt ready")
.assertIsDisplayed()
fun appLaunchesAndShowsTitle() {
composeTestRule.onNodeWithText("@fn_compose Gallery").assertIsDisplayed()
}
@Test
fun typographySectionRenders() {
composeTestRule.onNodeWithText("Typography").assertIsDisplayed()
composeTestRule.onNodeWithText("Title order 1").assertIsDisplayed()
}
@Test
fun buttonsSectionShowsAllVariants() {
composeTestRule.onNodeWithText("Filled").assertIsDisplayed()
composeTestRule.onNodeWithText("Outlined").assertIsDisplayed()
composeTestRule.onNodeWithText("Secondary").assertIsDisplayed()
composeTestRule.onNodeWithText("Ghost").assertIsDisplayed()
composeTestRule.onNodeWithText("Destructive").assertIsDisplayed()
composeTestRule.onNodeWithText("Link").assertIsDisplayed()
// "Disabled" appears twice (button + input label) — assert >=1 displayed.
composeTestRule.onAllNodesWithText("Disabled")[0].assertIsDisplayed()
}
@Test
fun alertsAndBadgesPresent() {
// Badges live below the fold; assertExists checks tree, not viewport.
composeTestRule.onNodeWithText("BRAND").assertExists()
composeTestRule.onNodeWithText("GREEN").assertExists()
composeTestRule.onNodeWithText("RED").assertExists()
composeTestRule.onNodeWithText("Info").assertExists()
composeTestRule.onNodeWithText("Success").assertExists()
}
}
@@ -3,22 +3,357 @@ package com.fnregistry.gallery_kt
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.Modifier
import androidx.compose.ui.unit.dp
import fn.compose.theme.FnSpacing
import fn.compose.theme.FnTheme
import fn.compose.ui.FnAlert
import fn.compose.ui.FnAlertVariant
import fn.compose.ui.FnAppShell
import fn.compose.ui.FnAvatar
import fn.compose.ui.FnAvatarSize
import fn.compose.ui.FnBadge
import fn.compose.ui.FnBadgeColor
import fn.compose.ui.FnBarChart
import fn.compose.ui.FnBarItem
import fn.compose.ui.FnButton
import fn.compose.ui.FnButtonVariant
import fn.compose.ui.FnCard
import fn.compose.ui.FnCardVariant
import fn.compose.ui.FnCheckbox
import fn.compose.ui.FnDataTable
import fn.compose.ui.FnDialog
import fn.compose.ui.FnEmptyState
import fn.compose.ui.FnGroup
import fn.compose.ui.FnKpiCard
import fn.compose.ui.FnLineChart
import fn.compose.ui.FnLoader
import fn.compose.ui.FnPageHeader
import fn.compose.ui.FnPaper
import fn.compose.ui.FnSelect
import fn.compose.ui.FnSkeleton
import fn.compose.ui.FnSparkline
import fn.compose.ui.FnStack
import fn.compose.ui.FnSwitch
import fn.compose.ui.FnTableColumn
import fn.compose.ui.FnTabs
import fn.compose.ui.FnText
import fn.compose.ui.FnTextInput
import fn.compose.ui.FnTextSize
import fn.compose.ui.FnTitle
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
FnTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Text("gallery_kt ready")
Surface(Modifier.fillMaxSize()) { GalleryScreen() }
}
}
}
}
private val CATEGORIES = listOf("Typography", "Inputs", "Display", "Data", "Charts", "Feedback", "Layout")
@Composable
fun GalleryScreen() {
var tab by remember { mutableStateOf(0) }
FnAppShell(title = "@fn_compose Gallery") { padding ->
FnStack(
modifier = Modifier.padding(padding).fillMaxSize(),
gap = FnSpacing.xs,
) {
FnTabs(tabs = CATEGORIES, selectedIndex = tab, onTabSelected = { tab = it }, scrollable = true)
FnStack(
modifier = Modifier
.padding(horizontal = FnSpacing.md, vertical = FnSpacing.sm)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
gap = FnSpacing.lg,
) {
when (tab) {
0 -> TypographyCategory()
1 -> InputsCategory()
2 -> DisplayCategory()
3 -> DataCategory()
4 -> ChartsCategory()
5 -> FeedbackCategory()
6 -> LayoutCategory()
}
}
}
}
}
@Composable
private fun TypographyCategory() {
FnPageHeader("Typography", subtitle = "Titles + body scale, weights via FnTypography tokens.")
FnCard(modifier = Modifier.fillMaxWidth()) {
FnStack(gap = FnSpacing.xs) {
FnTitle("Title order 1", order = 1)
FnTitle("Title order 2", order = 2)
FnTitle("Title order 3", order = 3)
FnTitle("Title order 4", order = 4)
FnTitle("Title order 5", order = 5)
FnTitle("Title order 6", order = 6)
}
}
FnCard(modifier = Modifier.fillMaxWidth()) {
FnStack(gap = FnSpacing.xs) {
FnText("Body Xl size", size = FnTextSize.Xl)
FnText("Body Lg size", size = FnTextSize.Lg)
FnText("Body Md (default)", size = FnTextSize.Md)
FnText("Body Sm size", size = FnTextSize.Sm)
FnText("Body Xs size", size = FnTextSize.Xs)
}
}
}
@Composable
private fun InputsCategory() {
FnPageHeader("Inputs", subtitle = "Buttons + text + select + toggles.")
var input by remember { mutableStateOf("") }
var selected by remember { mutableStateOf<String?>("Opcion 1") }
var switchOn by remember { mutableStateOf(true) }
var checked by remember { mutableStateOf(false) }
var dialogOpen by remember { mutableStateOf(false) }
FnTitle("Buttons", order = 4)
FnGroup { FnButton("Filled", onClick = {}); FnButton("Outlined", onClick = {}, variant = FnButtonVariant.Outlined); FnButton("Secondary", onClick = {}, variant = FnButtonVariant.Secondary) }
FnGroup { FnButton("Ghost", onClick = {}, variant = FnButtonVariant.Ghost); FnButton("Destructive", onClick = {}, variant = FnButtonVariant.Destructive); FnButton("Link", onClick = {}, variant = FnButtonVariant.Link) }
FnButton("Open dialog", onClick = { dialogOpen = true }, variant = FnButtonVariant.Outlined)
FnTitle("Text input", order = 4)
FnTextInput(value = input, onValueChange = { input = it }, label = "Tu nombre", placeholder = "Ej. Lucas", modifier = Modifier.fillMaxWidth())
FnTextInput(value = "valor con error", onValueChange = {}, label = "Email", error = "Email invalido", modifier = Modifier.fillMaxWidth())
FnTitle("Select", order = 4)
FnSelect(options = listOf("Opcion 1", "Opcion 2", "Opcion 3"), selected = selected, onSelected = { selected = it }, label = "Tipo", modifier = Modifier.fillMaxWidth())
FnTitle("Toggles", order = 4)
FnSwitch(checked = switchOn, onCheckedChange = { switchOn = it }, label = "Switch activado")
FnCheckbox(checked = checked, onCheckedChange = { checked = it }, label = "Acepto los terminos")
FnDialog(
open = dialogOpen,
onDismiss = { dialogOpen = false },
title = "Confirmar accion",
description = "Esto es un dialog modal de ejemplo. Confirmar?",
onConfirm = { dialogOpen = false },
)
}
@Composable
private fun DisplayCategory() {
FnPageHeader("Display", subtitle = "Avatars, badges, cards, paper.")
FnTitle("Avatars", order = 4)
FnGroup {
FnAvatar("LM", size = FnAvatarSize.Sm)
FnAvatar("AM", size = FnAvatarSize.Md)
FnAvatar("EG", size = FnAvatarSize.Lg)
}
FnTitle("Badges", order = 4)
FnGroup {
FnBadge("BRAND")
FnBadge("GRAY", color = FnBadgeColor.Gray)
FnBadge("GREEN", color = FnBadgeColor.Green)
FnBadge("RED", color = FnBadgeColor.Red)
FnBadge("YELLOW", color = FnBadgeColor.Yellow)
FnBadge("BLUE", color = FnBadgeColor.Blue)
}
FnTitle("Cards", order = 4)
FnCard(modifier = Modifier.fillMaxWidth(), variant = FnCardVariant.Default) {
FnText("Default — border + shadow", size = FnTextSize.Sm)
}
FnCard(modifier = Modifier.fillMaxWidth(), variant = FnCardVariant.Borderless) {
FnText("Borderless — solo bg", size = FnTextSize.Sm)
}
FnCard(modifier = Modifier.fillMaxWidth(), variant = FnCardVariant.Ghost) {
FnText("Ghost — transparente", size = FnTextSize.Sm)
}
}
@Composable
private fun DataCategory() {
FnPageHeader("Data", subtitle = "Tables + KPI cards + page header.")
FnTitle("KPI cards", order = 4)
FnGroup(gap = FnSpacing.sm) {
FnKpiCard(
label = "Revenue",
value = "€42.3k",
delta = "+12.4%",
deltaPositive = true,
sparklineData = listOf(10f, 14f, 12f, 18f, 22f, 28f, 32f),
modifier = Modifier.weight(1f),
)
FnKpiCard(
label = "Churn",
value = "3.1%",
delta = "-0.5pp",
deltaPositive = true,
sparklineData = listOf(5f, 4.8f, 4.2f, 4f, 3.5f, 3.2f, 3.1f),
modifier = Modifier.weight(1f),
)
}
FnTitle("Data table", order = 4)
val rows = listOf(
Triple("Lucas", "Madrid", "active"),
Triple("Ana", "Barcelona", "active"),
Triple("Marta", "Valencia", "paused"),
Triple("Carlos", "Sevilla", "active"),
Triple("Sara", "Bilbao", "paused"),
)
FnDataTable(
rows = rows,
modifier = Modifier.fillMaxWidth(),
columns = listOf(
FnTableColumn<Triple<String, String, String>>(
header = "Nombre", weight = 1.2f,
cell = { FnText(it.first, size = FnTextSize.Sm) },
),
FnTableColumn(
header = "Ciudad", weight = 1.5f,
cell = { FnText(it.second, size = FnTextSize.Sm) },
),
FnTableColumn(
header = "Estado", weight = 1f,
cell = {
val (txt, color) = if (it.third == "active") "ACTIVE" to FnBadgeColor.Green
else "PAUSED" to FnBadgeColor.Yellow
FnBadge(txt, color = color)
},
),
),
)
}
@Composable
private fun ChartsCategory() {
FnPageHeader("Charts", subtitle = "Canvas-based, sin deps externas.")
FnTitle("Line chart", order = 4)
FnCard(modifier = Modifier.fillMaxWidth()) {
FnLineChart(
data = listOf(10f, 14f, 12f, 18f, 22f, 28f, 32f, 30f, 36f, 42f, 40f, 48f),
modifier = Modifier.fillMaxWidth(),
)
}
FnTitle("Bar chart", order = 4)
FnCard(modifier = Modifier.fillMaxWidth()) {
FnBarChart(
data = listOf(
FnBarItem("Lun", 12f),
FnBarItem("Mar", 19f),
FnBarItem("Mie", 8f),
FnBarItem("Jue", 22f),
FnBarItem("Vie", 28f),
FnBarItem("Sab", 18f),
FnBarItem("Dom", 10f),
),
modifier = Modifier.fillMaxWidth(),
)
}
FnTitle("Sparklines (inline)", order = 4)
FnPaper(modifier = Modifier.fillMaxWidth()) {
FnStack(gap = FnSpacing.sm) {
FnGroup { FnText("CPU", size = FnTextSize.Sm); FnSparkline(listOf(20f, 35f, 28f, 42f, 38f, 55f, 60f)) }
FnGroup { FnText("RAM", size = FnTextSize.Sm); FnSparkline(listOf(50f, 52f, 48f, 51f, 49f, 50f, 53f)) }
FnGroup { FnText("Net", size = FnTextSize.Sm); FnSparkline(listOf(5f, 12f, 8f, 25f, 18f, 30f, 22f)) }
}
}
}
@Composable
private fun FeedbackCategory() {
FnPageHeader("Feedback", subtitle = "Alerts + loaders + skeletons + empty state.")
FnTitle("Alerts", order = 4)
FnAlert("Mensaje informativo neutro", title = "Info", variant = FnAlertVariant.Info)
FnAlert("Accion completada con exito", title = "Success", variant = FnAlertVariant.Success)
FnAlert("Algo requiere atencion", title = "Warning", variant = FnAlertVariant.Warning)
FnAlert("Operacion fallida", title = "Error", variant = FnAlertVariant.Error)
FnTitle("Loaders", order = 4)
FnGroup {
FnLoader(size = fn.compose.ui.FnLoaderSize.Sm)
FnLoader(size = fn.compose.ui.FnLoaderSize.Md)
FnLoader(size = fn.compose.ui.FnLoaderSize.Lg)
}
FnTitle("Skeleton placeholders", order = 4)
FnCard(modifier = Modifier.fillMaxWidth()) {
FnStack(gap = FnSpacing.sm) {
FnSkeleton(height = 20.dp)
FnSkeleton(height = 16.dp, modifier = Modifier.fillMaxWidth(0.7f))
FnSkeleton(height = 16.dp)
}
}
FnTitle("Empty state", order = 4)
FnCard(modifier = Modifier.fillMaxWidth(), variant = FnCardVariant.Borderless) {
FnEmptyState(
title = "Sin resultados",
description = "Prueba a cambiar los filtros.",
icon = "🔍",
action = { FnButton("Limpiar filtros", onClick = {}, variant = FnButtonVariant.Outlined) },
)
}
}
@Composable
private fun LayoutCategory() {
FnPageHeader("Layout primitives", subtitle = "Stack, Group, Paper, Card.")
FnTitle("FnStack (column)", order = 4)
FnCard(modifier = Modifier.fillMaxWidth()) {
FnStack(gap = FnSpacing.xs) {
FnText("Item 1", size = FnTextSize.Sm)
FnText("Item 2", size = FnTextSize.Sm)
FnText("Item 3", size = FnTextSize.Sm)
}
}
FnTitle("FnGroup (row)", order = 4)
FnCard(modifier = Modifier.fillMaxWidth()) {
FnGroup(gap = FnSpacing.sm) {
FnBadge("A")
FnBadge("B", color = FnBadgeColor.Gray)
FnBadge("C", color = FnBadgeColor.Green)
}
}
FnTitle("FnPaper", order = 4)
FnPaper(modifier = Modifier.fillMaxWidth()) {
FnText("Container minimal: radius + shadow leves.", size = FnTextSize.Sm)
}
FnTitle("FnPageHeader", order = 4)
FnPaper(modifier = Modifier.fillMaxWidth()) {
FnPageHeader(
title = "Titulo de seccion",
subtitle = "Subtitulo descriptivo",
actions = { FnButton("Action", onClick = {}, variant = FnButtonVariant.Outlined) },
)
}
}