diff --git a/app/src/main/kotlin/com/fnregistry/chat_kt/MainActivity.kt b/app/src/main/kotlin/com/fnregistry/chat_kt/MainActivity.kt index d7fb426..16cd7cf 100644 --- a/app/src/main/kotlin/com/fnregistry/chat_kt/MainActivity.kt +++ b/app/src/main/kotlin/com/fnregistry/chat_kt/MainActivity.kt @@ -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, text: String): List = + 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 = + 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, + 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, + ) + } + } +} diff --git a/app/src/test/kotlin/com/fnregistry/chat_kt/ExampleScreenshotTest.kt b/app/src/test/kotlin/com/fnregistry/chat_kt/ExampleScreenshotTest.kt index 023a07a..07c9145 100644 --- a/app/src/test/kotlin/com/fnregistry/chat_kt/ExampleScreenshotTest.kt +++ b/app/src/test/kotlin/com/fnregistry/chat_kt/ExampleScreenshotTest.kt @@ -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...") } diff --git a/app/src/test/kotlin/com/fnregistry/chat_kt/PureLogicTest.kt b/app/src/test/kotlin/com/fnregistry/chat_kt/PureLogicTest.kt new file mode 100644 index 0000000..34ddd60 --- /dev/null +++ b/app/src/test/kotlin/com/fnregistry/chat_kt/PureLogicTest.kt @@ -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. +} diff --git a/app/src/test/snapshots/images/chat_dark.png b/app/src/test/snapshots/images/chat_dark.png new file mode 100644 index 0000000..38709e6 Binary files /dev/null and b/app/src/test/snapshots/images/chat_dark.png differ diff --git a/app/src/test/snapshots/images/chat_light.png b/app/src/test/snapshots/images/chat_light.png new file mode 100644 index 0000000..299b65a Binary files /dev/null and b/app/src/test/snapshots/images/chat_light.png differ diff --git a/app/src/test/snapshots/images/chat_typing.png b/app/src/test/snapshots/images/chat_typing.png new file mode 100644 index 0000000..0786192 Binary files /dev/null and b/app/src/test/snapshots/images/chat_typing.png differ