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 dab1475cd4
commit be36c3236c
6 changed files with 297 additions and 11 deletions
@@ -3,22 +3,254 @@ package com.fnregistry.chat_kt
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.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.text.style.TextAlign
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.FnTheme
import fn.compose.theme.FnTypography
import fn.compose.ui.FnAppShell
import fn.compose.ui.FnButton
import fn.compose.ui.FnButtonVariant
import fn.compose.ui.FnGroup
import fn.compose.ui.FnText
import fn.compose.ui.FnTextInput
import fn.compose.ui.FnTextSize
data class Message(
val author: String,
val text: String,
val time: String,
val mine: Boolean,
)
internal val MOCK_MESSAGES = listOf(
Message("Ana", "Hola!! ¿Como vas con el proyecto?", "10:14", false),
Message("Yo", "Casi terminado, ahora mismo enrollando los tests instrumented", "10:15", true),
Message("Ana", "Buah que envidia, yo aun peleando con el manifest", "10:15", false),
Message("Yo", "Mandame el error si quieres", "10:16", true),
Message("Ana", "Theme.AppCompat not found 😭", "10:16", false),
Message("Yo", "JAJA literal el mismo bug que tuve. Cambia a @android:style/Theme.Material.Light.NoActionBar", "10:17", true),
Message("Ana", "Probando…", "10:18", false),
Message("Ana", "FUNCIONA!! Eres un crack", "10:20", false),
Message("Yo", "🎉", "10:20", true),
Message("Ana", "Cuando tengas tiempo te paso captura del UI", "10:21", false),
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
FnTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Text("chat_kt ready")
}
FnTheme { Surface(Modifier.fillMaxSize()) { ChatScreen() } }
}
}
}
// Pure transformations. Take state, return new state. No mutation, no I/O.
// Internal so tests can validate them directly.
internal fun appendMyMessage(messages: List<Message>, text: String): List<Message> =
if (text.isBlank()) messages
else messages + Message(author = "Yo", text = text, time = "ahora", mine = true)
// Theme-aware: outgoing uses brand (primary), incoming uses surface from MaterialTheme.
// Both adapt to light/dark via colorScheme.
@Composable
internal fun bubbleStyle(mine: Boolean): Triple<Color, Color, Alignment.Horizontal> =
if (mine) Triple(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.onPrimary,
Alignment.End,
)
else Triple(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.onSurface,
Alignment.Start,
)
@Composable
fun ChatScreen() {
var draft by remember { mutableStateOf("") }
var msgs by remember { mutableStateOf(MOCK_MESSAGES) }
ChatContent(
messages = msgs,
contactName = "Ana Martinez",
contactStatus = "online",
draft = draft,
onDraftChange = { draft = it },
onSend = {
msgs = appendMyMessage(msgs, draft)
draft = ""
},
)
}
@Composable
fun ChatContent(
messages: List<Message>,
contactName: String,
contactStatus: String,
draft: String,
onDraftChange: (String) -> Unit,
onSend: () -> Unit,
) {
FnAppShell(
topBar = { ChatHeader(contactName, contactStatus) },
bottomBar = { ChatInputBar(draft, onDraftChange, onSend) },
) { padding ->
Box(
Modifier
.padding(padding)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = FnSpacing.sm, vertical = FnSpacing.sm),
verticalArrangement = Arrangement.spacedBy(FnSpacing.xs),
) {
items(messages) { MessageBubble(it) }
}
}
}
}
@Composable
fun ChatHeader(name: String, status: String) {
Surface(
color = MaterialTheme.colorScheme.primary,
shadowElevation = 2.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = FnSpacing.md, vertical = FnSpacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(initial = name.first().toString())
Spacer(Modifier.size(FnSpacing.sm))
Column {
Text(
name,
color = FnColors.white,
style = FnTypography.bodyLg.copy(fontWeight = FontWeight.SemiBold),
)
Text(
status,
color = FnColors.brand100,
style = FnTypography.bodyXs,
)
}
}
}
}
@Composable
fun Avatar(initial: String) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(FnColors.brand400),
contentAlignment = Alignment.Center,
) {
Text(
initial.uppercase(),
color = FnColors.white,
style = FnTypography.bodyMd.copy(fontWeight = FontWeight.Bold),
)
}
}
@Composable
fun MessageBubble(msg: Message) {
val (bg, fg, align) = bubbleStyle(msg.mine)
val shape = if (msg.mine) {
RoundedCornerShape(FnRadius.lg, FnRadius.lg, FnRadius.xs, FnRadius.lg)
} else {
RoundedCornerShape(FnRadius.lg, FnRadius.lg, FnRadius.lg, FnRadius.xs)
}
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = align,
) {
Box(
modifier = Modifier
.widthIn(max = 280.dp)
.clip(shape)
.background(bg)
.padding(horizontal = FnSpacing.sm, vertical = FnSpacing.xs),
) {
Column {
Text(msg.text, color = fg, style = FnTypography.bodyMd)
Text(
msg.time,
color = if (msg.mine) FnColors.brand100 else FnColors.gray500,
style = FnTypography.bodyXs,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
@Composable
fun ChatInputBar(draft: String, onDraftChange: (String) -> Unit, onSend: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.surface,
shadowElevation = 4.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(FnSpacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
FnTextInput(
value = draft,
onValueChange = onDraftChange,
placeholder = "Mensaje...",
singleLine = false,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.size(FnSpacing.sm))
FnButton(
text = "",
onClick = onSend,
variant = FnButtonVariant.Filled,
)
}
}
}
@@ -1,7 +1,8 @@
package com.fnregistry.chat_kt
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
@@ -10,24 +11,36 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h800dp-xhdpi")
class ExampleScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun screenshotFnThemeSurface() {
private fun renderChat(name: String, dark: Boolean = false, draft: String = "") {
composeTestRule.setContent {
FnTheme {
Surface {
Text("chat_kt screenshot")
FnTheme(darkMode = dark) {
Surface(Modifier.fillMaxSize()) {
ChatContent(
messages = MOCK_MESSAGES,
contactName = "Ana Martinez",
contactStatus = "online",
draft = draft,
onDraftChange = {},
onSend = {},
)
}
}
}
composeTestRule.onRoot().captureRoboImage("src/test/snapshots/images/chat_kt_smoke.png")
composeTestRule.onRoot().captureRoboImage("src/test/snapshots/images/chat_$name.png")
}
@Test fun snapshotLight() = renderChat("light")
@Test fun snapshotDark() = renderChat("dark", dark = true)
@Test fun snapshotTyping() = renderChat("typing", draft = "Escribiendo respuesta...")
}
@@ -0,0 +1,41 @@
package com.fnregistry.chat_kt
// Pure-function unit tests. JVM only. No Compose, no Robolectric, no I/O.
// Validates the functional core: state transformations are pure and deterministic.
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertTrue
import org.junit.Test
class PureLogicTest {
private val seed = listOf(Message("Ana", "hola", "10:00", false))
@Test
fun appendBlankReturnsSameContent() {
assertEquals(seed, appendMyMessage(seed, ""))
assertEquals(seed, appendMyMessage(seed, " "))
}
@Test
fun appendNonBlankProducesNewListNotMutated() {
val out = appendMyMessage(seed, "hey")
assertEquals(2, out.size)
assertEquals("hey", out.last().text)
assertTrue(out.last().mine)
assertEquals(1, seed.size)
assertNotSame(seed, out)
}
@Test
fun appendIsDeterministic() {
val a = appendMyMessage(seed, "x")
val b = appendMyMessage(seed, "x")
assertEquals(a.size, b.size)
assertEquals(a.last().text, b.last().text)
}
// bubbleStyle is now @Composable (theme-aware via MaterialTheme). Visual
// correctness validated via Roborazzi screenshot tests instead.
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB