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 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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.Surface
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 fn.compose.theme.FnSpacing
import fn.compose.theme.FnTheme 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() { 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()) { WeatherScreen() } }
Surface(modifier = Modifier.fillMaxSize()) { }
Text("weather_local ready") }
}
// 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 package com.fnregistry.weather_local
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,77 @@ 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 render(name: String, dark: Boolean = false) {
fun screenshotFnThemeSurface() {
composeTestRule.setContent { composeTestRule.setContent {
FnTheme { FnTheme(darkMode = dark) {
Surface { Surface(Modifier.fillMaxSize()) {
Text("weather_local screenshot") 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