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