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:
@@ -3,22 +3,254 @@ package com.fnregistry.chat_kt
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
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.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.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.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.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() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
FnTheme {
|
FnTheme { Surface(Modifier.fillMaxSize()) { ChatScreen() } }
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
}
|
||||||
Text("chat_kt ready")
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
package com.fnregistry.chat_kt
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.Surface
|
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.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
@@ -10,24 +11,36 @@ import org.junit.Rule
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(qualifiers = "w360dp-h800dp-xhdpi")
|
||||||
class ExampleScreenshotTest {
|
class ExampleScreenshotTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeTestRule = createComposeRule()
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
@Test
|
private fun renderChat(name: String, dark: Boolean = false, draft: String = "") {
|
||||||
fun screenshotFnThemeSurface() {
|
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
FnTheme {
|
FnTheme(darkMode = dark) {
|
||||||
Surface {
|
Surface(Modifier.fillMaxSize()) {
|
||||||
Text("chat_kt screenshot")
|
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 |
Reference in New Issue
Block a user