Gasolinera app

This commit is contained in:
2026-08-11 13:48:13 +02:00
parent 2b39f873b3
commit 087053c959
14 changed files with 1013 additions and 429 deletions
+1
View File
@@ -3,6 +3,7 @@
<component name="PlanningModeManager">
<option name="approvalStates">
<map>
<entry key="45c0c769-704c-4782-94b9-85d5d4831c11" value="false" />
<entry key="f9d713bb-3de6-48b7-9013-a05b442ed5a5" value="false" />
</map>
</option>
+5
View File
@@ -45,10 +45,15 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.car.app)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.play.services.location)
implementation(libs.osmdroid)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
@@ -0,0 +1,40 @@
# Propuesta de Mejoras para MainActivity.kt
Este plan detalla la refactorización de `MainActivity.kt` para seguir las mejores prácticas de desarrollo Android moderno, centrándose en la arquitectura MVVM y una mejor gestión del estado.
## User Review Required
> [!NOTE]
> La refactorización moverá casi toda la lógica de `GasStationApp` a un nuevo `GasStationViewModel`. Esto cambiará la forma en que se pasan los datos a la UI.
## Proposed Changes
### [Component Name] Arquitectura y Estado
Se propone crear un `ViewModel` y una clase de estado para centralizar la lógica.
#### [NEW] [GasStationViewModel.kt](file:///C:/Users/pedro/AndroidStudioProjects/Prueba/app/src/main/java/com/example/prueba/ui/GasStationViewModel.kt)
- Manejará la lista de gasolineras, la ubicación actual, el historial de combustible y el estado de carga.
- Gestionará los permisos (opcionalmente) y las actualizaciones de ubicación.
- Proporcionará funciones para refrescar datos y registrar repostajes.
#### [MODIFY] [MainActivity.kt](file:///C:/Users/pedro/AndroidStudioProjects/Prueba/app/src/main/java/com/example/prueba/MainActivity.kt)
- Reducir el código en `GasStationApp` delegando en el `ViewModel`.
- Eliminar la lógica de `LaunchedEffect` y `remember` redundantes.
- Usar `collectAsStateWithLifecycle()` para observar el estado del `ViewModel`.
### [Component Name] Limpieza de UI
#### [MODIFY] [MainActivity.kt](file:///C:/Users/pedro/AndroidStudioProjects/Prueba/app/src/main/java/com/example/prueba/MainActivity.kt)
- Extraer strings hardcodeados (opcional si el usuario lo desea).
- Mejorar la legibilidad de `GasStationItem` y `FuelHistoryScreen`.
## Verification Plan
### Automated Tests
- Se podrían añadir pruebas unitarias para el `GasStationViewModel` para verificar la lógica de filtrado y actualización.
### Manual Verification
- Desplegar la app y verificar que la ubicación se sigue rastreando correctamente.
- Comprobar que la lista de gasolineras se actualiza al mover el dispositivo o al usar la barra de búsqueda.
- Verificar que el historial de combustible sigue funcionando correctamente.
@@ -3,7 +3,6 @@ package com.example.prueba
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Geocoder
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -25,31 +24,41 @@ 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.prueba.data.FuelType
import com.example.prueba.data.GasStationInfo
import com.example.prueba.data.GasStationRepository
import com.example.prueba.data.PriceCategory
import com.example.prueba.ui.GasStationViewModel
import com.example.prueba.ui.theme.PruebaTheme
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import android.content.Context
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import android.preference.PreferenceManager
import java.util.Locale
class MainActivity : ComponentActivity() {
private val repository = GasStationRepository()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// OSMDroid configuration
Configuration.getInstance().load(this, getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
Configuration.getInstance().userAgentValue = "GasolinerasApp/1.0 (Android; Contact: support@example.com)"
enableEdgeToEdge()
setContent {
PruebaTheme {
GasStationApp(repository)
GasStationApp()
}
}
}
@@ -57,242 +66,199 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GasStationApp(repository: GasStationRepository) {
fun GasStationApp(viewModel: GasStationViewModel = viewModel()) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var stations by remember { mutableStateOf<List<GasStationInfo>>(emptyList()) }
var lastLocation by remember { mutableStateOf<android.location.Location?>(null) }
var fuelRecords by remember { mutableStateOf(FuelStorage.getRecords(context)) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var showFuelDialog by remember { mutableStateOf<GasStationInfo?>(null) }
var editRecordDialog by remember { mutableStateOf<FuelRecord?>(null) }
var currentAddress by remember { mutableStateOf("Buscando ubicación...") }
var isLoading by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
var selectedTab by remember { mutableIntStateOf(0) }
var destinationQuery by remember { mutableStateOf("") }
var isRouteActive by remember { mutableStateOf(false) }
var destinationLocation by remember { mutableStateOf<android.location.Location?>(null) }
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
)
}
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
hasPermission = isGranted
viewModel.updatePermission(isGranted)
}
suspend fun updateAddress(location: android.location.Location) {
withContext(Dispatchers.IO) {
try {
val geocoder = Geocoder(context, Locale.getDefault())
@Suppress("DEPRECATION")
val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1)
if (!addresses.isNullOrEmpty()) {
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"
}
} catch (ignore: Exception) {}
}
LaunchedEffect(Unit) {
viewModel.updatePermission(
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
)
viewModel.loadFuelRecords()
}
// Registro del rastreador de GPS (Se ejecuta solo al inicio o si cambia el permiso)
LaunchedEffect(hasPermission) {
if (hasPermission) {
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
// Cargar ubicación inicial guardada para no empezar de cero
LocationStorage.getLastLocation(context)?.let { lastLocation = it }
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 15000)
.setMinUpdateDistanceMeters(500f)
.build()
val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
val location = result.lastLocation ?: return
lastLocation = location
LocationStorage.saveLocation(context, location)
scope.launch { updateAddress(location) }
}
}
try {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, android.os.Looper.getMainLooper())
}
} catch (e: Exception) {
isLoading = false
}
}
}
// Actualizador de la lista de gasolineras (Reacciona al movimiento del GPS)
LaunchedEffect(hasPermission, destinationLocation, selectedTab, isRouteActive, lastLocation) {
if (hasPermission) {
val loc = lastLocation ?: LocationStorage.getLastLocation(context)
loc?.let {
if (selectedTab == 0) {
// 1. Carga ultra-rápida (recta)
stations = repository.getCheapestAndNearest(
userLocation = it,
destinationLocation = if (isRouteActive) destinationLocation else null,
getRealDistances = false
)
// 2. Carga de precisión (carretera)
val realStations = repository.getCheapestAndNearest(
userLocation = it,
destinationLocation = if (isRouteActive) destinationLocation else null,
getRealDistances = true
)
if (realStations.isNotEmpty()) {
stations = realStations
}
isLoading = false
}
}
if (selectedTab == 0 && stations.isEmpty()) isLoading = true
LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearError()
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
Column {
CenterAlignedTopAppBar(
title = {
if (isRouteActive) {
if (uiState.isRouteActive) {
TextField(
value = destinationQuery,
onValueChange = { destinationQuery = it },
placeholder = { Text("¿A dónde vas?") },
value = uiState.destinationQuery,
onValueChange = { viewModel.updateDestinationQuery(it) },
placeholder = { Text(stringResource(R.string.where_to_go)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
singleLine = true,
trailingIcon = {
Row {
IconButton(onClick = {
scope.launch {
withContext(Dispatchers.IO) {
try {
val geocoder = Geocoder(context, Locale.getDefault())
@Suppress("DEPRECATION")
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) }
IconButton(onClick = { viewModel.searchDestination() }) { Icon(Icons.Default.DirectionsCar, null) }
IconButton(onClick = { viewModel.setRouteActive(false) }) { 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 = when (uiState.selectedFuelType) {
FuelType.DIESEL_A -> stringResource(R.string.diesel_a_label)
FuelType.PREMIUM -> stringResource(R.string.premium_label)
FuelType.GASOLINA_95 -> stringResource(R.string.gas95_label)
FuelType.GASOLINA_98 -> stringResource(R.string.gas98_label)
FuelType.GLP -> stringResource(R.string.glp_label)
FuelType.GNC -> stringResource(R.string.gnc_label)
FuelType.GNL -> stringResource(R.string.gnl_label)
FuelType.DIESEL_B -> stringResource(R.string.diesel_b_label)
FuelType.BIODIESEL -> stringResource(R.string.biodiesel_label)
},
style = MaterialTheme.typography.titleSmall
)
Text(
text = uiState.currentAddress ?: stringResource(R.string.searching_location),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1
)
if (uiState.stations.isNotEmpty()) {
Text(
text = stringResource(R.string.last_updated, uiState.stations.first().updateTime),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.secondary
)
}
}
}
},
actions = {
if (!isRouteActive && selectedTab == 0) {
IconButton(onClick = { isRouteActive = true }) {
Icon(Icons.AutoMirrored.Filled.AltRoute, "En ruta")
if (!uiState.isRouteActive && uiState.selectedTab == 0) {
IconButton(onClick = { viewModel.setRouteActive(true) }) {
Icon(Icons.AutoMirrored.Filled.AltRoute, stringResource(R.string.on_route))
}
}
}
)
if (uiState.selectedTab == 0) {
FuelTypeSelector(
selectedType = uiState.selectedFuelType,
onTypeSelected = { viewModel.selectFuelType(it) }
)
}
}
},
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.LocalGasStation, null) },
label = { Text("Gasolineras") },
selected = selectedTab == 0,
onClick = { selectedTab = 0 }
label = { Text(stringResource(R.string.nav_stations)) },
selected = uiState.selectedTab == 0,
onClick = { viewModel.selectTab(0) }
)
NavigationBarItem(
icon = { Icon(Icons.Default.History, null) },
label = { Text("Historial") },
selected = selectedTab == 1,
onClick = { selectedTab = 1 }
label = { Text(stringResource(R.string.nav_history)) },
selected = uiState.selectedTab == 1,
onClick = { viewModel.selectTab(1) }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Map, null) },
label = { Text(stringResource(R.string.nav_map)) },
selected = uiState.selectedTab == 2,
onClick = { viewModel.selectTab(2) }
)
}
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
if (!hasPermission) {
if (!uiState.hasPermission) {
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") }
Text(stringResource(R.string.permission_needed))
Button(onClick = { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }) { Text(stringResource(R.string.give_permission)) }
}
} else {
when (selectedTab) {
when (uiState.selectedTab) {
0 -> {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
lastLocation?.let { loc ->
isRefreshing = true
scope.launch {
stations = repository.getCheapestAndNearest(
userLocation = loc, forceRefresh = true,
destinationLocation = if (isRouteActive) destinationLocation else null,
getRealDistances = true
)
isRefreshing = false
}
}
},
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refreshStations(forceRefresh = true) },
modifier = Modifier.fillMaxSize()
) {
if (isLoading && stations.isEmpty()) {
if (uiState.isLoading && uiState.stations.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (stations.isEmpty()) {
Text("Sin resultados", modifier = Modifier.align(Alignment.Center))
} else if (uiState.stations.isEmpty()) {
Text(stringResource(R.string.no_results), modifier = Modifier.align(Alignment.Center))
} else {
GasStationList(stations = stations, onLogClick = { showFuelDialog = it })
GasStationList(
stations = uiState.stations,
visibleCount = uiState.visibleStationsCount,
selectedFuelType = uiState.selectedFuelType,
onLogClick = { showFuelDialog = it },
onShowMore = { viewModel.showMoreStations() },
onShowLess = { viewModel.resetStationLimit() }
)
}
}
}
1 -> {
FuelHistoryScreen(
records = fuelRecords,
onDelete = { record ->
FuelStorage.deleteRecord(context, record)
fuelRecords = FuelStorage.getRecords(context)
},
records = uiState.fuelRecords,
onDelete = { record -> viewModel.deleteFuelRecord(record) },
onEdit = { record -> editRecordDialog = record }
)
}
2 -> {
GasStationMap(
stations = uiState.stations,
routePoints = uiState.routePoints,
userLocation = uiState.lastLocation,
selectedFuelType = uiState.selectedFuelType
)
}
}
}
showFuelDialog?.let { station ->
FuelLogDialog(
stationName = station.name,
priceA = station.price,
pricePremium = station.pricePremium,
price95 = station.price95,
price98 = station.price98,
priceGLP = station.priceGLP,
priceGNC = station.priceGNC,
priceGNL = station.priceGNL,
priceDieselB = station.priceDieselB,
priceBiodiesel = station.priceBiodiesel,
initialType = when (uiState.selectedFuelType) {
FuelType.DIESEL_A -> stringResource(R.string.diesel_a_label)
FuelType.PREMIUM -> stringResource(R.string.premium_label)
FuelType.GASOLINA_95 -> stringResource(R.string.gas95_label)
FuelType.GASOLINA_98 -> stringResource(R.string.gas98_label)
FuelType.GLP -> stringResource(R.string.glp_label)
FuelType.GNC -> stringResource(R.string.gnc_label)
FuelType.GNL -> stringResource(R.string.gnl_label)
FuelType.DIESEL_B -> stringResource(R.string.diesel_b_label)
FuelType.BIODIESEL -> stringResource(R.string.biodiesel_label)
},
onDismiss = { showFuelDialog = null },
onConfirm = { liters, total ->
onConfirm = { liters, total, type, pricePerLiter ->
val record = FuelRecord(
date = System.currentTimeMillis(),
stationName = station.name,
@@ -300,10 +266,10 @@ fun GasStationApp(repository: GasStationRepository) {
municipality = station.municipality,
liters = liters,
totalPrice = total,
pricePerLiter = station.price
pricePerLiter = pricePerLiter,
fuelType = type
)
FuelStorage.saveRecord(context, record)
fuelRecords = FuelStorage.getRecords(context)
viewModel.saveFuelRecord(record)
showFuelDialog = null
}
)
@@ -314,11 +280,16 @@ fun GasStationApp(repository: GasStationRepository) {
stationName = record.stationName,
initialLiters = record.liters.toString(),
initialTotal = record.totalPrice.toString(),
initialType = record.fuelType,
onDismiss = { editRecordDialog = null },
onConfirm = { liters, total ->
val newRecord = record.copy(liters = liters, totalPrice = total)
FuelStorage.updateRecord(context, record, newRecord)
fuelRecords = FuelStorage.getRecords(context)
onConfirm = { liters, total, type, pricePerLiter ->
val newRecord = record.copy(
liters = liters,
totalPrice = total,
fuelType = type,
pricePerLiter = pricePerLiter
)
viewModel.updateFuelRecord(record, newRecord)
editRecordDialog = null
}
)
@@ -328,12 +299,124 @@ fun GasStationApp(repository: GasStationRepository) {
}
@Composable
fun GasStationList(stations: List<GasStationInfo>, onLogClick: (GasStationInfo) -> Unit) {
fun GasStationMap(
stations: List<GasStationInfo>,
routePoints: List<android.location.Location>,
userLocation: android.location.Location?,
selectedFuelType: FuelType
) {
AndroidView(
factory = { context ->
MapView(context).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
controller.setZoom(15.0)
userLocation?.let {
controller.setCenter(GeoPoint(it.latitude, it.longitude))
}
}
},
update = { mapView ->
mapView.overlays.clear()
// 1. Trazar la ruta (Polilínea)
if (routePoints.isNotEmpty()) {
val line = Polyline(mapView)
line.setPoints(routePoints.map { GeoPoint(it.latitude, it.longitude) })
line.outlinePaint.color = android.graphics.Color.BLUE
line.outlinePaint.strokeWidth = 10f
mapView.overlays.add(line)
// Zoom para ajustar a la ruta la primera vez
if (mapView.tag == null) {
val points = routePoints.map { GeoPoint(it.latitude, it.longitude) }
mapView.zoomToBoundingBox(org.osmdroid.util.BoundingBox.fromGeoPoints(points), true)
mapView.tag = "zoomed"
}
}
// 2. Marcador de usuario
userLocation?.let {
val userMarker = Marker(mapView)
userMarker.position = GeoPoint(it.latitude, it.longitude)
userMarker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
userMarker.title = "Tu ubicación"
mapView.overlays.add(userMarker)
}
// 3. Marcadores de gasolineras
stations.forEach { station ->
val marker = Marker(mapView)
marker.position = GeoPoint(station.latitude, station.longitude)
marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
val price = station.getPriceFor(selectedFuelType) ?: 0.0
marker.title = "${station.name}: $price €/L"
marker.subDescription = station.address
mapView.overlays.add(marker)
}
mapView.invalidate()
},
modifier = Modifier.fillMaxSize()
)
}
@Composable
fun FuelTypeSelector(
selectedType: FuelType,
onTypeSelected: (FuelType) -> Unit
) {
SecondaryScrollableTabRow(
selectedTabIndex = selectedType.ordinal,
edgePadding = 16.dp,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary,
divider = {}
) {
FuelType.entries.forEach { type ->
Tab(
selected = selectedType == type,
onClick = { onTypeSelected(type) },
text = {
Text(
text = when (type) {
FuelType.DIESEL_A -> stringResource(R.string.diesel_a_label)
FuelType.PREMIUM -> stringResource(R.string.premium_label)
FuelType.GASOLINA_95 -> stringResource(R.string.gas95_label)
FuelType.GASOLINA_98 -> stringResource(R.string.gas98_label)
FuelType.GLP -> stringResource(R.string.glp_label)
FuelType.GNC -> stringResource(R.string.gnc_label)
FuelType.GNL -> stringResource(R.string.gnl_label)
FuelType.DIESEL_B -> stringResource(R.string.diesel_b_label)
FuelType.BIODIESEL -> stringResource(R.string.biodiesel_label)
},
style = MaterialTheme.typography.labelMedium
)
}
)
}
}
}
@Composable
fun GasStationList(
stations: List<GasStationInfo>,
visibleCount: Int,
selectedFuelType: FuelType,
onLogClick: (GasStationInfo) -> Unit,
onShowMore: () -> Unit,
onShowLess: () -> Unit
) {
val context = LocalContext.current
val visibleStations = stations.take(visibleCount)
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(stations) { station ->
items(visibleStations) { station ->
GasStationItem(
station = station,
selectedFuelType = selectedFuelType,
onClick = {
val uri = Uri.parse("geo:${station.latitude},${station.longitude}?q=${station.latitude},${station.longitude}(${Uri.encode(station.name)})")
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
@@ -341,13 +424,39 @@ fun GasStationList(stations: List<GasStationInfo>, onLogClick: (GasStationInfo)
onLogClick = { onLogClick(station) }
)
}
item {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (stations.size > visibleCount) {
Button(
onClick = onShowMore,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.show_more))
}
}
if (visibleCount > 15) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = onShowLess,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.show_less))
}
}
}
}
}
}
@Composable
fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit, onEdit: (FuelRecord) -> Unit) {
if (records.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Sin registros") }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.no_records)) }
} else {
LazyColumn(contentPadding = PaddingValues(16.dp)) {
items(records) { record ->
@@ -366,7 +475,7 @@ fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit,
}
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("${record.fuelType}: ${record.liters} L x ${record.pricePerLiter} €/L", style = MaterialTheme.typography.bodyMedium)
Text("Total: ${record.totalPrice}", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary)
}
}
@@ -376,34 +485,182 @@ 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,
priceA: Double? = null,
pricePremium: Double? = null,
price95: Double? = null,
price98: Double? = null,
priceGLP: Double? = null,
priceGNC: Double? = null,
priceGNL: Double? = null,
priceDieselB: Double? = null,
priceBiodiesel: Double? = null,
initialLiters: String = "",
initialTotal: String = "",
initialType: String = "Diésel A",
onDismiss: () -> Unit,
onConfirm: (Double, Double, String, Double) -> Unit
) {
val context = LocalContext.current
var liters by remember { mutableStateOf(initialLiters) }
var total by remember { mutableStateOf(initialTotal) }
val dieselLabel = stringResource(R.string.diesel_a_label)
val premiumLabel = stringResource(R.string.premium_label)
val gas95Label = stringResource(R.string.gas95_label)
val gas98Label = stringResource(R.string.gas98_label)
val glpLabel = stringResource(R.string.glp_label)
val gncLabel = stringResource(R.string.gnc_label)
val gnlLabel = stringResource(R.string.gnl_label)
val dieselBLabel = stringResource(R.string.diesel_b_label)
val biodieselLabel = stringResource(R.string.biodiesel_label)
var selectedType by remember { mutableStateOf(initialType) }
val currentPrice = when (selectedType) {
premiumLabel -> pricePremium ?: 0.0
gas95Label -> price95 ?: 0.0
gas98Label -> price98 ?: 0.0
glpLabel -> priceGLP ?: 0.0
gncLabel -> priceGNC ?: 0.0
gnlLabel -> priceGNL ?: 0.0
dieselBLabel -> priceDieselB ?: 0.0
biodieselLabel -> priceBiodiesel ?: 0.0
else -> priceA ?: 0.0
}
// Auto-calculate total when liters or type change
LaunchedEffect(liters, selectedType) {
val l = liters.toDoubleOrNull() ?: 0.0
if (l > 0 && currentPrice > 0) {
total = String.format(Locale.US, "%.2f", l * currentPrice)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Registrar Repostaje") },
title = { Text(stringResource(R.string.log_fuel)) },
text = {
Column {
Text(stationName, style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.height(16.dp))
Text(stringResource(R.string.fuel_type), style = MaterialTheme.typography.labelMedium)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selectedType == dieselLabel,
onClick = { selectedType = dieselLabel }
)
Text(dieselLabel, style = MaterialTheme.typography.bodySmall)
}
if (pricePremium != null && pricePremium > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selectedType == premiumLabel,
onClick = { selectedType = premiumLabel }
)
Text(premiumLabel, style = MaterialTheme.typography.bodySmall)
}
}
if (price95 != null && price95 > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selectedType == gas95Label,
onClick = { selectedType = gas95Label }
)
Text(gas95Label, style = MaterialTheme.typography.bodySmall)
}
}
if (price98 != null && price98 > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selectedType == gas98Label,
onClick = { selectedType = gas98Label }
)
Text(gas98Label, style = MaterialTheme.typography.bodySmall)
}
}
if (priceGLP != null && priceGLP > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selectedType == glpLabel,
onClick = { selectedType = glpLabel }
)
Text(glpLabel, style = MaterialTheme.typography.bodySmall)
}
}
if (priceGNC != null && priceGNC > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selectedType == gncLabel,
onClick = { selectedType = gncLabel }
)
Text(gncLabel, style = MaterialTheme.typography.bodySmall)
}
}
if (priceGNL != null && priceGNL > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selectedType == gnlLabel,
onClick = { selectedType = gnlLabel }
)
Text(gnlLabel, style = MaterialTheme.typography.bodySmall)
}
}
if (priceDieselB != null && priceDieselB > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selectedType == dieselBLabel,
onClick = { selectedType = dieselBLabel }
)
Text(dieselBLabel, style = MaterialTheme.typography.bodySmall)
}
}
if (priceBiodiesel != null && priceBiodiesel > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selectedType == biodieselLabel,
onClick = { selectedType = biodieselLabel }
)
Text(biodieselLabel, style = MaterialTheme.typography.bodySmall)
}
}
}
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(stringResource(R.string.liters)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = total,
onValueChange = { total = it },
label = { Text(stringResource(R.string.total_price)) },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(onClick = {
val l = liters.toDoubleOrNull() ?: 0.0
val t = total.toDoubleOrNull() ?: 0.0
if (l > 0 && t > 0) onConfirm(l, t)
}) { Text("Guardar") }
if (l > 0 && t > 0) onConfirm(l, t, selectedType, if (l > 0) t / l else currentPrice)
}) { Text(stringResource(R.string.save)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancelar") } }
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }
)
}
@Composable
fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: () -> Unit) {
fun GasStationItem(station: GasStationInfo, selectedFuelType: FuelType, onClick: () -> Unit, onLogClick: () -> Unit) {
val priceColor = when (station.priceCategory) {
PriceCategory.CHEAP -> Color(0xFF2E7D32)
PriceCategory.NORMAL -> MaterialTheme.colorScheme.primary
@@ -428,6 +685,9 @@ fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: ()
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)
if (station.horario.isNotEmpty()) {
Text(text = station.horario, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
}
}
Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.medium) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally) {
@@ -449,9 +709,50 @@ fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: ()
Spacer(modifier = Modifier.height(8.dp))
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)
station.pricePremium?.let { premium ->
Text(text = "Premium: $premium €/L", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, fontWeight = FontWeight.Medium)
// Combustible Seleccionado (Principal)
val mainPrice = station.getPriceFor(selectedFuelType)
if (mainPrice != null && mainPrice > 0.1) {
val label = when (selectedFuelType) {
FuelType.DIESEL_A -> stringResource(R.string.diesel_a_label)
FuelType.PREMIUM -> stringResource(R.string.premium_label)
FuelType.GASOLINA_95 -> stringResource(R.string.gas95_label)
FuelType.GASOLINA_98 -> stringResource(R.string.gas98_label)
FuelType.GLP -> stringResource(R.string.glp_label)
FuelType.GNC -> stringResource(R.string.gnc_label)
FuelType.GNL -> stringResource(R.string.gnl_label)
FuelType.DIESEL_B -> stringResource(R.string.diesel_b_label)
FuelType.BIODIESEL -> stringResource(R.string.biodiesel_label)
}
Text(text = "$label: $mainPrice €/L", style = MaterialTheme.typography.titleLarge, color = priceColor, fontWeight = FontWeight.Bold)
}
// Otros Combustibles (Secundarios)
if (selectedFuelType != FuelType.DIESEL_A && station.price > 0.1) {
Text(text = stringResource(R.string.diesel_a, station.price.toString()), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
}
if (selectedFuelType != FuelType.PREMIUM && station.pricePremium != null && station.pricePremium > 0.1) {
Text(text = stringResource(R.string.premium, station.pricePremium.toString()), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
}
if (selectedFuelType != FuelType.GASOLINA_95 && station.price95 != null && station.price95 > 0.1) {
Text(text = "${stringResource(R.string.gas95_label)}: ${station.price95} €/L", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
}
if (selectedFuelType != FuelType.GASOLINA_98 && station.price98 != null && station.price98 > 0.1) {
Text(text = "${stringResource(R.string.gas98_label)}: ${station.price98} €/L", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
}
if (selectedFuelType != FuelType.GLP && station.priceGLP != null && station.priceGLP > 0.1) {
Text(text = "${stringResource(R.string.glp_label)}: ${station.priceGLP} €/L", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
}
if (selectedFuelType != FuelType.GNC && station.priceGNC != null && station.priceGNC > 0.1) {
Text(text = "${stringResource(R.string.gnc_label)}: ${station.priceGNC} €/L", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
}
if (selectedFuelType != FuelType.GNL && station.priceGNL != null && station.priceGNL > 0.1) {
Text(text = "${stringResource(R.string.gnl_label)}: ${station.priceGNL} €/L", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
}
if (selectedFuelType != FuelType.DIESEL_B && station.priceDieselB != null && station.priceDieselB > 0.1) {
Text(text = "${stringResource(R.string.diesel_b_label)}: ${station.priceDieselB} €/L", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
}
if (selectedFuelType != FuelType.BIODIESEL && station.priceBiodiesel != null && station.priceBiodiesel > 0.1) {
Text(text = "${stringResource(R.string.biodiesel_label)}: ${station.priceBiodiesel} €/L", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
}
}
FilledTonalIconButton(
@@ -460,7 +761,7 @@ fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: ()
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Registrar repostaje",
contentDescription = stringResource(R.string.log_fuel_desc),
modifier = Modifier.size(24.dp)
)
}
@@ -56,12 +56,12 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
gasStations = repository.getCheapestAndNearest(
userLocation = lastLoc,
getRealDistances = false
)
).first
invalidate()
gasStations = repository.getCheapestAndNearest(
userLocation = lastLoc,
getRealDistances = true
)
).first
invalidate()
}
}
@@ -81,7 +81,7 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
userLocation = location,
forceRefresh = forceRefresh,
getRealDistances = true
)
).first
isLoading = false
invalidate()
}
@@ -1,23 +1,39 @@
package com.example.prueba.data
import android.location.Location
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import kotlinx.coroutines.coroutineScope
class GasStationRepository {
private var cachedResponse: GasolineraResponse? = null
private val client = OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.header("User-Agent", "GasolinerasApp/1.0 (Android; Contact: support@example.com)")
.build()
chain.proceed(request)
}
.build()
private val api: GasolineraApi = Retrofit.Builder()
.baseUrl("https://sedeaplicaciones.minetur.gob.es/ServiciosRESTCarburantes/PreciosCarburantes/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(GasolineraApi::class.java)
private val routeApi: OsrmApi = Retrofit.Builder()
.baseUrl("https://router.project-osrm.org/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(OsrmApi::class.java)
@@ -25,46 +41,95 @@ class GasStationRepository {
suspend fun getCheapestAndNearest(
userLocation: Location,
forceRefresh: Boolean = false,
destinationLocation: Location? = null,
getRealDistances: Boolean = false
): List<GasStationInfo> {
return try {
getRealDistances: Boolean = false,
fuelType: FuelType = FuelType.DIESEL_A,
destinationLocation: Location? = null
): Pair<List<GasStationInfo>, List<Location>> = withContext(Dispatchers.Default) {
try {
if (cachedResponse == null || forceRefresh) {
cachedResponse = api.getGasolineras()
}
// 1. Obtener la ruta real si hay destino
val routePoints = if (destinationLocation != null) {
try {
val coords = "${userLocation.longitude},${userLocation.latitude};${destinationLocation.longitude},${destinationLocation.latitude}"
val response = routeApi.getRoute(coords)
val fullPath = response.routes.firstOrNull()?.geometry?.coordinates?.map {
Location("").apply {
longitude = it[0]
latitude = it[1]
}
} ?: emptyList()
// Optimización: Reducir puntos de la ruta (tomar 1 de cada 10 si es muy larga) para agilizar el filtrado
if (fullPath.size > 200) {
val step = fullPath.size / 100
fullPath.filterIndexed { index, _ -> index % step == 0 }
} else fullPath
} catch (e: Exception) {
emptyList()
}
} else emptyList()
val allStations = cachedResponse?.listaEESS?.mapNotNull {
val lat = it.latitud.replace(",", ".").toDoubleOrNull()
val lon = it.longitud.replace(",", ".").toDoubleOrNull()
val lat = it.latitud?.replace(",", ".")?.toDoubleOrNull()
val lon = it.longitud?.replace(",", ".")?.toDoubleOrNull()
if (lat != null && lon != null) {
val dieselPrice = it.precioDiesel.replace(",", ".").toDoubleOrNull()
val premiumPrice = it.precioDieselPremium.replace(",", ".").toDoubleOrNull()
val dieselPrice = it.precioDiesel?.replace(",", ".")?.toDoubleOrNull()
val premiumPrice = it.precioDieselPremium?.replace(",", ".")?.toDoubleOrNull()
val gas95Price = it.precioGasolina95?.replace(",", ".")?.toDoubleOrNull()
val gas98Price = it.precioGasolina98?.replace(",", ".")?.toDoubleOrNull()
val glpPrice = it.precioGLP?.replace(",", ".")?.toDoubleOrNull()
val gncPrice = it.precioGNC?.replace(",", ".")?.toDoubleOrNull()
val gnlPrice = it.precioGNL?.replace(",", ".")?.toDoubleOrNull()
val dieselBPrice = it.precioDieselB?.replace(",", ".")?.toDoubleOrNull()
val biodieselPrice = it.precioBiodiesel?.replace(",", ".")?.toDoubleOrNull()
if (dieselPrice != null || premiumPrice != null) {
if (dieselPrice != null || premiumPrice != null || gas95Price != null || gas98Price != null || glpPrice != null || gncPrice != null || gnlPrice != null || dieselBPrice != null || biodieselPrice != null) {
val stationLoc = Location("").apply {
latitude = lat
longitude = lon
}
val distanceToUser = userLocation.distanceTo(stationLoc)
val validPrice = (dieselPrice ?: 0.0) > 0.1
var isOnRoute = true
// Distancia rápida al usuario para descartar lejanas de inmediato
val distToUser = userLocation.distanceTo(stationLoc)
val maxDistKm = if (destinationLocation == null) 35.0 else 450.0
if (distToUser > maxDistKm * 1000) return@mapNotNull null
var isNearRoute = true
if (destinationLocation != null) {
val distUserToDest = userLocation.distanceTo(destinationLocation)
val distStationToDest = stationLoc.distanceTo(destinationLocation)
isOnRoute = (distanceToUser + distStationToDest) <= (distUserToDest * 1.15)
if (routePoints.isEmpty()) {
val distStationToDest = stationLoc.distanceTo(destinationLocation)
val diversion = (distToUser + distStationToDest) - userLocation.distanceTo(destinationLocation)
isNearRoute = diversion < 5000
} else {
// Filtrado por cercanía a la carretera real (máx 5km para mayor margen con menos puntos)
isNearRoute = routePoints.any { it.distanceTo(stationLoc) < 5000 }
}
}
if (isOnRoute && validPrice) {
val hasValidPrice = listOfNotNull(dieselPrice, premiumPrice, gas95Price, gas98Price, glpPrice, gncPrice, gnlPrice, dieselBPrice, biodieselPrice).any { p -> p > 0.1 }
if (hasValidPrice && isNearRoute) {
GasStationInfo(
id = it.id,
name = it.rotulo,
address = it.direccion,
municipality = it.municipio,
id = it.id ?: "",
name = it.rotulo ?: "Sin nombre",
address = it.direccion ?: "Sin dirección",
municipality = it.municipio ?: "",
horario = it.horario ?: "",
updateTime = cachedResponse?.fecha ?: "",
price = dieselPrice ?: 0.0,
pricePremium = premiumPrice,
distance = distanceToUser,
price95 = gas95Price,
price98 = gas98Price,
priceGLP = glpPrice,
priceGNC = gncPrice,
priceGNL = gnlPrice,
priceDieselB = dieselBPrice,
priceBiodiesel = biodieselPrice,
distance = distToUser,
latitude = lat,
longitude = lon
)
@@ -73,25 +138,34 @@ class GasStationRepository {
} else null
} ?: emptyList()
// Radio máximo: 30km si es local, 400km si hay destino (ruta larga)
// Radio máximo: 30km local / 400km viaje
val finalMaxDist = if (destinationLocation == null) 30.0 else 400.0
val filtered = allStations.filter { (it.distance / 1000.0) <= finalMaxDist }
if (filtered.isEmpty()) return emptyList()
if (filtered.isEmpty()) return@withContext emptyList<GasStationInfo>() to emptyList<Location>()
val minPrice = filtered.minOf { it.price }
val avgPrice = filtered.map { it.price }.average()
val pricesForType = filtered.mapNotNull { it.getPriceFor(fuelType) }.filter { it > 0.1 }
val minPrice = if (pricesForType.isNotEmpty()) pricesForType.min() else 0.0
val avgPrice = if (pricesForType.isNotEmpty()) pricesForType.average() else 0.0
var results = filtered.map { station ->
val price = station.getPriceFor(fuelType) ?: 0.0
val category = when {
station.price <= minPrice * 1.01 -> PriceCategory.CHEAP
station.price <= avgPrice -> PriceCategory.NORMAL
price <= 0.1 -> PriceCategory.NORMAL
price <= minPrice * 1.01 -> PriceCategory.CHEAP
price <= avgPrice -> PriceCategory.NORMAL
else -> PriceCategory.EXPENSIVE
}
station.copy(priceCategory = category)
}.sortedBy { it.distance }
.take(if (destinationLocation != null) 30 else 15)
}
if (destinationLocation != null) {
// Modo VIAJE: Ordenar por PRECIO y coger solo las 10 mejores
results = results.sortedBy { it.getPriceFor(fuelType) ?: 99.9 }.take(10)
} else {
// Modo LOCAL: Ordenar por DISTANCIA y coger 100
results = results.sortedBy { it.distance }.take(100)
}
if (getRealDistances) {
coroutineScope {
@@ -116,25 +190,48 @@ class GasStationRepository {
}
}
results
results to routePoints
} catch (e: Exception) {
emptyList()
emptyList<GasStationInfo>() to emptyList<Location>()
}
}
}
enum class PriceCategory { CHEAP, NORMAL, EXPENSIVE }
enum class FuelType { DIESEL_A, PREMIUM, GASOLINA_95, GASOLINA_98, GLP, GNC, GNL, DIESEL_B, BIODIESEL }
data class GasStationInfo(
val id: String,
val name: String,
val address: String,
val municipality: String,
val horario: String = "",
val updateTime: String = "",
val price: Double,
val pricePremium: Double? = null,
val price95: Double? = null,
val price98: Double? = null,
val priceGLP: Double? = null,
val priceGNC: Double? = null,
val priceGNL: Double? = null,
val priceDieselB: Double? = null,
val priceBiodiesel: Double? = null,
val distance: Float,
val durationMinutes: Int? = null,
val latitude: Double,
val longitude: Double,
val priceCategory: PriceCategory = PriceCategory.NORMAL
)
) {
fun getPriceFor(type: FuelType): Double? = when (type) {
FuelType.DIESEL_A -> price
FuelType.PREMIUM -> pricePremium
FuelType.GASOLINA_95 -> price95
FuelType.GASOLINA_98 -> price98
FuelType.GLP -> priceGLP
FuelType.GNC -> priceGNC
FuelType.GNL -> priceGNL
FuelType.DIESEL_B -> priceDieselB
FuelType.BIODIESEL -> priceBiodiesel
}
}
@@ -4,29 +4,45 @@ import com.google.gson.annotations.SerializedName
import retrofit2.http.GET
data class GasolineraResponse(
@SerializedName("Fecha")
val fecha: String,
@SerializedName("ListaEESSPrecio")
val listaEESS: List<Gasolinera>
)
data class Gasolinera(
@SerializedName("IDEESS")
val id: String,
val id: String?,
@SerializedName("Rótulo")
val rotulo: String,
val rotulo: String?,
@SerializedName("Dirección")
val direccion: String,
val direccion: String?,
@SerializedName("Municipio")
val municipio: String,
val municipio: String?,
@SerializedName("Horario")
val horario: String?,
@SerializedName("Precio Gasoleo A")
val precioDiesel: String,
val precioDiesel: String?,
@SerializedName("Precio Gasoleo Premium")
val precioDieselPremium: String,
val precioDieselPremium: String?,
@SerializedName("Precio Gasolina 95 E5")
val precioGasolina95: String,
val precioGasolina95: String?,
@SerializedName("Precio Gasolina 98 E5")
val precioGasolina98: String?,
@SerializedName("Precio Gases licuados del petróleo")
val precioGLP: String?,
@SerializedName("Precio Gas natural comprimido")
val precioGNC: String?,
@SerializedName("Precio Gas natural licuado")
val precioGNL: String?,
@SerializedName("Precio Gasoleo B")
val precioDieselB: String?,
@SerializedName("Precio Biodiesel")
val precioBiodiesel: String?,
@SerializedName("Latitud")
val latitud: String,
val latitud: String?,
@SerializedName("Longitud (WGS84)")
val longitud: String
val longitud: String?
)
interface GasolineraApi {
@@ -13,11 +13,18 @@ data class OsrmRoute(
@SerializedName("distance")
val distance: Double, // En metros
@SerializedName("duration")
val duration: Double // En segundos
val duration: Double, // En segundos
@SerializedName("geometry")
val geometry: OsrmGeometry? = null
)
data class OsrmGeometry(
@SerializedName("coordinates")
val coordinates: List<List<Double>> // [[lon, lat], [lon, lat], ...]
)
interface OsrmApi {
@GET("route/v1/driving/{coords}?overview=false")
@GET("route/v1/driving/{coords}?overview=full&geometries=geojson")
suspend fun getRoute(
@Path("coords") coords: String
): OsrmResponse
@@ -0,0 +1,247 @@
package com.example.prueba.ui
import android.annotation.SuppressLint
import android.app.Application
import android.location.Geocoder
import android.location.Location
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.prueba.R
import com.example.prueba.data.FuelType
import com.example.prueba.data.GasStationInfo
import com.example.prueba.data.GasStationRepository
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.util.Locale
data class GasStationUiState(
val stations: List<GasStationInfo> = emptyList(),
val lastLocation: Location? = null,
val fuelRecords: List<FuelRecord> = emptyList(),
val currentAddress: String? = null, // null means "searching"
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val selectedTab: Int = 0,
val hasPermission: Boolean = false,
val errorMessage: String? = null,
val visibleStationsCount: Int = 15,
val selectedFuelType: FuelType = FuelType.DIESEL_A,
val destinationQuery: String = "",
val isRouteActive: Boolean = false,
val destinationLocation: Location? = null,
val routePoints: List<Location> = emptyList()
)
class GasStationViewModel(
application: Application
) : AndroidViewModel(application) {
private val repository: GasStationRepository = GasStationRepository()
private val context get() = getApplication<Application>().applicationContext
private val _uiState = MutableStateFlow(GasStationUiState())
val uiState: StateFlow<GasStationUiState> = _uiState.asStateFlow()
fun updatePermission(granted: Boolean) {
_uiState.update { it.copy(hasPermission = granted) }
if (granted) {
startLocationUpdates()
}
}
fun selectTab(index: Int) {
_uiState.update { it.copy(selectedTab = index, errorMessage = null, visibleStationsCount = 15) }
}
fun selectFuelType(type: FuelType) {
_uiState.update { it.copy(selectedFuelType = type, visibleStationsCount = 15) }
}
fun setRouteActive(active: Boolean) {
_uiState.update {
if (!active) it.copy(isRouteActive = false, destinationLocation = null, destinationQuery = "", routePoints = emptyList())
else it.copy(isRouteActive = true)
}
}
fun updateDestinationQuery(query: String) {
_uiState.update { it.copy(destinationQuery = query) }
}
fun searchDestination() {
val query = _uiState.value.destinationQuery
if (query.isBlank()) return
viewModelScope.launch(Dispatchers.IO) {
try {
val geocoder = Geocoder(context, Locale.getDefault())
@Suppress("DEPRECATION")
val results = geocoder.getFromLocationName(query, 1)
if (!results.isNullOrEmpty()) {
val loc = Location("").apply {
latitude = results[0].latitude
longitude = results[0].longitude
}
_uiState.update { it.copy(destinationLocation = loc) }
}
} catch (e: Exception) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.error_location)) }
}
}
}
fun loadFuelRecords() {
viewModelScope.launch(Dispatchers.IO) {
val records = FuelStorage.getRecords(context)
_uiState.update { it.copy(fuelRecords = records) }
}
}
fun deleteFuelRecord(record: FuelRecord) {
viewModelScope.launch(Dispatchers.IO) {
FuelStorage.deleteRecord(context, record)
loadFuelRecords()
}
}
fun saveFuelRecord(record: FuelRecord) {
viewModelScope.launch(Dispatchers.IO) {
FuelStorage.saveRecord(context, record)
loadFuelRecords()
}
}
fun updateFuelRecord(oldRecord: FuelRecord, newRecord: FuelRecord) {
viewModelScope.launch(Dispatchers.IO) {
FuelStorage.updateRecord(context, oldRecord, newRecord)
loadFuelRecords()
}
}
@SuppressLint("MissingPermission")
private fun startLocationUpdates() {
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
LocationStorage.getLastLocation(context)?.let { loc ->
_uiState.update { it.copy(lastLocation = loc) }
updateAddress(loc)
}
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 15000)
.setMinUpdateDistanceMeters(500f)
.build()
val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
val location = result.lastLocation ?: return
_uiState.update { it.copy(lastLocation = location) }
LocationStorage.saveLocation(context, location)
updateAddress(location)
}
}
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, context.mainLooper)
}
private fun updateAddress(location: Location) {
viewModelScope.launch(Dispatchers.IO) {
try {
val geocoder = Geocoder(context, Locale.getDefault())
@Suppress("DEPRECATION")
val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1)
if (!addresses.isNullOrEmpty()) {
val address = addresses[0]
val street = address.thoroughfare
val number = address.subThoroughfare
val text = if (street != null && number != null) "$street, $number" else street ?: address.getAddressLine(0) ?: context.getString(R.string.unknown_location)
_uiState.update { it.copy(currentAddress = text) }
}
} catch (ignore: Exception) {}
}
}
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
fun showMoreStations() {
_uiState.update { it.copy(visibleStationsCount = it.visibleStationsCount + 15) }
}
fun resetStationLimit() {
_uiState.update { it.copy(visibleStationsCount = 15) }
}
fun refreshStations(forceRefresh: Boolean = false) {
val state = _uiState.value
val loc = state.lastLocation ?: return
if (state.selectedTab != 0) return
viewModelScope.launch {
try {
if (forceRefresh) _uiState.update { it.copy(isRefreshing = true, errorMessage = null) }
else if (state.stations.isEmpty()) _uiState.update { it.copy(isLoading = true, errorMessage = null) }
// 1. Fast load
val (fastStations, points) = repository.getCheapestAndNearest(
userLocation = loc,
getRealDistances = false,
forceRefresh = forceRefresh,
fuelType = state.selectedFuelType,
destinationLocation = if (state.isRouteActive) state.destinationLocation else null
)
if (fastStations.isEmpty() && forceRefresh) {
_uiState.update { it.copy(errorMessage = context.getString(R.string.no_results)) }
}
_uiState.update { it.copy(stations = fastStations, routePoints = points, isLoading = false) }
// 2. Precision load
val (realStations, _) = repository.getCheapestAndNearest(
userLocation = loc,
getRealDistances = true,
forceRefresh = false, // don't force twice
fuelType = state.selectedFuelType,
destinationLocation = if (state.isRouteActive) state.destinationLocation else null
)
if (realStations.isNotEmpty()) {
_uiState.update { it.copy(stations = realStations, isRefreshing = false) }
} else {
_uiState.update { it.copy(isRefreshing = false) }
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = context.getString(R.string.error_network)
)
}
}
}
}
init {
viewModelScope.launch {
combine(
_uiState.map { it.lastLocation }.distinctUntilChanged(),
_uiState.map { it.selectedTab }.distinctUntilChanged(),
_uiState.map { it.selectedFuelType }.distinctUntilChanged(),
_uiState.map { it.isRouteActive }.distinctUntilChanged(),
_uiState.map { it.destinationLocation }.distinctUntilChanged()
) { lastLoc, tab, _, _, _ ->
if (lastLoc != null && tab == 0) {
refreshStations()
}
}.collect()
}
}
}
@@ -11,7 +11,8 @@ data class FuelRecord(
val municipality: String,
val liters: Double,
val totalPrice: Double,
val pricePerLiter: Double
val pricePerLiter: Double,
val fuelType: String = "Diésel A"
)
object FuelStorage {
@@ -5,166 +5,6 @@
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:fillColor="#FF6D00"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
</vector>
@@ -1,30 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
<group
android:translateX="27"
android:translateY="27">
<path
android:fillColor="#FFFFFF"
android:pathData="M39.67,10.5V37.8h-2.1V10.5c0,-2.31 -1.89,-4.2 -4.2,-4.2H12.6c-2.31,0 -4.2,1.89 -4.2,4.2V44.1H37.8V39.9h2.1c2.31,0 4.2,-1.89 4.2,-4.2V18.9c0,-2.31 -1.89,-4.2 -4.2,-4.2H37.8V10.5M16.8,10.5h12.6V18.9H16.8V10.5M12.6,39.9V23.1h21V39.9H12.6Z"
android:scaleX="1.2"
android:scaleY="1.2" />
</group>
</vector>
+37 -1
View File
@@ -1,3 +1,39 @@
<resources>
<string name="app_name">Prueba</string>
<string name="app_name">Gasolineras</string>
<string name="searching_location">Buscando ubicación…</string>
<string name="unknown_location">Ubicación desconocida</string>
<string name="title_diesel">Gasolineras Diésel</string>
<string name="last_updated">Actualizado: %1$s</string>
<string name="nav_stations">Gasolineras</string>
<string name="nav_history">Historial</string>
<string name="nav_map">Mapa</string>
<string name="permission_needed">Se necesita permiso de ubicación</string>
<string name="give_permission">Dar permiso</string>
<string name="no_results">Sin resultados</string>
<string name="no_records">Sin registros</string>
<string name="log_fuel">Registrar Repostaje</string>
<string name="liters">Litros</string>
<string name="total_price">Precio Total (€)</string>
<string name="save">Guardar</string>
<string name="cancel">Cancelar</string>
<string name="error_network">Error de conexión. Comprueba tu internet.</string>
<string name="error_generic">Ha ocurrido un error inesperado.</string>
<string name="error_location">No se pudo obtener la dirección.</string>
<string name="diesel_a">Diésel A: %1$s €/L</string>
<string name="premium">Premium: %1$s €/L</string>
<string name="log_fuel_desc">Registrar repostaje</string>
<string name="show_more">Ver más resultados</string>
<string name="show_less">Ver menos</string>
<string name="where_to_go">¿A dónde vas?</string>
<string name="on_route">En ruta</string>
<string name="fuel_type">Tipo de combustible</string>
<string name="diesel_a_label">Diésel A</string>
<string name="premium_label">Premium</string>
<string name="gas95_label">Gasolina 95</string>
<string name="gas98_label">Gasolina 98</string>
<string name="glp_label">GLP</string>
<string name="gnc_label">GNC</string>
<string name="gnl_label">GNL</string>
<string name="diesel_b_label">Diésel B</string>
<string name="biodiesel_label">Biodiesel</string>
</resources>
+7
View File
@@ -10,7 +10,9 @@ kotlin = "2.2.10"
composeBom = "2026.02.01"
androidx-car-app = "1.7.0"
retrofit = "2.9.0"
okhttp = "4.12.0"
playServicesLocation = "21.3.0"
osmdroid = "6.1.20"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -18,6 +20,8 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
@@ -31,7 +35,10 @@ androidx-compose-material-icons = { group = "androidx.compose.material", name =
androidx-car-app = { group = "androidx.car.app", name = "app", version.ref = "androidx-car-app" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }