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"> <component name="PlanningModeManager">
<option name="approvalStates"> <option name="approvalStates">
<map> <map>
<entry key="45c0c769-704c-4782-94b9-85d5d4831c11" value="false" />
<entry key="f9d713bb-3de6-48b7-9013-a05b442ed5a5" value="false" /> <entry key="f9d713bb-3de6-48b7-9013-a05b442ed5a5" value="false" />
</map> </map>
</option> </option>
+5
View File
@@ -45,10 +45,15 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.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.androidx.car.app)
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.retrofit.gson) implementation(libs.retrofit.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.play.services.location) implementation(libs.play.services.location)
implementation(libs.osmdroid)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) 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.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.location.Geocoder
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -25,31 +24,41 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat 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.GasStationInfo
import com.example.prueba.data.GasStationRepository
import com.example.prueba.data.PriceCategory import com.example.prueba.data.PriceCategory
import com.example.prueba.ui.GasStationViewModel
import com.example.prueba.ui.theme.PruebaTheme import com.example.prueba.ui.theme.PruebaTheme
import com.example.prueba.utils.FuelRecord import com.example.prueba.utils.FuelRecord
import com.example.prueba.utils.FuelStorage import android.content.Context
import com.example.prueba.utils.LocationStorage import org.osmdroid.config.Configuration
import com.google.android.gms.location.* import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import kotlinx.coroutines.Dispatchers import org.osmdroid.util.GeoPoint
import kotlinx.coroutines.launch import org.osmdroid.views.MapView
import kotlinx.coroutines.withContext import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import android.preference.PreferenceManager
import java.util.Locale import java.util.Locale
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val repository = GasStationRepository()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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() enableEdgeToEdge()
setContent { setContent {
PruebaTheme { PruebaTheme {
GasStationApp(repository) GasStationApp()
} }
} }
} }
@@ -57,242 +66,199 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun GasStationApp(repository: GasStationRepository) { fun GasStationApp(viewModel: GasStationViewModel = viewModel()) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var stations by remember { mutableStateOf<List<GasStationInfo>>(emptyList()) } val snackbarHostState = remember { SnackbarHostState() }
var lastLocation by remember { mutableStateOf<android.location.Location?>(null) }
var fuelRecords by remember { mutableStateOf(FuelStorage.getRecords(context)) }
var showFuelDialog by remember { mutableStateOf<GasStationInfo?>(null) } var showFuelDialog by remember { mutableStateOf<GasStationInfo?>(null) }
var editRecordDialog by remember { mutableStateOf<FuelRecord?>(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( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
) { isGranted -> ) { isGranted ->
hasPermission = isGranted viewModel.updatePermission(isGranted)
} }
suspend fun updateAddress(location: android.location.Location) { LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { viewModel.updatePermission(
try { ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
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) {}
}
}
// 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
) )
viewModel.loadFuelRecords()
}
// 2. Carga de precisión (carretera) LaunchedEffect(uiState.errorMessage) {
val realStations = repository.getCheapestAndNearest( uiState.errorMessage?.let {
userLocation = it, snackbarHostState.showSnackbar(it)
destinationLocation = if (isRouteActive) destinationLocation else null, viewModel.clearError()
getRealDistances = true
)
if (realStations.isNotEmpty()) {
stations = realStations
}
isLoading = false
}
}
if (selectedTab == 0 && stations.isEmpty()) isLoading = true
} }
} }
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
Column { Column {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { title = {
if (isRouteActive) { if (uiState.isRouteActive) {
TextField( TextField(
value = destinationQuery, value = uiState.destinationQuery,
onValueChange = { destinationQuery = it }, onValueChange = { viewModel.updateDestinationQuery(it) },
placeholder = { Text("¿A dónde vas?") }, placeholder = { Text(stringResource(R.string.where_to_go)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
singleLine = true, singleLine = true,
trailingIcon = { trailingIcon = {
Row { Row {
IconButton(onClick = { IconButton(onClick = { viewModel.searchDestination() }) { Icon(Icons.Default.DirectionsCar, null) }
scope.launch { IconButton(onClick = { viewModel.setRouteActive(false) }) { Icon(Icons.Default.Close, null) }
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) }
} }
} }
) )
} else { } else {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Gasolineras Diésel", style = MaterialTheme.typography.titleSmall) Text(
Text(text = currentAddress, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, maxLines = 1) 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 = { actions = {
if (!isRouteActive && selectedTab == 0) { if (!uiState.isRouteActive && uiState.selectedTab == 0) {
IconButton(onClick = { isRouteActive = true }) { IconButton(onClick = { viewModel.setRouteActive(true) }) {
Icon(Icons.AutoMirrored.Filled.AltRoute, "En ruta") Icon(Icons.AutoMirrored.Filled.AltRoute, stringResource(R.string.on_route))
} }
} }
} }
) )
if (uiState.selectedTab == 0) {
FuelTypeSelector(
selectedType = uiState.selectedFuelType,
onTypeSelected = { viewModel.selectFuelType(it) }
)
}
} }
}, },
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
NavigationBarItem( NavigationBarItem(
icon = { Icon(Icons.Default.LocalGasStation, null) }, icon = { Icon(Icons.Default.LocalGasStation, null) },
label = { Text("Gasolineras") }, label = { Text(stringResource(R.string.nav_stations)) },
selected = selectedTab == 0, selected = uiState.selectedTab == 0,
onClick = { selectedTab = 0 } onClick = { viewModel.selectTab(0) }
) )
NavigationBarItem( NavigationBarItem(
icon = { Icon(Icons.Default.History, null) }, icon = { Icon(Icons.Default.History, null) },
label = { Text("Historial") }, label = { Text(stringResource(R.string.nav_history)) },
selected = selectedTab == 1, selected = uiState.selectedTab == 1,
onClick = { 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 -> ) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) { Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
if (!hasPermission) { if (!uiState.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") Text(stringResource(R.string.permission_needed))
Button(onClick = { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }) { Text("Dar permiso") } Button(onClick = { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }) { Text(stringResource(R.string.give_permission)) }
} }
} else { } else {
when (selectedTab) { when (uiState.selectedTab) {
0 -> { 0 -> {
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = uiState.isRefreshing,
onRefresh = { onRefresh = { viewModel.refreshStations(forceRefresh = true) },
lastLocation?.let { loc ->
isRefreshing = true
scope.launch {
stations = repository.getCheapestAndNearest(
userLocation = loc, forceRefresh = true,
destinationLocation = if (isRouteActive) destinationLocation else null,
getRealDistances = true
)
isRefreshing = false
}
}
},
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
if (isLoading && stations.isEmpty()) { if (uiState.isLoading && uiState.stations.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (stations.isEmpty()) { } else if (uiState.stations.isEmpty()) {
Text("Sin resultados", modifier = Modifier.align(Alignment.Center)) Text(stringResource(R.string.no_results), modifier = Modifier.align(Alignment.Center))
} else { } 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 -> { 1 -> {
FuelHistoryScreen( FuelHistoryScreen(
records = fuelRecords, records = uiState.fuelRecords,
onDelete = { record -> onDelete = { record -> viewModel.deleteFuelRecord(record) },
FuelStorage.deleteRecord(context, record)
fuelRecords = FuelStorage.getRecords(context)
},
onEdit = { record -> editRecordDialog = record } onEdit = { record -> editRecordDialog = record }
) )
} }
2 -> {
GasStationMap(
stations = uiState.stations,
routePoints = uiState.routePoints,
userLocation = uiState.lastLocation,
selectedFuelType = uiState.selectedFuelType
)
}
} }
} }
showFuelDialog?.let { station -> showFuelDialog?.let { station ->
FuelLogDialog( FuelLogDialog(
stationName = station.name, 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 }, onDismiss = { showFuelDialog = null },
onConfirm = { liters, total -> onConfirm = { liters, total, type, pricePerLiter ->
val record = FuelRecord( val record = FuelRecord(
date = System.currentTimeMillis(), date = System.currentTimeMillis(),
stationName = station.name, stationName = station.name,
@@ -300,10 +266,10 @@ fun GasStationApp(repository: GasStationRepository) {
municipality = station.municipality, municipality = station.municipality,
liters = liters, liters = liters,
totalPrice = total, totalPrice = total,
pricePerLiter = station.price pricePerLiter = pricePerLiter,
fuelType = type
) )
FuelStorage.saveRecord(context, record) viewModel.saveFuelRecord(record)
fuelRecords = FuelStorage.getRecords(context)
showFuelDialog = null showFuelDialog = null
} }
) )
@@ -314,11 +280,16 @@ fun GasStationApp(repository: GasStationRepository) {
stationName = record.stationName, stationName = record.stationName,
initialLiters = record.liters.toString(), initialLiters = record.liters.toString(),
initialTotal = record.totalPrice.toString(), initialTotal = record.totalPrice.toString(),
initialType = record.fuelType,
onDismiss = { editRecordDialog = null }, onDismiss = { editRecordDialog = null },
onConfirm = { liters, total -> onConfirm = { liters, total, type, pricePerLiter ->
val newRecord = record.copy(liters = liters, totalPrice = total) val newRecord = record.copy(
FuelStorage.updateRecord(context, record, newRecord) liters = liters,
fuelRecords = FuelStorage.getRecords(context) totalPrice = total,
fuelType = type,
pricePerLiter = pricePerLiter
)
viewModel.updateFuelRecord(record, newRecord)
editRecordDialog = null editRecordDialog = null
} }
) )
@@ -328,12 +299,124 @@ fun GasStationApp(repository: GasStationRepository) {
} }
@Composable @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 context = LocalContext.current
val visibleStations = stations.take(visibleCount)
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) { LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(stations) { station -> items(visibleStations) { station ->
GasStationItem( GasStationItem(
station = station, station = station,
selectedFuelType = selectedFuelType,
onClick = { onClick = {
val uri = Uri.parse("geo:${station.latitude},${station.longitude}?q=${station.latitude},${station.longitude}(${Uri.encode(station.name)})") 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)) context.startActivity(Intent(Intent.ACTION_VIEW, uri))
@@ -341,13 +424,39 @@ fun GasStationList(stations: List<GasStationInfo>, onLogClick: (GasStationInfo)
onLogClick = { onLogClick(station) } 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 @Composable
fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit, onEdit: (FuelRecord) -> Unit) { fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit, onEdit: (FuelRecord) -> Unit) {
if (records.isEmpty()) { 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 { } else {
LazyColumn(contentPadding = PaddingValues(16.dp)) { LazyColumn(contentPadding = PaddingValues(16.dp)) {
items(records) { record -> 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) Text(android.text.format.DateFormat.format("dd/MM/yyyy", record.date).toString(), style = MaterialTheme.typography.labelSmall)
Spacer(modifier = Modifier.height(4.dp)) 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) 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 @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 liters by remember { mutableStateOf(initialLiters) }
var total by remember { mutableStateOf(initialTotal) } 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( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Registrar Repostaje") }, title = { Text(stringResource(R.string.log_fuel)) },
text = { text = {
Column { Column {
Text(stationName, style = MaterialTheme.typography.bodyLarge) 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)) Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(value = liters, onValueChange = { liters = it }, label = { Text("Litros") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(
OutlinedTextField(value = total, onValueChange = { total = it }, label = { Text("Precio Total (€)") }, modifier = Modifier.fillMaxWidth()) 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 = { confirmButton = {
Button(onClick = { Button(onClick = {
val l = liters.toDoubleOrNull() ?: 0.0 val l = liters.toDoubleOrNull() ?: 0.0
val t = total.toDoubleOrNull() ?: 0.0 val t = total.toDoubleOrNull() ?: 0.0
if (l > 0 && t > 0) onConfirm(l, t) if (l > 0 && t > 0) onConfirm(l, t, selectedType, if (l > 0) t / l else currentPrice)
}) { Text("Guardar") } }) { Text(stringResource(R.string.save)) }
}, },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancelar") } } dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }
) )
} }
@Composable @Composable
fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: () -> Unit) { fun GasStationItem(station: GasStationInfo, selectedFuelType: FuelType, onClick: () -> Unit, onLogClick: () -> Unit) {
val priceColor = when (station.priceCategory) { val priceColor = when (station.priceCategory) {
PriceCategory.CHEAP -> Color(0xFF2E7D32) PriceCategory.CHEAP -> Color(0xFF2E7D32)
PriceCategory.NORMAL -> MaterialTheme.colorScheme.primary 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.name} - ${station.municipality}", style = MaterialTheme.typography.titleMedium, maxLines = 1, modifier = Modifier.weight(1f))
} }
Text(text = station.address, style = MaterialTheme.typography.bodySmall, maxLines = 1) 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) { Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.medium) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally) { 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)) 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 { Column {
Text(text = "Diésel A: ${station.price} €/L", style = MaterialTheme.typography.titleLarge, color = priceColor, fontWeight = FontWeight.Bold) // Combustible Seleccionado (Principal)
station.pricePremium?.let { premium -> val mainPrice = station.getPriceFor(selectedFuelType)
Text(text = "Premium: $premium €/L", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, fontWeight = FontWeight.Medium) 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( FilledTonalIconButton(
@@ -460,7 +761,7 @@ fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: ()
) { ) {
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,
contentDescription = "Registrar repostaje", contentDescription = stringResource(R.string.log_fuel_desc),
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
@@ -56,12 +56,12 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
gasStations = repository.getCheapestAndNearest( gasStations = repository.getCheapestAndNearest(
userLocation = lastLoc, userLocation = lastLoc,
getRealDistances = false getRealDistances = false
) ).first
invalidate() invalidate()
gasStations = repository.getCheapestAndNearest( gasStations = repository.getCheapestAndNearest(
userLocation = lastLoc, userLocation = lastLoc,
getRealDistances = true getRealDistances = true
) ).first
invalidate() invalidate()
} }
} }
@@ -81,7 +81,7 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
userLocation = location, userLocation = location,
forceRefresh = forceRefresh, forceRefresh = forceRefresh,
getRealDistances = true getRealDistances = true
) ).first
isLoading = false isLoading = false
invalidate() invalidate()
} }
@@ -1,23 +1,39 @@
package com.example.prueba.data package com.example.prueba.data
import android.location.Location import android.location.Location
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
class GasStationRepository { class GasStationRepository {
private var cachedResponse: GasolineraResponse? = null 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() private val api: GasolineraApi = Retrofit.Builder()
.baseUrl("https://sedeaplicaciones.minetur.gob.es/ServiciosRESTCarburantes/PreciosCarburantes/") .baseUrl("https://sedeaplicaciones.minetur.gob.es/ServiciosRESTCarburantes/PreciosCarburantes/")
.client(client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
.create(GasolineraApi::class.java) .create(GasolineraApi::class.java)
private val routeApi: OsrmApi = Retrofit.Builder() private val routeApi: OsrmApi = Retrofit.Builder()
.baseUrl("https://router.project-osrm.org/") .baseUrl("https://router.project-osrm.org/")
.client(client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
.create(OsrmApi::class.java) .create(OsrmApi::class.java)
@@ -25,46 +41,95 @@ class GasStationRepository {
suspend fun getCheapestAndNearest( suspend fun getCheapestAndNearest(
userLocation: Location, userLocation: Location,
forceRefresh: Boolean = false, forceRefresh: Boolean = false,
destinationLocation: Location? = null, getRealDistances: Boolean = false,
getRealDistances: Boolean = false fuelType: FuelType = FuelType.DIESEL_A,
): List<GasStationInfo> { destinationLocation: Location? = null
return try { ): Pair<List<GasStationInfo>, List<Location>> = withContext(Dispatchers.Default) {
try {
if (cachedResponse == null || forceRefresh) { if (cachedResponse == null || forceRefresh) {
cachedResponse = api.getGasolineras() 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 allStations = cachedResponse?.listaEESS?.mapNotNull {
val lat = it.latitud.replace(",", ".").toDoubleOrNull() val lat = it.latitud?.replace(",", ".")?.toDoubleOrNull()
val lon = it.longitud.replace(",", ".").toDoubleOrNull() val lon = it.longitud?.replace(",", ".")?.toDoubleOrNull()
if (lat != null && lon != null) { if (lat != null && lon != null) {
val dieselPrice = it.precioDiesel.replace(",", ".").toDoubleOrNull() val dieselPrice = it.precioDiesel?.replace(",", ".")?.toDoubleOrNull()
val premiumPrice = it.precioDieselPremium.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 { val stationLoc = Location("").apply {
latitude = lat latitude = lat
longitude = lon 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) { if (destinationLocation != null) {
val distUserToDest = userLocation.distanceTo(destinationLocation) if (routePoints.isEmpty()) {
val distStationToDest = stationLoc.distanceTo(destinationLocation) val distStationToDest = stationLoc.distanceTo(destinationLocation)
isOnRoute = (distanceToUser + distStationToDest) <= (distUserToDest * 1.15) 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( GasStationInfo(
id = it.id, id = it.id ?: "",
name = it.rotulo, name = it.rotulo ?: "Sin nombre",
address = it.direccion, address = it.direccion ?: "Sin dirección",
municipality = it.municipio, municipality = it.municipio ?: "",
horario = it.horario ?: "",
updateTime = cachedResponse?.fecha ?: "",
price = dieselPrice ?: 0.0, price = dieselPrice ?: 0.0,
pricePremium = premiumPrice, pricePremium = premiumPrice,
distance = distanceToUser, price95 = gas95Price,
price98 = gas98Price,
priceGLP = glpPrice,
priceGNC = gncPrice,
priceGNL = gnlPrice,
priceDieselB = dieselBPrice,
priceBiodiesel = biodieselPrice,
distance = distToUser,
latitude = lat, latitude = lat,
longitude = lon longitude = lon
) )
@@ -73,25 +138,34 @@ class GasStationRepository {
} else null } else null
} ?: emptyList() } ?: 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 finalMaxDist = if (destinationLocation == null) 30.0 else 400.0
val filtered = allStations.filter { (it.distance / 1000.0) <= finalMaxDist } 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 pricesForType = filtered.mapNotNull { it.getPriceFor(fuelType) }.filter { it > 0.1 }
val avgPrice = filtered.map { it.price }.average() 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 -> var results = filtered.map { station ->
val price = station.getPriceFor(fuelType) ?: 0.0
val category = when { val category = when {
station.price <= minPrice * 1.01 -> PriceCategory.CHEAP price <= 0.1 -> PriceCategory.NORMAL
station.price <= avgPrice -> PriceCategory.NORMAL price <= minPrice * 1.01 -> PriceCategory.CHEAP
price <= avgPrice -> PriceCategory.NORMAL
else -> PriceCategory.EXPENSIVE else -> PriceCategory.EXPENSIVE
} }
station.copy(priceCategory = category) 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) { if (getRealDistances) {
coroutineScope { coroutineScope {
@@ -116,25 +190,48 @@ class GasStationRepository {
} }
} }
results results to routePoints
} catch (e: Exception) { } catch (e: Exception) {
emptyList() emptyList<GasStationInfo>() to emptyList<Location>()
} }
} }
} }
enum class PriceCategory { CHEAP, NORMAL, EXPENSIVE } 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( data class GasStationInfo(
val id: String, val id: String,
val name: String, val name: String,
val address: String, val address: String,
val municipality: String, val municipality: String,
val horario: String = "",
val updateTime: String = "",
val price: Double, val price: Double,
val pricePremium: Double? = null, 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 distance: Float,
val durationMinutes: Int? = null, val durationMinutes: Int? = null,
val latitude: Double, val latitude: Double,
val longitude: Double, val longitude: Double,
val priceCategory: PriceCategory = PriceCategory.NORMAL 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 import retrofit2.http.GET
data class GasolineraResponse( data class GasolineraResponse(
@SerializedName("Fecha")
val fecha: String,
@SerializedName("ListaEESSPrecio") @SerializedName("ListaEESSPrecio")
val listaEESS: List<Gasolinera> val listaEESS: List<Gasolinera>
) )
data class Gasolinera( data class Gasolinera(
@SerializedName("IDEESS") @SerializedName("IDEESS")
val id: String, val id: String?,
@SerializedName("Rótulo") @SerializedName("Rótulo")
val rotulo: String, val rotulo: String?,
@SerializedName("Dirección") @SerializedName("Dirección")
val direccion: String, val direccion: String?,
@SerializedName("Municipio") @SerializedName("Municipio")
val municipio: String, val municipio: String?,
@SerializedName("Horario")
val horario: String?,
@SerializedName("Precio Gasoleo A") @SerializedName("Precio Gasoleo A")
val precioDiesel: String, val precioDiesel: String?,
@SerializedName("Precio Gasoleo Premium") @SerializedName("Precio Gasoleo Premium")
val precioDieselPremium: String, val precioDieselPremium: String?,
@SerializedName("Precio Gasolina 95 E5") @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") @SerializedName("Latitud")
val latitud: String, val latitud: String?,
@SerializedName("Longitud (WGS84)") @SerializedName("Longitud (WGS84)")
val longitud: String val longitud: String?
) )
interface GasolineraApi { interface GasolineraApi {
@@ -13,11 +13,18 @@ data class OsrmRoute(
@SerializedName("distance") @SerializedName("distance")
val distance: Double, // En metros val distance: Double, // En metros
@SerializedName("duration") @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 { interface OsrmApi {
@GET("route/v1/driving/{coords}?overview=false") @GET("route/v1/driving/{coords}?overview=full&geometries=geojson")
suspend fun getRoute( suspend fun getRoute(
@Path("coords") coords: String @Path("coords") coords: String
): OsrmResponse ): 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 municipality: String,
val liters: Double, val liters: Double,
val totalPrice: Double, val totalPrice: Double,
val pricePerLiter: Double val pricePerLiter: Double,
val fuelType: String = "Diésel A"
) )
object FuelStorage { object FuelStorage {
@@ -5,166 +5,6 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path <path
android:fillColor="#3DDC84" android:fillColor="#FF6D00"
android:pathData="M0,0h108v108h-108z" /> 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" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="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"> <group
<aapt:attr name="android:fillColor"> android:translateX="27"
<gradient android:translateY="27">
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 <path
android:fillColor="#FFFFFF" android:fillColor="#FFFFFF"
android:fillType="nonZero" 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: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:scaleX="1.2"
android:strokeWidth="1" android:scaleY="1.2" />
android:strokeColor="#00000000" /> </group>
</vector> </vector>
+37 -1
View File
@@ -1,3 +1,39 @@
<resources> <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> </resources>
+7
View File
@@ -10,7 +10,9 @@ kotlin = "2.2.10"
composeBom = "2026.02.01" composeBom = "2026.02.01"
androidx-car-app = "1.7.0" androidx-car-app = "1.7.0"
retrofit = "2.9.0" retrofit = "2.9.0"
okhttp = "4.12.0"
playServicesLocation = "21.3.0" playServicesLocation = "21.3.0"
osmdroid = "6.1.20"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 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-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-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-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } 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" } 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 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", 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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }