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