From 087053c959a7a4bce496285e9b995e420425824c Mon Sep 17 00:00:00 2001 From: Pedro Javier Date: Tue, 11 Aug 2026 13:48:13 +0200 Subject: [PATCH] Gasolinera app --- .idea/planningMode.xml | 1 + app/build.gradle.kts | 5 + .../implementation_plan.artifact.md | 40 + .../java/com/example/prueba/MainActivity.kt | 689 +++++++++++++----- .../java/com/example/prueba/MainScreen.kt | 6 +- .../prueba/data/GasStationRepository.kt | 163 ++++- .../com/example/prueba/data/GasolineraApi.kt | 34 +- .../java/com/example/prueba/data/OsrmApi.kt | 11 +- .../example/prueba/ui/GasStationViewModel.kt | 247 +++++++ .../com/example/prueba/utils/FuelStorage.kt | 3 +- .../res/drawable/ic_launcher_background.xml | 164 +---- .../res/drawable/ic_launcher_foreground.xml | 34 +- app/src/main/res/values/strings.xml | 38 +- gradle/libs.versions.toml | 7 + 14 files changed, 1013 insertions(+), 429 deletions(-) create mode 100644 app/src/main/java/com/example/prueba/.artifacts/implementation_plan.artifact.md create mode 100644 app/src/main/java/com/example/prueba/ui/GasStationViewModel.kt diff --git a/.idea/planningMode.xml b/.idea/planningMode.xml index d5006ec..7fa12df 100644 --- a/.idea/planningMode.xml +++ b/.idea/planningMode.xml @@ -3,6 +3,7 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index df968e7..4ae0549 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/example/prueba/.artifacts/implementation_plan.artifact.md b/app/src/main/java/com/example/prueba/.artifacts/implementation_plan.artifact.md new file mode 100644 index 0000000..cd1abbe --- /dev/null +++ b/app/src/main/java/com/example/prueba/.artifacts/implementation_plan.artifact.md @@ -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. diff --git a/app/src/main/java/com/example/prueba/MainActivity.kt b/app/src/main/java/com/example/prueba/MainActivity.kt index aec3036..973b02b 100644 --- a/app/src/main/java/com/example/prueba/MainActivity.kt +++ b/app/src/main/java/com/example/prueba/MainActivity.kt @@ -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>(emptyList()) } - var lastLocation by remember { mutableStateOf(null) } - var fuelRecords by remember { mutableStateOf(FuelStorage.getRecords(context)) } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + var showFuelDialog by remember { mutableStateOf(null) } var editRecordDialog by remember { mutableStateOf(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(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, onLogClick: (GasStationInfo) -> Unit) { +fun GasStationMap( + stations: List, + routePoints: List, + 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, + 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, 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, 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, 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, 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) ) } diff --git a/app/src/main/java/com/example/prueba/MainScreen.kt b/app/src/main/java/com/example/prueba/MainScreen.kt index 8b96cc0..9a779e0 100644 --- a/app/src/main/java/com/example/prueba/MainScreen.kt +++ b/app/src/main/java/com/example/prueba/MainScreen.kt @@ -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() } diff --git a/app/src/main/java/com/example/prueba/data/GasStationRepository.kt b/app/src/main/java/com/example/prueba/data/GasStationRepository.kt index 42b0fcb..3579af3 100644 --- a/app/src/main/java/com/example/prueba/data/GasStationRepository.kt +++ b/app/src/main/java/com/example/prueba/data/GasStationRepository.kt @@ -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 { - return try { + getRealDistances: Boolean = false, + fuelType: FuelType = FuelType.DIESEL_A, + destinationLocation: Location? = null + ): Pair, List> = 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() to emptyList() - 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() to emptyList() } } } 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 + } +} diff --git a/app/src/main/java/com/example/prueba/data/GasolineraApi.kt b/app/src/main/java/com/example/prueba/data/GasolineraApi.kt index f4e1fd3..3fdba5f 100644 --- a/app/src/main/java/com/example/prueba/data/GasolineraApi.kt +++ b/app/src/main/java/com/example/prueba/data/GasolineraApi.kt @@ -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 ) 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 { diff --git a/app/src/main/java/com/example/prueba/data/OsrmApi.kt b/app/src/main/java/com/example/prueba/data/OsrmApi.kt index 8ae1b7b..b786bb6 100644 --- a/app/src/main/java/com/example/prueba/data/OsrmApi.kt +++ b/app/src/main/java/com/example/prueba/data/OsrmApi.kt @@ -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> // [[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 diff --git a/app/src/main/java/com/example/prueba/ui/GasStationViewModel.kt b/app/src/main/java/com/example/prueba/ui/GasStationViewModel.kt new file mode 100644 index 0000000..525183b --- /dev/null +++ b/app/src/main/java/com/example/prueba/ui/GasStationViewModel.kt @@ -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 = emptyList(), + val lastLocation: Location? = null, + val fuelRecords: List = 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 = emptyList() +) + +class GasStationViewModel( + application: Application +) : AndroidViewModel(application) { + + private val repository: GasStationRepository = GasStationRepository() + private val context get() = getApplication().applicationContext + + private val _uiState = MutableStateFlow(GasStationUiState()) + val uiState: StateFlow = _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() + } + } +} diff --git a/app/src/main/java/com/example/prueba/utils/FuelStorage.kt b/app/src/main/java/com/example/prueba/utils/FuelStorage.kt index 6572bfe..8fdf339 100644 --- a/app/src/main/java/com/example/prueba/utils/FuelStorage.kt +++ b/app/src/main/java/com/example/prueba/utils/FuelStorage.kt @@ -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 { diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9..eaa6cea 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -5,166 +5,6 @@ android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d1..8b844f2 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,16 @@ + - - - - - - - - - + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fae7929..3b05225 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,39 @@ - Prueba + Gasolineras + Buscando ubicación… + Ubicación desconocida + Gasolineras Diésel + Actualizado: %1$s + Gasolineras + Historial + Mapa + Se necesita permiso de ubicación + Dar permiso + Sin resultados + Sin registros + Registrar Repostaje + Litros + Precio Total (€) + Guardar + Cancelar + Error de conexión. Comprueba tu internet. + Ha ocurrido un error inesperado. + No se pudo obtener la dirección. + Diésel A: %1$s €/L + Premium: %1$s €/L + Registrar repostaje + Ver más resultados + Ver menos + ¿A dónde vas? + En ruta + Tipo de combustible + Diésel A + Premium + Gasolina 95 + Gasolina 98 + GLP + GNC + GNL + Diésel B + Biodiesel \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9543fda..152e652 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }