diff --git a/app/src/main/kotlin/com/fnregistry/weather_local/MainActivity.kt b/app/src/main/kotlin/com/fnregistry/weather_local/MainActivity.kt index a0ab267..4292b06 100644 --- a/app/src/main/kotlin/com/fnregistry/weather_local/MainActivity.kt +++ b/app/src/main/kotlin/com/fnregistry/weather_local/MainActivity.kt @@ -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, query: String): List = + 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, + 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) } } } diff --git a/app/src/test/kotlin/com/fnregistry/weather_local/ExampleScreenshotTest.kt b/app/src/test/kotlin/com/fnregistry/weather_local/ExampleScreenshotTest.kt index 1f91387..9d3dd7e 100644 --- a/app/src/test/kotlin/com/fnregistry/weather_local/ExampleScreenshotTest.kt +++ b/app/src/test/kotlin/com/fnregistry/weather_local/ExampleScreenshotTest.kt @@ -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") } } diff --git a/app/src/test/kotlin/com/fnregistry/weather_local/PureLogicTest.kt b/app/src/test/kotlin/com/fnregistry/weather_local/PureLogicTest.kt new file mode 100644 index 0000000..9c31ccc --- /dev/null +++ b/app/src/test/kotlin/com/fnregistry/weather_local/PureLogicTest.kt @@ -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")) + } +} diff --git a/app/src/test/snapshots/images/weather_dark.png b/app/src/test/snapshots/images/weather_dark.png new file mode 100644 index 0000000..7c3abbc Binary files /dev/null and b/app/src/test/snapshots/images/weather_dark.png differ diff --git a/app/src/test/snapshots/images/weather_empty.png b/app/src/test/snapshots/images/weather_empty.png new file mode 100644 index 0000000..d9c17dc Binary files /dev/null and b/app/src/test/snapshots/images/weather_empty.png differ diff --git a/app/src/test/snapshots/images/weather_light.png b/app/src/test/snapshots/images/weather_light.png new file mode 100644 index 0000000..c851f1e Binary files /dev/null and b/app/src/test/snapshots/images/weather_light.png differ diff --git a/app/src/test/snapshots/images/weather_loading.png b/app/src/test/snapshots/images/weather_loading.png new file mode 100644 index 0000000..1b8a35c Binary files /dev/null and b/app/src/test/snapshots/images/weather_loading.png differ