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 55aa1d5b82
commit 594f7dee62
7 changed files with 275 additions and 11 deletions
@@ -3,20 +3,175 @@ package com.fnregistry.weather_local
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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 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.FnBadge
import fn.compose.ui.FnBadgeColor
import fn.compose.ui.FnCard
import fn.compose.ui.FnGroup
import fn.compose.ui.FnLoader
import fn.compose.ui.FnPaper
import fn.compose.ui.FnStack
import fn.compose.ui.FnText
import fn.compose.ui.FnTextInput
import fn.compose.ui.FnTextSize
import fn.compose.ui.FnTitle
data class WeatherSnapshot(
val location: String,
val tempC: Int,
val description: String,
val icon: String,
)
data class Poi(
val name: String,
val category: String,
val distanceM: Int,
val icon: String,
)
private val MOCK_WEATHER = WeatherSnapshot(
location = "Madrid, ES",
tempC = 22,
description = "Parcialmente nublado",
icon = "☁️",
)
private val MOCK_POIS = listOf(
Poi("Restaurante La Plaza", "restaurant", 320, "🍽️"),
Poi("Museo Reina Sofia", "museum", 720, "🏛️"),
Poi("Cafeteria Central", "cafe", 1150, ""),
Poi("Parque del Retiro", "park", 1640, "🌳"),
Poi("Estacion Atocha", "transit", 2100, "🚆"),
Poi("Mercado San Miguel", "market", 780, "🛒"),
)
// Pure: input → output, no side effects. Internal for test access.
internal fun categoryColor(c: String): FnBadgeColor = when (c) {
"restaurant" -> FnBadgeColor.Red
"museum" -> FnBadgeColor.Brand
"cafe" -> FnBadgeColor.Yellow
"park" -> FnBadgeColor.Green
"transit" -> FnBadgeColor.Blue
else -> FnBadgeColor.Gray
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
FnTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Text("weather_local ready")
FnTheme { Surface(Modifier.fillMaxSize()) { WeatherScreen() } }
}
}
}
// Pure helpers: state → state. No I/O, no side effects.
internal fun filterPois(pois: List<Poi>, query: String): List<Poi> =
if (query.isBlank()) pois
else pois.filter { it.name.contains(query, ignoreCase = true) || it.category.contains(query, ignoreCase = true) }
@Composable
fun WeatherScreen() {
var query by remember { mutableStateOf("") }
val pois = filterPois(MOCK_POIS, query)
WeatherContent(
query = query,
onQueryChange = { query = it },
weather = MOCK_WEATHER,
pois = pois,
loading = false,
)
}
@Composable
fun WeatherContent(
query: String,
onQueryChange: (String) -> Unit,
weather: WeatherSnapshot,
pois: List<Poi>,
loading: Boolean,
) {
FnAppShell(title = "Tiempo Local") { padding ->
FnStack(
modifier = Modifier
.padding(padding)
.padding(FnSpacing.md)
.fillMaxSize(),
gap = FnSpacing.md,
) {
FnTextInput(
value = query,
onValueChange = onQueryChange,
label = "Buscar ubicacion",
placeholder = "ej. Madrid",
modifier = Modifier.fillMaxWidth(),
)
FnCard(modifier = Modifier.fillMaxWidth()) {
FnStack(gap = FnSpacing.sm) {
FnGroup {
FnText(weather.icon, size = FnTextSize.Xl)
FnStack(gap = FnSpacing.xs) {
FnTitle(weather.location, order = 3)
FnText(weather.description, size = FnTextSize.Sm)
}
}
FnTitle("${weather.tempC} °C", order = 1)
}
}
FnGroup {
FnTitle("POIs cercanos", order = 4)
FnBadge(pois.size.toString(), color = FnBadgeColor.Brand)
}
when {
loading -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
FnLoader()
}
pois.isEmpty() -> FnAlert(
"Sin resultados para esta ubicacion.",
variant = FnAlertVariant.Info,
)
else -> LazyColumn(
verticalArrangement = Arrangement.spacedBy(FnSpacing.sm),
) {
items(pois) { PoiRow(it) }
}
}
}
}
}
@Composable
fun PoiRow(poi: Poi) {
FnPaper(modifier = Modifier.fillMaxWidth()) {
FnGroup {
FnText(poi.icon, size = FnTextSize.Lg)
FnStack(gap = FnSpacing.xs, modifier = Modifier.padding(start = FnSpacing.xs)) {
FnText(poi.name, size = FnTextSize.Md)
FnGroup(gap = FnSpacing.xs) {
FnBadge(poi.category, color = categoryColor(poi.category))
FnText("${poi.distanceM} m", size = FnTextSize.Xs)
}
}
}
@@ -1,7 +1,8 @@
package com.fnregistry.weather_local
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,77 @@ 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 render(name: String, dark: Boolean = false) {
composeTestRule.setContent {
FnTheme {
Surface {
Text("weather_local screenshot")
FnTheme(darkMode = dark) {
Surface(Modifier.fillMaxSize()) {
WeatherContent(
query = "",
onQueryChange = {},
weather = WeatherSnapshot("Madrid, ES", 22, "Parcialmente nublado", "☁️"),
pois = listOf(
Poi("Restaurante La Plaza", "restaurant", 320, "🍽️"),
Poi("Museo Reina Sofia", "museum", 720, "🏛️"),
Poi("Cafeteria Central", "cafe", 1150, ""),
Poi("Parque del Retiro", "park", 1640, "🌳"),
Poi("Estacion Atocha", "transit", 2100, "🚆"),
Poi("Mercado San Miguel", "market", 780, "🛒"),
),
loading = false,
)
}
}
}
composeTestRule.onRoot().captureRoboImage("src/test/snapshots/images/weather_local_smoke.png")
composeTestRule.onRoot().captureRoboImage("src/test/snapshots/images/weather_$name.png")
}
@Test fun snapshotLight() = render("light", dark = false)
@Test fun snapshotDark() = render("dark", dark = true)
@Test
fun snapshotEmpty() {
composeTestRule.setContent {
FnTheme {
Surface(Modifier.fillMaxSize()) {
WeatherContent(
query = "Loctown sin POIs",
onQueryChange = {},
weather = WeatherSnapshot("Loctown", 14, "Lluvia ligera", "🌧️"),
pois = emptyList(),
loading = false,
)
}
}
}
composeTestRule.onRoot().captureRoboImage("src/test/snapshots/images/weather_empty.png")
}
@Test
fun snapshotLoading() {
composeTestRule.setContent {
FnTheme {
Surface(Modifier.fillMaxSize()) {
WeatherContent(
query = "Cargando...",
onQueryChange = {},
weather = WeatherSnapshot("Madrid, ES", 22, "Parcialmente nublado", "☁️"),
pois = emptyList(),
loading = true,
)
}
}
}
composeTestRule.onRoot().captureRoboImage("src/test/snapshots/images/weather_loading.png")
}
}
@@ -0,0 +1,55 @@
package com.fnregistry.weather_local
import fn.compose.ui.FnBadgeColor
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Test
class PureLogicTest {
private val pois = listOf(
Poi("La Plaza", "restaurant", 100, "🍽️"),
Poi("Reina Sofia", "museum", 700, "🏛️"),
Poi("Cafe Central", "cafe", 1100, ""),
)
@Test
fun filterEmptyReturnsAll() {
assertEquals(pois, filterPois(pois, ""))
assertEquals(pois, filterPois(pois, " "))
}
@Test
fun filterMatchesNameAndCategory() {
assertEquals(1, filterPois(pois, "plaza").size)
assertEquals(1, filterPois(pois, "museum").size)
assertEquals(0, filterPois(pois, "noexiste").size)
}
@Test
fun filterReturnsNewList() {
val out = filterPois(pois, "")
// Same content, but original list is not mutated regardless.
assertEquals(3, pois.size)
assertEquals(3, out.size)
// Filtering with non-empty should produce a subset reference different from input:
val filtered = filterPois(pois, "cafe")
assertNotSame(pois, filtered)
}
@Test
fun categoryColorIsTotal() {
assertEquals(FnBadgeColor.Red, categoryColor("restaurant"))
assertEquals(FnBadgeColor.Brand, categoryColor("museum"))
assertEquals(FnBadgeColor.Yellow, categoryColor("cafe"))
assertEquals(FnBadgeColor.Green, categoryColor("park"))
assertEquals(FnBadgeColor.Blue, categoryColor("transit"))
assertEquals(FnBadgeColor.Gray, categoryColor("unknown"))
assertEquals(FnBadgeColor.Gray, categoryColor(""))
}
@Test
fun categoryColorIsDeterministic() {
assertEquals(categoryColor("museum"), categoryColor("museum"))
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB