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 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 |
Reference in New Issue
Block a user