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