Gasolinera app

This commit is contained in:
2026-08-10 13:42:04 +02:00
parent b6857bc422
commit 1e1fa640e7
9 changed files with 307 additions and 408 deletions
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="jbr-25" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="jbr-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
@@ -12,12 +12,12 @@ 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
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.AltRoute
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -28,12 +28,12 @@ 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.compose.ui.unit.sp
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
@@ -71,8 +71,11 @@ 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 destinationQuery by remember { mutableStateOf("") }
var isRouteActive by remember { mutableStateOf(false) }
var isReserveFilterActive by remember { mutableStateOf(false) }
var destinationLocation by remember { mutableStateOf<android.location.Location?>(null) }
var hasPermission by remember {
mutableStateOf(
@@ -98,33 +101,36 @@ fun GasStationApp(repository: GasStationRepository) {
val address = addresses[0]
val street = address.thoroughfare
val number = address.subThoroughfare
currentAddress = if (street != null && number != null) {
"$street, $number"
} else {
street ?: address.getAddressLine(0) ?: "Ubicación desconocida"
}
currentAddress = if (street != null && number != null) "$street, $number" else street ?: address.getAddressLine(0) ?: "Ubicación desconocida"
}
} catch (ignore: Exception) {}
}
}
LaunchedEffect(hasPermission, searchQuery, selectedTab) {
LaunchedEffect(hasPermission, destinationLocation, selectedTab, isReserveFilterActive) {
if (hasPermission) {
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
val loc = lastLocation ?: LocationStorage.getLastLocation(context)
loc?.let {
// Primero recta para rapidez
stations = repository.getCheapestAndNearest(
context = context,
userLocation = it,
municipalityQuery = if (selectedTab == 0) searchQuery.ifBlank { null } else null,
onlyFavorites = (selectedTab == 1)
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
destinationLocation = if (isRouteActive) destinationLocation else null,
getRealDistances = false
)
// Luego real para precisión
scope.launch {
stations = repository.getCheapestAndNearest(
userLocation = it,
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
destinationLocation = if (isRouteActive) destinationLocation else null,
getRealDistances = true
)
}
}
if (selectedTab == 0) isLoading = true
if (selectedTab != 2) isLoading = true
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 10000)
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 15000)
.setMinUpdateDistanceMeters(500f)
.build()
@@ -133,10 +139,15 @@ fun GasStationApp(repository: GasStationRepository) {
val location = result.lastLocation ?: return
lastLocation = location
LocationStorage.saveLocation(context, location)
if (searchQuery.isBlank()) {
if (selectedTab == 0) {
scope.launch {
updateAddress(location)
stations = repository.getCheapestAndNearest(context, location)
stations = repository.getCheapestAndNearest(
userLocation = location,
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
destinationLocation = if (isRouteActive) destinationLocation else null,
getRealDistances = true
)
isLoading = false
}
} else {
@@ -145,6 +156,7 @@ fun GasStationApp(repository: GasStationRepository) {
}
}
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
try {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, android.os.Looper.getMainLooper())
@@ -161,42 +173,50 @@ fun GasStationApp(repository: GasStationRepository) {
Column {
CenterAlignedTopAppBar(
title = {
if (isSearchActive) {
if (isRouteActive) {
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text("Buscar municipio...") },
value = destinationQuery,
onValueChange = { destinationQuery = it },
placeholder = { Text("¿A dónde vas?") },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent
),
trailingIcon = {
Row {
IconButton(onClick = {
searchQuery = ""
isSearchActive = false
}) {
Icon(Icons.Default.Close, contentDescription = "Cerrar")
scope.launch {
withContext(Dispatchers.IO) {
try {
val geocoder = Geocoder(context, Locale.getDefault())
val results = geocoder.getFromLocationName(destinationQuery, 1)
if (!results.isNullOrEmpty()) {
destinationLocation = android.location.Location("").apply {
latitude = results[0].latitude
longitude = results[0].longitude
}
}
} catch (e: Exception) {}
}
}
}) { Icon(Icons.Default.DirectionsCar, null) }
IconButton(onClick = {
isRouteActive = false
destinationLocation = null
destinationQuery = ""
}) { Icon(Icons.Default.Close, null) }
}
}
)
} 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
)
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")
if (!isRouteActive && selectedTab == 0) {
IconButton(onClick = { isRouteActive = true }) {
Icon(Icons.AutoMirrored.Filled.AltRoute, "En ruta")
}
}
}
@@ -206,99 +226,63 @@ fun GasStationApp(repository: GasStationRepository) {
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Place, "Gasolineras") },
label = { Text("Diésel") },
icon = { Icon(Icons.Default.LocalGasStation, null) },
label = { Text("Gasolineras") },
selected = selectedTab == 0,
onClick = { selectedTab = 0 }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Favorite, "Favoritas") },
label = { Text("Favoritas") },
icon = { Icon(Icons.Default.History, null) },
label = { Text("Historial") },
selected = selectedTab == 1,
onClick = { selectedTab = 1 }
)
NavigationBarItem(
icon = { Icon(Icons.Default.DateRange, "Historial") },
label = { Text("Historial") },
selected = selectedTab == 2,
onClick = { selectedTab = 2 }
)
}
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
if (!hasPermission) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Text("Se necesita permiso de ubicación")
Button(onClick = { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }) {
Text("Dar permiso")
}
Button(onClick = { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }) { Text("Dar permiso") }
}
} else {
when (selectedTab) {
0, 1 -> {
0 -> {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
lastLocation?.let { loc ->
isRefreshing = true
scope.launch {
stations = repository.getCheapestAndNearest(context, loc, forceRefresh = true, municipalityQuery = if(searchQuery.isNotBlank()) searchQuery else null)
stations = repository.getCheapestAndNearest(
userLocation = loc, forceRefresh = true,
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
destinationLocation = if (isRouteActive) destinationLocation else null
)
isRefreshing = false
}
}
},
modifier = Modifier.fillMaxSize()
) {
if (selectedTab == 0) {
if (isLoading && stations.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (stations.isEmpty()) {
Text("No se encontraron gasolineras", modifier = Modifier.align(Alignment.Center))
Text(if (isReserveFilterActive) "No hay nada a menos de 40km" else "Sin resultados", modifier = Modifier.align(Alignment.Center))
} else {
GasStationList(
stations = stations,
context = context,
scope = scope,
repository = repository,
lastLocation = lastLocation,
onLogClick = { showFuelDialog = it },
onUpdateStations = { stations = it },
searchQuery = searchQuery
)
}
} else {
if (stations.isEmpty()) {
Text("No tienes gasolineras favoritas marcadas", modifier = Modifier.align(Alignment.Center))
} else {
GasStationList(
stations = stations,
context = context,
scope = scope,
repository = repository,
lastLocation = lastLocation,
onLogClick = { showFuelDialog = it },
onUpdateStations = { stations = it },
searchQuery = ""
)
GasStationList(stations = stations, onLogClick = { showFuelDialog = it })
}
}
}
}
2 -> {
1 -> {
FuelHistoryScreen(
records = fuelRecords,
onDelete = { record ->
FuelStorage.deleteRecord(context, record)
fuelRecords = FuelStorage.getRecords(context)
},
onEdit = { record ->
editRecordDialog = record
}
onEdit = { record -> editRecordDialog = record }
)
}
}
@@ -332,10 +316,7 @@ fun GasStationApp(repository: GasStationRepository) {
initialTotal = record.totalPrice.toString(),
onDismiss = { editRecordDialog = null },
onConfirm = { liters, total ->
val newRecord = record.copy(
liters = liters,
totalPrice = total
)
val newRecord = record.copy(liters = liters, totalPrice = total)
FuelStorage.updateRecord(context, record, newRecord)
fuelRecords = FuelStorage.getRecords(context)
editRecordDialog = null
@@ -347,35 +328,17 @@ fun GasStationApp(repository: GasStationRepository) {
}
@Composable
fun GasStationList(
stations: List<GasStationInfo>,
context: android.content.Context,
scope: kotlinx.coroutines.CoroutineScope,
repository: GasStationRepository,
lastLocation: android.location.Location?,
onLogClick: (GasStationInfo) -> Unit,
onUpdateStations: (List<GasStationInfo>) -> Unit,
searchQuery: String
) {
fun GasStationList(stations: List<GasStationInfo>, onLogClick: (GasStationInfo) -> Unit) {
val context = LocalContext.current
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(stations) { station ->
GasStationItem(
station = station,
onClick = {
val uri = Uri.parse("geo:${station.latitude},${station.longitude}?q=${station.latitude},${station.longitude}(${Uri.encode(station.name)})")
val intent = Intent(Intent.ACTION_VIEW, uri)
context.startActivity(intent)
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
},
onLogClick = { onLogClick(station) },
onFavoriteClick = {
FavoriteStorage.toggleFavorite(context, station.id)
val loc = lastLocation ?: LocationStorage.getLastLocation(context)
loc?.let {
scope.launch {
onUpdateStations(repository.getCheapestAndNearest(context, it, municipalityQuery = if(searchQuery.isNotBlank()) searchQuery else null))
}
}
}
onLogClick = { onLogClick(station) }
)
}
}
@@ -384,16 +347,11 @@ fun GasStationList(
@Composable
fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit, onEdit: (FuelRecord) -> Unit) {
if (records.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Aún no tienes repostajes registrados")
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Sin registros") }
} else {
LazyColumn(contentPadding = PaddingValues(16.dp)) {
items(records) { record ->
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.padding(16.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) {
@@ -401,26 +359,15 @@ fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit,
Text("${record.municipality} - ${record.address}", style = MaterialTheme.typography.bodySmall)
}
Row {
IconButton(onClick = { onEdit(record) }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Default.Edit, contentDescription = "Editar", modifier = Modifier.size(18.dp))
}
IconButton(onClick = { onEdit(record) }, modifier = Modifier.size(24.dp)) { Icon(Icons.Default.Edit, null, modifier = Modifier.size(18.dp)) }
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = { onDelete(record) }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Default.Delete, contentDescription = "Eliminar", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.error)
IconButton(onClick = { onDelete(record) }, modifier = Modifier.size(24.dp)) { Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.error) }
}
}
}
Text(
android.text.format.DateFormat.format("dd/MM/yyyy", record.date).toString(),
style = MaterialTheme.typography.labelSmall
)
Text(android.text.format.DateFormat.format("dd/MM/yyyy", record.date).toString(), style = MaterialTheme.typography.labelSmall)
Spacer(modifier = Modifier.height(4.dp))
Text("${record.liters} L x ${record.pricePerLiter} €/L", style = MaterialTheme.typography.bodyMedium)
Text(
"Total: ${record.totalPrice}",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Text("Total: ${record.totalPrice}", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary)
}
}
}
@@ -429,13 +376,7 @@ fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit,
}
@Composable
fun FuelLogDialog(
stationName: String,
initialLiters: String = "",
initialTotal: String = "",
onDismiss: () -> Unit,
onConfirm: (Double, Double) -> Unit
) {
fun FuelLogDialog(stationName: String, initialLiters: String = "", initialTotal: String = "", onDismiss: () -> Unit, onConfirm: (Double, Double) -> Unit) {
var liters by remember { mutableStateOf(initialLiters) }
var total by remember { mutableStateOf(initialTotal) }
@@ -446,18 +387,8 @@ fun FuelLogDialog(
Column {
Text(stationName, style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = liters,
onValueChange = { liters = it },
label = { Text("Litros") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = total,
onValueChange = { total = it },
label = { Text("Precio Total (€)") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(value = liters, onValueChange = { liters = it }, label = { Text("Litros") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = total, onValueChange = { total = it }, label = { Text("Precio Total (€)") }, modifier = Modifier.fillMaxWidth())
}
},
confirmButton = {
@@ -467,14 +398,12 @@ fun FuelLogDialog(
if (l > 0 && t > 0) onConfirm(l, t)
}) { Text("Guardar") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancelar") }
}
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancelar") } }
)
}
@Composable
fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: () -> Unit, onFavoriteClick: () -> Unit) {
fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: () -> Unit) {
val priceColor = when (station.priceCategory) {
PriceCategory.CHEAP -> Color(0xFF2E7D32)
PriceCategory.NORMAL -> MaterialTheme.colorScheme.primary
@@ -490,86 +419,43 @@ fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: ()
else -> MaterialTheme.colorScheme.surfaceVariant
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.clickable { onClick() },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp).clickable { onClick() }, elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
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,
maxLines = 1,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onFavoriteClick) {
Icon(
imageVector = if (station.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = "Favorito",
tint = if (station.isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
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, maxLines = 1, modifier = Modifier.weight(1f))
}
Text(text = station.address, style = MaterialTheme.typography.bodySmall, maxLines = 1)
}
Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.medium) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = station.address,
style = MaterialTheme.typography.bodySmall,
maxLines = 1
)
}
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium
) {
Text(
text = "${String.format(Locale.getDefault(), "%.1f", station.distance / 1000)} km (recta)",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
text = "${String.format(Locale.getDefault(), "%.1f", station.distance / 1000)} km",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
station.durationMinutes?.let { mins ->
Text(
text = "$mins min",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.secondary
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom) {
Column {
Text(
text = "Diésel A: ${station.price} €/L",
style = MaterialTheme.typography.titleLarge,
color = priceColor,
fontWeight = FontWeight.Bold
)
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
)
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),
shape = MaterialTheme.shapes.small
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Button(onClick = onLogClick, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), shape = MaterialTheme.shapes.small) {
Icon(Icons.Default.Add, null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Registrar")
}
@@ -11,12 +11,10 @@ import androidx.car.app.model.*
import androidx.lifecycle.lifecycleScope
import com.example.prueba.data.GasStationInfo
import com.example.prueba.data.GasStationRepository
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 java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -26,6 +24,7 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
private var gasStations: List<GasStationInfo> = emptyList()
private var currentAddress: String? = null
private var isLoading = true
private var isReserveFilterActive = false
private val repository = GasStationRepository()
init {
@@ -41,16 +40,9 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
val address = addresses[0]
val street = address.thoroughfare
val number = address.subThoroughfare
currentAddress = if (street != null && number != null) {
"$street, $number"
} else {
street ?: address.getAddressLine(0)
}
}
} catch (e: Exception) {
// Silencioso
currentAddress = if (street != null && number != null) "$street, $number" else street ?: address.getAddressLine(0)
}
} catch (e: Exception) {}
}
}
@@ -58,12 +50,23 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
private fun fetchLocationAndGasStations(forceRefresh: Boolean = false) {
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(carContext)
// Cargar última ubicación guardada inmediatamente si no es un refresh forzado
if (!forceRefresh) {
LocationStorage.getLastLocation(carContext)?.let { lastLoc ->
lifecycleScope.launch {
updateAddress(lastLoc)
gasStations = repository.getCheapestAndNearest(carContext, lastLoc)
// Primero recta
gasStations = repository.getCheapestAndNearest(
userLocation = lastLoc,
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
getRealDistances = false
)
invalidate()
// Luego real
gasStations = repository.getCheapestAndNearest(
userLocation = lastLoc,
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
getRealDistances = true
)
invalidate()
}
}
@@ -79,79 +82,60 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
LocationStorage.saveLocation(carContext, location)
lifecycleScope.launch {
updateAddress(location)
gasStations = repository.getCheapestAndNearest(carContext, location, forceRefresh)
gasStations = repository.getCheapestAndNearest(
userLocation = location,
forceRefresh = forceRefresh,
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
getRealDistances = true
)
isLoading = false
invalidate()
}
// Si era un forceRefresh, después de la primera actualización volvemos a false
// para que los updates automáticos del GPS no sigan forzando red innecesariamente
if (forceRefresh) {
fusedLocationClient.removeLocationUpdates(this)
fetchLocationAndGasStations(false)
}
}
}
fusedLocationClient.requestLocationUpdates(
locationRequest,
locationCallback,
carContext.mainLooper
)
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, carContext.mainLooper)
}
override fun onGetTemplate(): Template {
val listBuilder = ItemList.Builder()
val refreshAction = Action.Builder()
.setTitle("Refrescar")
val reserveAction = Action.Builder()
.setTitle(if (isReserveFilterActive) "Todo" else "Reserva")
.setOnClickListener {
isReserveFilterActive = !isReserveFilterActive
isLoading = true
invalidate()
fetchLocationAndGasStations(forceRefresh = true)
fetchLocationAndGasStations(forceRefresh = false)
}
.build()
if (isLoading && gasStations.isEmpty()) {
return MessageTemplate.Builder("Buscando gasolineras...")
return MessageTemplate.Builder("Buscando...")
.setLoading(true)
.setHeader(
Header.Builder()
.setStartHeaderAction(Action.APP_ICON)
.setTitle("Gasolineras")
.build()
)
.setHeader(Header.Builder().setTitle("Gasolineras").build())
.build()
}
if (gasStations.isEmpty()) {
return MessageTemplate.Builder("No se encontraron gasolineras o no hay permiso de ubicación.")
.setHeader(
Header.Builder()
.setStartHeaderAction(Action.APP_ICON)
.setTitle("Gasolineras")
.addEndHeaderAction(refreshAction)
.build()
)
.addAction(
Action.Builder()
.setTitle("Reintentar")
.setOnClickListener {
return MessageTemplate.Builder("Sin resultados")
.setHeader(Header.Builder().setTitle("Gasolineras").addEndHeaderAction(reserveAction).build())
.addAction(Action.Builder().setTitle("Reintentar").setOnClickListener {
isLoading = true
invalidate()
fetchLocationAndGasStations()
}
.build()
)
}.build())
.build()
}
for (station in gasStations) {
val distanceStr = String.format(Locale.getDefault(), "%.1f km", station.distance / 1000)
val durationStr = station.durationMinutes?.let { " ($it min)" } ?: ""
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}")
.addText("$distanceStr$durationStr${station.address}")
.setOnClickListener {
val paneBuilder = Pane.Builder()
.addRow(Row.Builder().setTitle("Diésel A").addText("${station.price} €/L").build())
@@ -162,31 +146,12 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
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 {
.addRow(Row.Builder().setTitle("Distancia").addText("$distanceStr$durationStr").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 {
carContext.startCarApp(Intent(CarContext.ACTION_NAVIGATE, uri))
}.build())
.addAction(Action.Builder().setTitle("Registrar").setOnClickListener {
val record = FuelRecord(
date = System.currentTimeMillis(),
stationName = station.name,
@@ -198,22 +163,15 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
)
FuelStorage.saveRecord(carContext, record)
screenManager.pop()
}
.build()
)
}.build())
.build()
screenManager.push(
object : Screen(carContext) {
override fun onGetTemplate(): Template {
return PaneTemplate.Builder(pane)
screenManager.push(object : Screen(carContext) {
override fun onGetTemplate(): Template = PaneTemplate.Builder(pane)
.setHeader(Header.Builder().setTitle(station.name).setStartHeaderAction(Action.BACK).build())
.build()
})
}
}
)
}
listBuilder.addItem(rowBuilder.build())
}
@@ -221,9 +179,8 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
.setSingleList(listBuilder.build())
.setHeader(
Header.Builder()
.setStartHeaderAction(Action.APP_ICON)
.setTitle(currentAddress ?: "Gasolineras más baratas")
.addEndHeaderAction(refreshAction)
.setTitle(currentAddress ?: "Gasolineras Diésel")
.addEndHeaderAction(reserveAction)
.build()
)
.build()
@@ -1,10 +1,11 @@
package com.example.prueba.data
import android.content.Context
import android.location.Location
import com.example.prueba.utils.FavoriteStorage
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
class GasStationRepository {
private var cachedResponse: GasolineraResponse? = null
@@ -15,20 +16,24 @@ class GasStationRepository {
.build()
.create(GasolineraApi::class.java)
private val routeApi: OsrmApi = Retrofit.Builder()
.baseUrl("https://router.project-osrm.org/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(OsrmApi::class.java)
suspend fun getCheapestAndNearest(
context: Context,
userLocation: Location,
forceRefresh: Boolean = false,
municipalityQuery: String? = null,
onlyFavorites: Boolean = false
maxDistanceKm: Double? = null,
destinationLocation: Location? = null,
getRealDistances: Boolean = false
): List<GasStationInfo> {
return try {
if (cachedResponse == null || forceRefresh) {
cachedResponse = api.getGasolineras()
}
val favorites = FavoriteStorage.getFavoriteIds(context)
val allStations = cachedResponse?.listaEESS?.mapNotNull {
val lat = it.latitud.replace(",", ".").toDoubleOrNull()
val lon = it.longitud.replace(",", ".").toDoubleOrNull()
@@ -42,7 +47,17 @@ class GasStationRepository {
latitude = lat
longitude = lon
}
val distance = userLocation.distanceTo(stationLoc)
val distanceToUser = userLocation.distanceTo(stationLoc)
val validPrice = (dieselPrice ?: 0.0) > 0.1
var isOnRoute = true
if (destinationLocation != null) {
val distUserToDest = userLocation.distanceTo(destinationLocation)
val distStationToDest = stationLoc.distanceTo(destinationLocation)
isOnRoute = (distanceToUser + distStationToDest) <= (distUserToDest * 1.15)
}
if (isOnRoute && validPrice) {
GasStationInfo(
id = it.id,
name = it.rotulo,
@@ -50,37 +65,59 @@ class GasStationRepository {
municipality = it.municipio,
price = dieselPrice ?: 0.0,
pricePremium = premiumPrice,
distance = distance,
distance = distanceToUser,
latitude = lat,
longitude = lon,
isFavorite = favorites.contains(it.id)
longitude = lon
)
} else null
} else null
} else null
} ?: 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)
}
// Radio máximo: 30km si es local, 400km si hay destino (ruta larga)
val finalMaxDist = maxDistanceKm ?: if (destinationLocation == null) 30.0 else 400.0
if (filteredStations.isEmpty()) return emptyList()
val filtered = allStations.filter { (it.distance / 1000.0) <= finalMaxDist }
val minPrice = filteredStations.minOf { it.price }
val avgPrice = filteredStations.map { it.price }.average()
if (filtered.isEmpty()) return emptyList()
filteredStations.map { station ->
val minPrice = filtered.minOf { it.price }
val avgPrice = filtered.map { it.price }.average()
var results = filtered.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)
}.sortedBy { it.distance }
.take(if (destinationLocation != null) 30 else 15)
if (getRealDistances) {
coroutineScope {
val tasks = results.map { station ->
async {
try {
val coords = "${userLocation.longitude},${userLocation.latitude};${station.longitude},${station.latitude}"
val routeResponse = routeApi.getRoute(coords)
val route = routeResponse.routes.firstOrNull()
if (route != null) {
station.copy(
distance = route.distance.toFloat(),
durationMinutes = (route.duration / 60.0).toInt()
)
} else station
} catch (e: Exception) {
station
}
}
}
results = tasks.awaitAll().sortedBy { it.distance }
}
}
results
} catch (e: Exception) {
emptyList()
}
@@ -97,8 +134,8 @@ data class GasStationInfo(
val price: Double,
val pricePremium: Double? = null,
val distance: Float,
val durationMinutes: Int? = null,
val latitude: Double,
val longitude: Double,
val isFavorite: Boolean = false,
val priceCategory: PriceCategory = PriceCategory.NORMAL
)
@@ -0,0 +1,24 @@
package com.example.prueba.data
import com.google.gson.annotations.SerializedName
import retrofit2.http.GET
import retrofit2.http.Path
data class OsrmResponse(
@SerializedName("routes")
val routes: List<OsrmRoute>
)
data class OsrmRoute(
@SerializedName("distance")
val distance: Double, // En metros
@SerializedName("duration")
val duration: Double // En segundos
)
interface OsrmApi {
@GET("route/v1/driving/{coords}?overview=false")
suspend fun getRoute(
@Path("coords") coords: String
): OsrmResponse
}
@@ -1,30 +0,0 @@
package com.example.prueba.utils
import android.content.Context
object FavoriteStorage {
private const val PREFS_NAME = "favorite_prefs"
private const val KEY_FAVORITES = "favorite_ids"
fun toggleFavorite(context: Context, stationId: String) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val favorites = getFavoriteIds(context).toMutableSet()
if (favorites.contains(stationId)) {
favorites.remove(stationId)
} else {
favorites.add(stationId)
}
prefs.edit().putStringSet(KEY_FAVORITES, favorites).apply()
}
fun getFavoriteIds(context: Context): Set<String> {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getStringSet(KEY_FAVORITES, emptySet()) ?: emptySet()
}
fun isFavorite(context: Context, stationId: String): Boolean {
return getFavoriteIds(context).contains(stationId)
}
}