diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..ca16a99
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index cdbc250..02c4aa5 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -1,5 +1,6 @@
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index a7fab1c..29a5cc0 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,7 +1,7 @@
-
+
diff --git a/app/src/main/java/com/example/prueba/MainActivity.kt b/app/src/main/java/com/example/prueba/MainActivity.kt
index 8fef14b..cb850ad 100644
--- a/app/src/main/java/com/example/prueba/MainActivity.kt
+++ b/app/src/main/java/com/example/prueba/MainActivity.kt
@@ -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(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 != 2) isLoading = true
- val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 10000)
+ if (selectedTab == 0) isLoading = true
+
+ 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 = {
- IconButton(onClick = {
- searchQuery = ""
- isSearchActive = false
- }) {
- Icon(Icons.Default.Close, contentDescription = "Cerrar")
+ Row {
+ IconButton(onClick = {
+ 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))
- } else {
- GasStationList(
- stations = stations,
- context = context,
- scope = scope,
- repository = repository,
- lastLocation = lastLocation,
- onLogClick = { showFuelDialog = it },
- onUpdateStations = { stations = it },
- searchQuery = searchQuery
- )
- }
+ if (isLoading && stations.isEmpty()) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ } else if (stations.isEmpty()) {
+ Text(if (isReserveFilterActive) "No hay nada a menos de 40km" else "Sin resultados", modifier = Modifier.align(Alignment.Center))
} 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,
- context: android.content.Context,
- scope: kotlinx.coroutines.CoroutineScope,
- repository: GasStationRepository,
- lastLocation: android.location.Location?,
- onLogClick: (GasStationInfo) -> Unit,
- onUpdateStations: (List) -> Unit,
- searchQuery: String
-) {
+fun GasStationList(stations: List, 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, 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, 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, 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
- ) {}
+ 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.name} - ${station.municipality}",
- style = MaterialTheme.typography.titleMedium,
- maxLines = 1,
- modifier = Modifier.weight(1f)
+ text = "${String.format(Locale.getDefault(), "%.1f", station.distance / 1000)} km",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
)
- 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
+ station.durationMinutes?.let { mins ->
+ Text(
+ text = "$mins min",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.secondary
)
}
}
- 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),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSecondaryContainer
- )
}
}
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")
}
diff --git a/app/src/main/java/com/example/prueba/MainScreen.kt b/app/src/main/java/com/example/prueba/MainScreen.kt
index 06bb3b0..ded714d 100644
--- a/app/src/main/java/com/example/prueba/MainScreen.kt
+++ b/app/src/main/java/com/example/prueba/MainScreen.kt
@@ -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 = 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)
- }
+ currentAddress = if (street != null && number != null) "$street, $number" else street ?: address.getAddressLine(0)
}
- } catch (e: Exception) {
- // Silencioso
- }
+ } 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 {
- isLoading = true
- invalidate()
- fetchLocationAndGasStations()
- }
- .build()
- )
+ 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()
}
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("A: ${station.price} €/L" + (station.pricePremium?.let { " • P: $it €/L" } ?: ""))
+ .addText("$distanceStr$durationStr • ${station.address}")
.setOnClickListener {
val paneBuilder = Pane.Builder()
.addRow(Row.Builder().setTitle("Diésel A").addText("${station.price} €/L").build())
@@ -162,58 +146,32 @@ 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 {
- 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()
- )
+ .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}")
+ carContext.startCarApp(Intent(CarContext.ACTION_NAVIGATE, uri))
+ }.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()
- }
- }
- )
+ 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()
diff --git a/app/src/main/java/com/example/prueba/data/GasStationRepository.kt b/app/src/main/java/com/example/prueba/data/GasStationRepository.kt
index 239be03..983cae7 100644
--- a/app/src/main/java/com/example/prueba/data/GasStationRepository.kt
+++ b/app/src/main/java/com/example/prueba/data/GasStationRepository.kt
@@ -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 {
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,45 +47,77 @@ class GasStationRepository {
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)
- )
+ 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,
+ address = it.direccion,
+ municipality = it.municipio,
+ price = dieselPrice ?: 0.0,
+ pricePremium = premiumPrice,
+ distance = distanceToUser,
+ latitude = lat,
+ 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)
- }
-
- if (filteredStations.isEmpty()) return emptyList()
-
- val minPrice = filteredStations.minOf { it.price }
- val avgPrice = filteredStations.map { it.price }.average()
+ // Radio máximo: 30km si es local, 400km si hay destino (ruta larga)
+ val finalMaxDist = maxDistanceKm ?: if (destinationLocation == null) 30.0 else 400.0
- filteredStations.map { station ->
+ val filtered = allStations.filter { (it.distance / 1000.0) <= finalMaxDist }
+
+ if (filtered.isEmpty()) return emptyList()
+
+ 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 { 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
)
diff --git a/app/src/main/java/com/example/prueba/data/OsrmApi.kt b/app/src/main/java/com/example/prueba/data/OsrmApi.kt
new file mode 100644
index 0000000..8ae1b7b
--- /dev/null
+++ b/app/src/main/java/com/example/prueba/data/OsrmApi.kt
@@ -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
+)
+
+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
+}
diff --git a/app/src/main/java/com/example/prueba/utils/FavoriteStorage.kt b/app/src/main/java/com/example/prueba/utils/FavoriteStorage.kt
deleted file mode 100644
index 201ec55..0000000
--- a/app/src/main/java/com/example/prueba/utils/FavoriteStorage.kt
+++ /dev/null
@@ -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 {
- 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)
- }
-}