Gasolinera app

This commit is contained in:
2026-08-10 12:42:28 +02:00
parent 3d839131bd
commit b6857bc422
4 changed files with 249 additions and 132 deletions
@@ -12,6 +12,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -23,18 +24,20 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.example.prueba.data.GasStationInfo
import com.example.prueba.data.GasStationRepository
import com.example.prueba.data.PriceCategory
import com.example.prueba.ui.theme.PruebaTheme
import com.example.prueba.utils.FavoriteStorage
import com.example.prueba.utils.FuelRecord
import com.example.prueba.utils.FuelStorage
import com.example.prueba.utils.LocationStorage
import com.google.android.gms.location.*
import com.google.android.gms.tasks.CancellationTokenSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -68,6 +71,8 @@ fun GasStationApp(repository: GasStationRepository) {
var isLoading by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
var selectedTab by remember { mutableIntStateOf(0) }
var searchQuery by remember { mutableStateOf("") }
var isSearchActive by remember { mutableStateOf(false) }
var hasPermission by remember {
mutableStateOf(
@@ -104,17 +109,21 @@ fun GasStationApp(repository: GasStationRepository) {
}
}
LaunchedEffect(hasPermission) {
LaunchedEffect(hasPermission, searchQuery, selectedTab) {
if (hasPermission) {
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
LocationStorage.getLastLocation(context)?.let { lastLoc ->
scope.launch {
updateAddress(lastLoc)
stations = repository.getCheapestAndNearest(context, lastLoc)
}
val loc = lastLocation ?: LocationStorage.getLastLocation(context)
loc?.let {
stations = repository.getCheapestAndNearest(
context = context,
userLocation = it,
municipalityQuery = if (selectedTab == 0) searchQuery.ifBlank { null } else null,
onlyFavorites = (selectedTab == 1)
)
}
isLoading = true
if (selectedTab != 2) isLoading = true
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 10000)
.setMinUpdateDistanceMeters(500f)
.build()
@@ -124,9 +133,13 @@ fun GasStationApp(repository: GasStationRepository) {
val location = result.lastLocation ?: return
lastLocation = location
LocationStorage.saveLocation(context, location)
scope.launch {
updateAddress(location)
stations = repository.getCheapestAndNearest(context, location)
if (searchQuery.isBlank()) {
scope.launch {
updateAddress(location)
stations = repository.getCheapestAndNearest(context, location)
isLoading = false
}
} else {
isLoading = false
}
}
@@ -145,25 +158,56 @@ fun GasStationApp(repository: GasStationRepository) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
CenterAlignedTopAppBar(
title = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Gasolineras Baratas", style = MaterialTheme.typography.titleSmall)
Text(
text = currentAddress,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1
)
Column {
CenterAlignedTopAppBar(
title = {
if (isSearchActive) {
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text("Buscar municipio...") },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent
),
trailingIcon = {
IconButton(onClick = {
searchQuery = ""
isSearchActive = false
}) {
Icon(Icons.Default.Close, contentDescription = "Cerrar")
}
}
)
} else {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Gasolineras Diésel", style = MaterialTheme.typography.titleSmall)
Text(
text = currentAddress,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1
)
}
}
},
actions = {
if (!isSearchActive && selectedTab != 2) {
IconButton(onClick = { isSearchActive = true }) {
Icon(Icons.Default.Search, contentDescription = "Buscar")
}
}
}
}
)
)
}
},
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Place, "Gasolineras") },
label = { Text("Gasolineras") },
label = { Text("Diésel") },
selected = selectedTab == 0,
onClick = { selectedTab = 0 }
)
@@ -203,7 +247,7 @@ fun GasStationApp(repository: GasStationRepository) {
lastLocation?.let { loc ->
isRefreshing = true
scope.launch {
stations = repository.getCheapestAndNearest(context, loc, forceRefresh = true)
stations = repository.getCheapestAndNearest(context, loc, forceRefresh = true, municipalityQuery = if(searchQuery.isNotBlank()) searchQuery else null)
isRefreshing = false
}
}
@@ -223,22 +267,23 @@ fun GasStationApp(repository: GasStationRepository) {
repository = repository,
lastLocation = lastLocation,
onLogClick = { showFuelDialog = it },
onUpdateStations = { stations = it }
onUpdateStations = { stations = it },
searchQuery = searchQuery
)
}
} else {
val favoriteStations = stations.filter { it.isFavorite }
if (favoriteStations.isEmpty()) {
if (stations.isEmpty()) {
Text("No tienes gasolineras favoritas marcadas", modifier = Modifier.align(Alignment.Center))
} else {
GasStationList(
stations = favoriteStations,
stations = stations,
context = context,
scope = scope,
repository = repository,
lastLocation = lastLocation,
onLogClick = { showFuelDialog = it },
onUpdateStations = { stations = it }
onUpdateStations = { stations = it },
searchQuery = ""
)
}
}
@@ -262,7 +307,6 @@ fun GasStationApp(repository: GasStationRepository) {
showFuelDialog?.let { station ->
FuelLogDialog(
stationName = station.name,
pricePerLiter = station.price,
onDismiss = { showFuelDialog = null },
onConfirm = { liters, total ->
val record = FuelRecord(
@@ -284,7 +328,6 @@ fun GasStationApp(repository: GasStationRepository) {
editRecordDialog?.let { record ->
FuelLogDialog(
stationName = record.stationName,
pricePerLiter = record.pricePerLiter,
initialLiters = record.liters.toString(),
initialTotal = record.totalPrice.toString(),
onDismiss = { editRecordDialog = null },
@@ -311,7 +354,8 @@ fun GasStationList(
repository: GasStationRepository,
lastLocation: android.location.Location?,
onLogClick: (GasStationInfo) -> Unit,
onUpdateStations: (List<GasStationInfo>) -> Unit
onUpdateStations: (List<GasStationInfo>) -> Unit,
searchQuery: String
) {
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(stations) { station ->
@@ -325,9 +369,10 @@ fun GasStationList(
onLogClick = { onLogClick(station) },
onFavoriteClick = {
FavoriteStorage.toggleFavorite(context, station.id)
lastLocation?.let { loc ->
val loc = lastLocation ?: LocationStorage.getLastLocation(context)
loc?.let {
scope.launch {
onUpdateStations(repository.getCheapestAndNearest(context, loc))
onUpdateStations(repository.getCheapestAndNearest(context, it, municipalityQuery = if(searchQuery.isNotBlank()) searchQuery else null))
}
}
}
@@ -386,7 +431,6 @@ fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit,
@Composable
fun FuelLogDialog(
stationName: String,
pricePerLiter: Double,
initialLiters: String = "",
initialTotal: String = "",
onDismiss: () -> Unit,
@@ -431,6 +475,21 @@ fun FuelLogDialog(
@Composable
fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: () -> Unit, onFavoriteClick: () -> Unit) {
val priceColor = when (station.priceCategory) {
PriceCategory.CHEAP -> Color(0xFF2E7D32)
PriceCategory.NORMAL -> MaterialTheme.colorScheme.primary
PriceCategory.EXPENSIVE -> Color(0xFFC62828)
}
val brandColor = when {
station.name.contains("REPSOL", ignoreCase = true) -> Color(0xFFE30613)
station.name.contains("CEPSA", ignoreCase = true) -> Color(0xFFEC0000)
station.name.contains("BP", ignoreCase = true) -> Color(0xFF00A94F)
station.name.contains("GALP", ignoreCase = true) -> Color(0xFFFF6B00)
station.name.contains("SHELL", ignoreCase = true) -> Color(0xFFFFD500)
else -> MaterialTheme.colorScheme.surfaceVariant
}
Card(
modifier = Modifier
.fillMaxWidth()
@@ -446,6 +505,11 @@ fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: ()
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
color = brandColor,
modifier = Modifier.size(12.dp).padding(end = 8.dp),
shape = androidx.compose.foundation.shape.CircleShape
) {}
Text(
text = "${station.name} - ${station.municipality}",
style = MaterialTheme.typography.titleMedium,
@@ -471,9 +535,9 @@ fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: ()
shape = MaterialTheme.shapes.medium
) {
Text(
text = "${String.format(Locale.getDefault(), "%.1f", station.distance / 1000)} km",
text = "${String.format(Locale.getDefault(), "%.1f", station.distance / 1000)} km (recta)",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelLarge,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
@@ -482,14 +546,24 @@ fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: ()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.Bottom
) {
Text(
text = "${station.price} €/L",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
Column {
Text(
text = "Diésel A: ${station.price} €/L",
style = MaterialTheme.typography.titleLarge,
color = priceColor,
fontWeight = FontWeight.Bold
)
station.pricePremium?.let { premium ->
Text(
text = "Premium: $premium €/L",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
fontWeight = FontWeight.Medium
)
}
}
Button(
onClick = onLogClick,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
@@ -147,68 +147,74 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
for (station in gasStations) {
val distanceStr = String.format(Locale.getDefault(), "%.1f km", station.distance / 1000)
listBuilder.addItem(
Row.Builder()
.setTitle("${station.name} - ${station.municipality}")
.addText("${station.price} €/L$distanceStr")
.addText(station.address)
.setOnClickListener {
val pane = Pane.Builder()
.addRow(Row.Builder().setTitle("Precio").addText("${station.price} €/L").build())
.addRow(Row.Builder().setTitle("Distancia").addText(distanceStr).build())
.addAction(
Action.Builder()
.setTitle(if (station.isFavorite) "Quitar Favorito" else "Añadir Favorito")
.setOnClickListener {
FavoriteStorage.toggleFavorite(carContext, station.id)
screenManager.pop()
fetchLocationAndGasStations(forceRefresh = false)
}
.build()
)
.addAction(
Action.Builder()
.setTitle("Ir ahora")
.setOnClickListener {
val uri = Uri.parse("geo:${station.latitude},${station.longitude}?q=${station.latitude},${station.longitude}")
val intent = Intent(CarContext.ACTION_NAVIGATE, uri)
carContext.startCarApp(intent)
}
.build()
)
.addAction(
Action.Builder()
.setTitle("Registrar Repostaje")
.setOnClickListener {
// Guardar record simplificado desde el coche
val record = FuelRecord(
date = System.currentTimeMillis(),
stationName = station.name,
address = station.address,
municipality = station.municipality,
liters = 0.0, // Se editará en el móvil después
totalPrice = 0.0,
pricePerLiter = station.price
)
FuelStorage.saveRecord(carContext, record)
screenManager.pop()
}
.build()
)
.build()
screenManager.push(
object : Screen(carContext) {
override fun onGetTemplate(): Template {
return PaneTemplate.Builder(pane)
.setHeader(Header.Builder().setTitle(station.name).setStartHeaderAction(Action.BACK).build())
.build()
}
}
)
val rowBuilder = Row.Builder()
.setTitle("${station.name} - ${station.municipality}")
.addText("A: ${station.price} €/L" + (station.pricePremium?.let { " • P: $it €/L" } ?: ""))
.addText("$distanceStr (recta) • ${station.address}")
.setOnClickListener {
val paneBuilder = Pane.Builder()
.addRow(Row.Builder().setTitle("Diésel A").addText("${station.price} €/L").build())
station.pricePremium?.let {
paneBuilder.addRow(Row.Builder().setTitle("Diésel Premium").addText("$it €/L").build())
}
.build()
)
val pane = paneBuilder
.addRow(Row.Builder().setTitle("Ubicación").addText("${station.municipality}\n${station.address}").build())
.addRow(Row.Builder().setTitle("Distancia").addText("$distanceStr (en línea recta)").build())
.addAction(
Action.Builder()
.setTitle(if (station.isFavorite) "Quitar Favorito" else "Añadir Favorito")
.setOnClickListener {
FavoriteStorage.toggleFavorite(carContext, station.id)
screenManager.pop()
fetchLocationAndGasStations(forceRefresh = false)
}
.build()
)
.addAction(
Action.Builder()
.setTitle("Ir ahora")
.setOnClickListener {
val uri = Uri.parse("geo:${station.latitude},${station.longitude}?q=${station.latitude},${station.longitude}")
val intent = Intent(CarContext.ACTION_NAVIGATE, uri)
carContext.startCarApp(intent)
}
.build()
)
.addAction(
Action.Builder()
.setTitle("Registrar")
.setOnClickListener {
val record = FuelRecord(
date = System.currentTimeMillis(),
stationName = station.name,
address = station.address,
municipality = station.municipality,
liters = 0.0,
totalPrice = 0.0,
pricePerLiter = station.price
)
FuelStorage.saveRecord(carContext, record)
screenManager.pop()
}
.build()
)
.build()
screenManager.push(
object : Screen(carContext) {
override fun onGetTemplate(): Template {
return PaneTemplate.Builder(pane)
.setHeader(Header.Builder().setTitle(station.name).setStartHeaderAction(Action.BACK).build())
.build()
}
}
)
}
listBuilder.addItem(rowBuilder.build())
}
return ListTemplate.Builder()
@@ -15,7 +15,13 @@ class GasStationRepository {
.build()
.create(GasolineraApi::class.java)
suspend fun getCheapestAndNearest(context: Context, userLocation: Location, forceRefresh: Boolean = false): List<GasStationInfo> {
suspend fun getCheapestAndNearest(
context: Context,
userLocation: Location,
forceRefresh: Boolean = false,
municipalityQuery: String? = null,
onlyFavorites: Boolean = false
): List<GasStationInfo> {
return try {
if (cachedResponse == null || forceRefresh) {
cachedResponse = api.getGasolineras()
@@ -23,47 +29,76 @@ class GasStationRepository {
val favorites = FavoriteStorage.getFavoriteIds(context)
cachedResponse?.listaEESS?.mapNotNull {
val allStations = cachedResponse?.listaEESS?.mapNotNull {
val lat = it.latitud.replace(",", ".").toDoubleOrNull()
val lon = it.longitud.replace(",", ".").toDoubleOrNull()
val price = it.precioDiesel.replace(",", ".").toDoubleOrNull() ?: it.precioGasolina95.replace(",", ".").toDoubleOrNull()
if (lat != null && lon != null && price != null) {
val stationLoc = Location("").apply {
latitude = lat
longitude = lon
}
val distance = userLocation.distanceTo(stationLoc)
GasStationInfo(
id = it.id,
name = it.rotulo,
address = it.direccion,
municipality = it.municipio,
price = price,
distance = distance,
latitude = lat,
longitude = lon,
isFavorite = favorites.contains(it.id)
)
if (lat != null && lon != null) {
val dieselPrice = it.precioDiesel.replace(",", ".").toDoubleOrNull()
val premiumPrice = it.precioDieselPremium.replace(",", ".").toDoubleOrNull()
if (dieselPrice != null || premiumPrice != null) {
val stationLoc = Location("").apply {
latitude = lat
longitude = lon
}
val distance = userLocation.distanceTo(stationLoc)
GasStationInfo(
id = it.id,
name = it.rotulo,
address = it.direccion,
municipality = it.municipio,
price = dieselPrice ?: 0.0,
pricePremium = premiumPrice,
distance = distance,
latitude = lat,
longitude = lon,
isFavorite = favorites.contains(it.id)
)
} else null
} else null
}?.sortedBy { it.distance }
?.take(50)
?.sortedWith(compareByDescending<GasStationInfo> { it.isFavorite }.thenBy { it.price })
?.take(10) ?: emptyList()
} ?: emptyList()
val filteredStations = when {
onlyFavorites -> allStations.filter { it.isFavorite }
.sortedBy { it.distance }
!municipalityQuery.isNullOrBlank() -> allStations.filter { it.municipality.contains(municipalityQuery, ignoreCase = true) }
.sortedBy { it.distance }
else -> allStations.sortedBy { it.distance }.take(50)
}
if (filteredStations.isEmpty()) return emptyList()
val minPrice = filteredStations.minOf { it.price }
val avgPrice = filteredStations.map { it.price }.average()
filteredStations.map { station ->
val category = when {
station.price <= minPrice * 1.01 -> PriceCategory.CHEAP
station.price <= avgPrice -> PriceCategory.NORMAL
else -> PriceCategory.EXPENSIVE
}
station.copy(priceCategory = category)
}.sortedWith(compareByDescending<GasStationInfo> { it.isFavorite }.thenBy { it.price })
.take(if (onlyFavorites) 50 else 15)
} catch (e: Exception) {
emptyList()
}
}
}
enum class PriceCategory { CHEAP, NORMAL, EXPENSIVE }
data class GasStationInfo(
val id: String,
val name: String,
val address: String,
val municipality: String,
val price: Double,
val pricePremium: Double? = null,
val distance: Float,
val latitude: Double,
val longitude: Double,
val isFavorite: Boolean = false
val isFavorite: Boolean = false,
val priceCategory: PriceCategory = PriceCategory.NORMAL
)
@@ -19,6 +19,8 @@ data class Gasolinera(
val municipio: String,
@SerializedName("Precio Gasoleo A")
val precioDiesel: String,
@SerializedName("Precio Gasoleo Premium")
val precioDieselPremium: String,
@SerializedName("Precio Gasolina 95 E5")
val precioGasolina95: String,
@SerializedName("Latitud")