Gasolinera app

This commit is contained in:
2026-08-14 13:58:02 +02:00
parent 087053c959
commit 4655f0008c
9 changed files with 753 additions and 101 deletions
+50
View File
@@ -0,0 +1,50 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
</profile>
</component>
+10 -8
View File
@@ -3,15 +3,13 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-feature
android:name="android.hardware.type.automotive"
android:required="false" />
<uses-feature <uses-feature
android:name="android.software.car.templates_host" android:name="android.software.car.templates_host"
android:required="false" /> android:required="true" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -23,12 +21,12 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Prueba"> android:theme="@style/Theme.Prueba">
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data <meta-data
android:name="androidx.car.app.minCarApiLevel" android:name="androidx.car.app.minCarApiLevel"
android:value="1" /> android:value="1" />
<meta-data
android:name="androidx.car.app.category"
android:value="poi" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -38,15 +36,19 @@
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Indicar al sistema que la actividad está optimizada para el coche -->
<meta-data
android:name="distractionOptimized"
android:value="true" />
</activity> </activity>
<service <service
android:name=".MyCarAppService" android:name=".MyCarAppService"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:permission="androidx.car.app.PERMISSION_CAR_APP"> android:permission="androidx.car.app.PERMISSION_CAR_APP">
<intent-filter> <intent-filter>
<action android:name="androidx.car.app.CarAppService" /> <action android:name="androidx.car.app.CarAppService" />
@@ -1,6 +1,7 @@
package com.example.prueba package com.example.prueba
import android.Manifest import android.Manifest
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
@@ -10,10 +11,12 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.AltRoute import androidx.compose.material.icons.automirrored.filled.AltRoute
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
@@ -23,7 +26,9 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -37,14 +42,13 @@ import com.example.prueba.data.PriceCategory
import com.example.prueba.ui.GasStationViewModel import com.example.prueba.ui.GasStationViewModel
import com.example.prueba.ui.theme.PruebaTheme import com.example.prueba.ui.theme.PruebaTheme
import com.example.prueba.utils.FuelRecord import com.example.prueba.utils.FuelRecord
import android.content.Context
import org.osmdroid.config.Configuration import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline import org.osmdroid.views.overlay.Polyline
import android.preference.PreferenceManager import java.io.File
import java.util.Locale import java.util.Locale
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -52,8 +56,9 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// OSMDroid configuration // OSMDroid configuration
Configuration.getInstance().load(this, getSharedPreferences("osmdroid", Context.MODE_PRIVATE)) val osmConfig = Configuration.getInstance()
Configuration.getInstance().userAgentValue = "GasolinerasApp/1.0 (Android; Contact: support@example.com)" osmConfig.userAgentValue = "Mozilla/5.0 (Android) GasolinerasApp/1.0"
osmConfig.load(this, getSharedPreferences("osmdroid_prefs", MODE_PRIVATE))
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
@@ -73,6 +78,8 @@ fun GasStationApp(viewModel: GasStationViewModel = viewModel()) {
var showFuelDialog by remember { mutableStateOf<GasStationInfo?>(null) } var showFuelDialog by remember { mutableStateOf<GasStationInfo?>(null) }
var editRecordDialog by remember { mutableStateOf<FuelRecord?>(null) } var editRecordDialog by remember { mutableStateOf<FuelRecord?>(null) }
var selectedStationForMap by remember { mutableStateOf<GasStationInfo?>(null) }
var showHistoryFilter by remember { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
@@ -148,14 +155,36 @@ fun GasStationApp(viewModel: GasStationViewModel = viewModel()) {
} }
}, },
actions = { actions = {
if (!uiState.isRouteActive && uiState.selectedTab == 0) { // Filtro de favoritos (solo en lista o mapa)
if (uiState.selectedTab == 0 || uiState.selectedTab == 2) {
IconButton(onClick = { viewModel.toggleFavoritesFilter() }) {
Icon(
imageVector = if (uiState.showOnlyFavorites) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = stringResource(R.string.show_favorites),
tint = if (uiState.showOnlyFavorites) Color.Red else LocalContentColor.current
)
}
}
// Filtro de historial
if (uiState.selectedTab == 1) {
IconButton(onClick = { showHistoryFilter = true }) {
Icon(
imageVector = if (uiState.filterMonth != null || uiState.filterYear != null) Icons.Default.FilterList else Icons.Default.FilterListOff,
contentDescription = stringResource(R.string.filter_history),
tint = if (uiState.filterMonth != null || uiState.filterYear != null) MaterialTheme.colorScheme.primary else LocalContentColor.current
)
}
}
if (!uiState.isRouteActive && (uiState.selectedTab == 0 || uiState.selectedTab == 2)) {
IconButton(onClick = { viewModel.setRouteActive(true) }) { IconButton(onClick = { viewModel.setRouteActive(true) }) {
Icon(Icons.AutoMirrored.Filled.AltRoute, stringResource(R.string.on_route)) Icon(Icons.AutoMirrored.Filled.AltRoute, stringResource(R.string.on_route))
} }
} }
} }
) )
if (uiState.selectedTab == 0) { if (uiState.selectedTab == 0 || uiState.selectedTab == 2) {
FuelTypeSelector( FuelTypeSelector(
selectedType = uiState.selectedFuelType, selectedType = uiState.selectedFuelType,
onTypeSelected = { viewModel.selectFuelType(it) } onTypeSelected = { viewModel.selectFuelType(it) }
@@ -209,9 +238,11 @@ fun GasStationApp(viewModel: GasStationViewModel = viewModel()) {
stations = uiState.stations, stations = uiState.stations,
visibleCount = uiState.visibleStationsCount, visibleCount = uiState.visibleStationsCount,
selectedFuelType = uiState.selectedFuelType, selectedFuelType = uiState.selectedFuelType,
favoriteIds = uiState.favoriteIds,
onLogClick = { showFuelDialog = it }, onLogClick = { showFuelDialog = it },
onShowMore = { viewModel.showMoreStations() }, onShowMore = { viewModel.showMoreStations() },
onShowLess = { viewModel.resetStationLimit() } onShowLess = { viewModel.resetStationLimit() },
onToggleFavorite = { viewModel.toggleFavorite(it) }
) )
} }
} }
@@ -219,17 +250,99 @@ fun GasStationApp(viewModel: GasStationViewModel = viewModel()) {
1 -> { 1 -> {
FuelHistoryScreen( FuelHistoryScreen(
records = uiState.fuelRecords, records = uiState.fuelRecords,
filterMonth = uiState.filterMonth,
filterYear = uiState.filterYear,
onDelete = { record -> viewModel.deleteFuelRecord(record) }, onDelete = { record -> viewModel.deleteFuelRecord(record) },
onEdit = { record -> editRecordDialog = record } onEdit = { record -> editRecordDialog = record }
) )
} }
2 -> { 2 -> {
Box(modifier = Modifier.fillMaxSize()) {
GasStationMap( GasStationMap(
stations = uiState.stations, stations = uiState.stations,
routePoints = uiState.routePoints, routePoints = uiState.routePoints,
userLocation = uiState.lastLocation, userLocation = uiState.lastLocation,
selectedFuelType = uiState.selectedFuelType selectedFuelType = uiState.selectedFuelType,
favoriteIds = uiState.favoriteIds,
onMapMoved = { lat, lon -> viewModel.updateMapCenter(lat, lon) },
onStationSelected = { selectedStationForMap = it }
) )
// Botón flotante para buscar en la zona del mapa
Button(
onClick = { viewModel.searchAtMapCenter() },
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 16.dp),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 8.dp)
) {
Icon(Icons.Default.Search, null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.search_this_area))
}
// Tarjeta informativa al seleccionar una gasolinera
selectedStationForMap?.let { station ->
Card(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp, start = 16.dp, end = 16.dp)
.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(station.name, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
IconButton(onClick = { viewModel.toggleFavorite(station.id) }) {
Icon(
imageVector = if (uiState.favoriteIds.contains(station.id)) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = null,
tint = if (uiState.favoriteIds.contains(station.id)) Color.Red else LocalContentColor.current
)
}
}
Text(station.address, style = MaterialTheme.typography.bodySmall)
val price = station.getPriceFor(uiState.selectedFuelType)
Text(
text = "${uiState.selectedFuelType}: $price €/L",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
IconButton(onClick = { selectedStationForMap = null }) {
Icon(Icons.Default.Close, null)
}
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
val uri = "waze://?ll=${station.latitude},${station.longitude}&navigate=yes"
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
try {
context.startActivity(intent)
} catch (e: Exception) {
// Fallback a Google Maps si Waze no está instalado
val gmapsUri = "google.navigation:q=${station.latitude},${station.longitude}"
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(gmapsUri)))
}
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Directions, null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.go_to_waze))
}
}
}
}
}
} }
} }
} }
@@ -258,16 +371,17 @@ fun GasStationApp(viewModel: GasStationViewModel = viewModel()) {
FuelType.BIODIESEL -> stringResource(R.string.biodiesel_label) FuelType.BIODIESEL -> stringResource(R.string.biodiesel_label)
}, },
onDismiss = { showFuelDialog = null }, onDismiss = { showFuelDialog = null },
onConfirm = { liters, total, type, pricePerLiter -> onConfirm = { liters, total, type, pricePerLiter, date, odo ->
val record = FuelRecord( val record = FuelRecord(
date = System.currentTimeMillis(), date = date,
stationName = station.name, stationName = station.name,
address = station.address, address = station.address,
municipality = station.municipality, municipality = station.municipality,
liters = liters, liters = liters,
totalPrice = total, totalPrice = total,
pricePerLiter = pricePerLiter, pricePerLiter = pricePerLiter,
fuelType = type fuelType = type,
odometer = odo
) )
viewModel.saveFuelRecord(record) viewModel.saveFuelRecord(record)
showFuelDialog = null showFuelDialog = null
@@ -275,19 +389,24 @@ fun GasStationApp(viewModel: GasStationViewModel = viewModel()) {
) )
} }
if (editRecordDialog != null) {
editRecordDialog?.let { record -> editRecordDialog?.let { record ->
FuelLogDialog( FuelLogDialog(
stationName = record.stationName, stationName = record.stationName,
initialLiters = record.liters.toString(), initialLiters = record.liters.toString(),
initialTotal = record.totalPrice.toString(), initialTotal = record.totalPrice.toString(),
initialType = record.fuelType, initialType = record.fuelType,
initialDate = record.date,
initialOdometer = record.odometer?.toString() ?: "",
onDismiss = { editRecordDialog = null }, onDismiss = { editRecordDialog = null },
onConfirm = { liters, total, type, pricePerLiter -> onConfirm = { liters, total, type, pricePerLiter, date, odo ->
val newRecord = record.copy( val newRecord = record.copy(
date = date,
liters = liters, liters = liters,
totalPrice = total, totalPrice = total,
fuelType = type, fuelType = type,
pricePerLiter = pricePerLiter pricePerLiter = pricePerLiter,
odometer = odo
) )
viewModel.updateFuelRecord(record, newRecord) viewModel.updateFuelRecord(record, newRecord)
editRecordDialog = null editRecordDialog = null
@@ -295,6 +414,20 @@ fun GasStationApp(viewModel: GasStationViewModel = viewModel()) {
) )
} }
} }
if (showHistoryFilter) {
HistoryFilterDialog(
records = uiState.fuelRecords,
currentMonth = uiState.filterMonth,
currentYear = uiState.filterYear,
onDismiss = { showHistoryFilter = false },
onConfirm = { m, y ->
viewModel.setHistoryFilters(m, y)
showHistoryFilter = false
}
)
}
}
} }
} }
@@ -303,23 +436,52 @@ fun GasStationMap(
stations: List<GasStationInfo>, stations: List<GasStationInfo>,
routePoints: List<android.location.Location>, routePoints: List<android.location.Location>,
userLocation: android.location.Location?, userLocation: android.location.Location?,
selectedFuelType: FuelType selectedFuelType: FuelType,
favoriteIds: Set<String>,
onMapMoved: (Double, Double) -> Unit,
onStationSelected: (GasStationInfo) -> Unit
) { ) {
AndroidView( AndroidView(
factory = { context -> factory = { context ->
MapView(context).apply { MapView(context).apply {
setTileSource(TileSourceFactory.MAPNIK) setTileSource(org.osmdroid.tileprovider.tilesource.XYTileSource(
"CartoVoyager", 0, 20, 256, ".png",
arrayOf("https://a.basemaps.cartocdn.com/rastertiles/voyager/",
"https://b.basemaps.cartocdn.com/rastertiles/voyager/",
"https://c.basemaps.cartocdn.com/rastertiles/voyager/")
))
setMultiTouchControls(true) setMultiTouchControls(true)
setLayerType(android.view.View.LAYER_TYPE_SOFTWARE, null)
controller.setZoom(15.0) controller.setZoom(15.0)
userLocation?.let { userLocation?.let {
controller.setCenter(GeoPoint(it.latitude, it.longitude)) controller.setCenter(GeoPoint(it.latitude, it.longitude))
} }
// Detectar movimiento del mapa
addMapListener(object : org.osmdroid.events.MapListener {
override fun onScroll(event: org.osmdroid.events.ScrollEvent?): Boolean {
onMapMoved(mapCenter.latitude, mapCenter.longitude)
return true
}
override fun onZoom(event: org.osmdroid.events.ZoomEvent?): Boolean {
onMapMoved(mapCenter.latitude, mapCenter.longitude)
return true
}
})
} }
}, },
update = { mapView -> update = { mapView ->
mapView.overlays.clear() mapView.overlays.clear()
// 1. Trazar la ruta (Polilínea) // 1. Centrar en el usuario si no hay ruta y es la primera vez que entra al mapa
if (routePoints.isEmpty() && userLocation != null && mapView.tag == null) {
mapView.controller.setCenter(GeoPoint(userLocation.latitude, userLocation.longitude))
mapView.controller.setZoom(15.0)
mapView.tag = "centered"
}
// 2. Trazar la ruta (Polilínea)
if (routePoints.isNotEmpty()) { if (routePoints.isNotEmpty()) {
val line = Polyline(mapView) val line = Polyline(mapView)
line.setPoints(routePoints.map { GeoPoint(it.latitude, it.longitude) }) line.setPoints(routePoints.map { GeoPoint(it.latitude, it.longitude) })
@@ -327,24 +489,30 @@ fun GasStationMap(
line.outlinePaint.strokeWidth = 10f line.outlinePaint.strokeWidth = 10f
mapView.overlays.add(line) mapView.overlays.add(line)
// Zoom para ajustar a la ruta la primera vez // Zoom para ajustar a la ruta si es nueva
if (mapView.tag == null) { if (mapView.tag != "route_zoomed") {
val points = routePoints.map { GeoPoint(it.latitude, it.longitude) } val points = routePoints.map { GeoPoint(it.latitude, it.longitude) }
if (points.isNotEmpty()) {
mapView.zoomToBoundingBox(org.osmdroid.util.BoundingBox.fromGeoPoints(points), true) mapView.zoomToBoundingBox(org.osmdroid.util.BoundingBox.fromGeoPoints(points), true)
mapView.tag = "zoomed" mapView.tag = "route_zoomed"
} }
} }
} else if (mapView.tag == "route_zoomed") {
// Si se desactivó la ruta, permitir volver a centrar en el usuario
mapView.tag = null
}
// 2. Marcador de usuario // 3. Marcador de usuario
userLocation?.let { userLocation?.let {
val userMarker = Marker(mapView) val userMarker = Marker(mapView)
userMarker.position = GeoPoint(it.latitude, it.longitude) userMarker.position = GeoPoint(it.latitude, it.longitude)
userMarker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) userMarker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
userMarker.title = "Tu ubicación" userMarker.title = "Tu ubicación"
userMarker.icon = ContextCompat.getDrawable(mapView.context, org.osmdroid.library.R.drawable.person)
mapView.overlays.add(userMarker) mapView.overlays.add(userMarker)
} }
// 3. Marcadores de gasolineras // 4. Marcadores de gasolineras con colores
stations.forEach { station -> stations.forEach { station ->
val marker = Marker(mapView) val marker = Marker(mapView)
marker.position = GeoPoint(station.latitude, station.longitude) marker.position = GeoPoint(station.latitude, station.longitude)
@@ -354,6 +522,27 @@ fun GasStationMap(
marker.title = "${station.name}: $price €/L" marker.title = "${station.name}: $price €/L"
marker.subDescription = station.address marker.subDescription = station.address
val isFavorite = favoriteIds.contains(station.id)
marker.setOnMarkerClickListener { m, _ ->
onStationSelected(station)
m.showInfoWindow()
true
}
val iconRes = org.osmdroid.library.R.drawable.marker_default
val drawable = ContextCompat.getDrawable(mapView.context, iconRes)?.mutate()
drawable?.let {
val color = when {
isFavorite -> android.graphics.Color.MAGENTA // Color especial para favoritos
station.priceCategory == PriceCategory.CHEAP -> android.graphics.Color.rgb(46, 125, 50) // Verde
station.priceCategory == PriceCategory.EXPENSIVE -> android.graphics.Color.rgb(198, 40, 40) // Rojo
else -> android.graphics.Color.rgb(251, 192, 45) // Amarillo
}
it.setTint(color)
marker.icon = it
}
mapView.overlays.add(marker) mapView.overlays.add(marker)
} }
@@ -405,9 +594,11 @@ fun GasStationList(
stations: List<GasStationInfo>, stations: List<GasStationInfo>,
visibleCount: Int, visibleCount: Int,
selectedFuelType: FuelType, selectedFuelType: FuelType,
favoriteIds: Set<String>,
onLogClick: (GasStationInfo) -> Unit, onLogClick: (GasStationInfo) -> Unit,
onShowMore: () -> Unit, onShowMore: () -> Unit,
onShowLess: () -> Unit onShowLess: () -> Unit,
onToggleFavorite: (String) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val visibleStations = stations.take(visibleCount) val visibleStations = stations.take(visibleCount)
@@ -417,11 +608,13 @@ fun GasStationList(
GasStationItem( GasStationItem(
station = station, station = station,
selectedFuelType = selectedFuelType, selectedFuelType = selectedFuelType,
isFavorite = favoriteIds.contains(station.id),
onClick = { onClick = {
val uri = Uri.parse("geo:${station.latitude},${station.longitude}?q=${station.latitude},${station.longitude}(${Uri.encode(station.name)})") val uri = Uri.parse("geo:${station.latitude},${station.longitude}?q=${station.latitude},${station.longitude}(${Uri.encode(station.name)})")
context.startActivity(Intent(Intent.ACTION_VIEW, uri)) context.startActivity(Intent(Intent.ACTION_VIEW, uri))
}, },
onLogClick = { onLogClick(station) } onLogClick = { onLogClick(station) },
onToggleFavorite = { onToggleFavorite(station.id) }
) )
} }
@@ -454,17 +647,133 @@ fun GasStationList(
} }
@Composable @Composable
fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit, onEdit: (FuelRecord) -> Unit) { fun FuelHistoryScreen(
if (records.isEmpty()) { records: List<FuelRecord>,
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.no_records)) } filterMonth: Int?,
} else { filterYear: Int?,
onDelete: (FuelRecord) -> Unit,
onEdit: (FuelRecord) -> Unit
) {
val context = LocalContext.current
val calInstance = java.util.Calendar.getInstance()
val nowMonth = calInstance.get(java.util.Calendar.MONTH)
val nowYear = calInstance.get(java.util.Calendar.YEAR)
val allRecordsSorted = records.sortedByDescending { it.date }
val filteredRecords = allRecordsSorted.filter {
val cal = java.util.Calendar.getInstance().apply { timeInMillis = it.date }
val mMatch = filterMonth == null || cal.get(java.util.Calendar.MONTH) == filterMonth
val yMatch = filterYear == null || cal.get(java.util.Calendar.YEAR) == filterYear
mMatch && yMatch
}
val displayMonth = filterMonth ?: nowMonth
val displayYear = filterYear ?: nowYear
val monthlyTotal = records.filter {
val cal = java.util.Calendar.getInstance().apply { timeInMillis = it.date }
cal.get(java.util.Calendar.MONTH) == displayMonth && cal.get(java.util.Calendar.YEAR) == displayYear
}.sumOf { it.totalPrice }
val yearlyTotal = records.filter {
val cal = java.util.Calendar.getInstance().apply { timeInMillis = it.date }
cal.get(java.util.Calendar.YEAR) == displayYear
}.sumOf { it.totalPrice }
LazyColumn(contentPadding = PaddingValues(16.dp)) { LazyColumn(contentPadding = PaddingValues(16.dp)) {
items(records) { record -> item {
Column {
if (filterMonth != null || filterYear != null) {
val monthResId = if (filterMonth != null) context.resources.getIdentifier("month_$filterMonth", "string", context.packageName) else 0
val monthName = if (monthResId != 0) stringResource(monthResId) else stringResource(R.string.all_months)
val yearName = filterYear?.toString() ?: stringResource(R.string.all_years)
Text(
text = "Viendo: $monthName $yearName",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Row(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Card(
modifier = Modifier.weight(1f),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(stringResource(R.string.monthly_spend), style = MaterialTheme.typography.labelMedium)
Text("${String.format(Locale.getDefault(), "%.2f", monthlyTotal)}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
}
Card(
modifier = Modifier.weight(1f),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(stringResource(R.string.yearly_spend), style = MaterialTheme.typography.labelMedium)
Text("${String.format(Locale.getDefault(), "%.2f", yearlyTotal)}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
}
}
}
}
if (filteredRecords.isEmpty()) {
item {
Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.no_records))
}
}
} else {
items(filteredRecords) { record ->
// Buscar el registro inmediatamente anterior en la lista completa (cronológicamente)
val fullIndex = allRecordsSorted.indexOf(record)
var consumption: Double? = null
if (record.odometer != null && fullIndex != -1 && fullIndex < allRecordsSorted.size - 1) {
val prevRecord = allRecordsSorted[fullIndex + 1]
if (prevRecord.odometer != null && record.odometer > prevRecord.odometer) {
val kmDiff = record.odometer - prevRecord.odometer
consumption = (record.liters / kmDiff) * 100
}
}
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val isRepsol = record.stationName.contains("REPSOL", ignoreCase = true)
val (bColor, bInitial) = when {
isRepsol -> Color(0xFFE30613) to "R"
record.stationName.contains("CEPSA", ignoreCase = true) -> Color(0xFFEC0000) to "C"
record.stationName.contains("BP", ignoreCase = true) -> Color(0xFF00A94F) to "B"
record.stationName.contains("GALP", ignoreCase = true) -> Color(0xFFFF6B00) to "G"
record.stationName.contains("SHELL", ignoreCase = true) -> Color(0xFFFFD500) to "S"
record.stationName.contains("MOEVE", ignoreCase = true) -> Color(0xFF555555) to "M"
record.stationName.contains("BALLENOIL", ignoreCase = true) -> Color(0xFF003399) to "B"
record.stationName.contains("PLENERGY", ignoreCase = true) -> Color(0xFF2E7D32) to "P"
else -> MaterialTheme.colorScheme.surfaceVariant to (record.stationName.firstOrNull()?.toString()?.uppercase() ?: "?")
}
Surface(
color = bColor,
modifier = Modifier.size(28.dp),
shape = CircleShape
) {
if (isRepsol) {
Image(
painter = painterResource(id = R.drawable.repsol_logo),
contentDescription = "Repsol",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(contentAlignment = Alignment.Center) {
Text(bInitial, style = MaterialTheme.typography.labelSmall, color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
Spacer(modifier = Modifier.width(8.dp))
Text(record.stationName, style = MaterialTheme.typography.titleMedium) Text(record.stationName, style = MaterialTheme.typography.titleMedium)
}
Text("${record.municipality} - ${record.address}", style = MaterialTheme.typography.bodySmall) Text("${record.municipality} - ${record.address}", style = MaterialTheme.typography.bodySmall)
} }
Row { Row {
@@ -474,14 +783,102 @@ fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit,
} }
} }
Text(android.text.format.DateFormat.format("dd/MM/yyyy", record.date).toString(), style = MaterialTheme.typography.labelSmall) Text(android.text.format.DateFormat.format("dd/MM/yyyy", record.date).toString(), style = MaterialTheme.typography.labelSmall)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text("${record.fuelType}: ${record.liters} L x ${record.pricePerLiter} €/L", style = MaterialTheme.typography.bodyMedium) Text("${record.fuelType}: ${record.liters} L x ${record.pricePerLiter} €/L", style = MaterialTheme.typography.bodyMedium)
Text("Total: ${record.totalPrice}", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary) Text("Total: ${record.totalPrice}", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary)
} }
consumption?.let {
Column(horizontalAlignment = Alignment.End) {
Text(stringResource(R.string.consumption), style = MaterialTheme.typography.labelSmall)
Text(
text = stringResource(R.string.l_per_100km, String.format(Locale.getDefault(), "%.1f", it)),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.Bold
)
}
}
}
record.odometer?.let {
Text("KM: ${String.format(Locale.getDefault(), "%.0f", it)}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary)
} }
} }
} }
} }
}
}
}
@Composable
fun HistoryFilterDialog(
records: List<FuelRecord>,
currentMonth: Int?,
currentYear: Int?,
onDismiss: () -> Unit,
onConfirm: (Int?, Int?) -> Unit
) {
var selectedMonth by remember { mutableStateOf(currentMonth) }
var selectedYear by remember { mutableStateOf(currentYear) }
val years = records.map {
java.util.Calendar.getInstance().apply { timeInMillis = it.date }.get(java.util.Calendar.YEAR)
}.distinct().sortedDescending()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.filter_history)) },
text = {
Column {
Text(stringResource(R.string.month), style = MaterialTheme.typography.labelMedium)
val months = (0..11)
val context = LocalContext.current
SecondaryScrollableTabRow(
selectedTabIndex = if (selectedMonth == null) 0 else selectedMonth!! + 1,
edgePadding = 0.dp,
divider = {},
containerColor = Color.Transparent
) {
Tab(selected = selectedMonth == null, onClick = { selectedMonth = null }) {
Text(stringResource(R.string.all_months), modifier = Modifier.padding(8.dp))
}
months.forEach { m ->
val mResId = context.resources.getIdentifier("month_$m", "string", context.packageName)
Tab(selected = selectedMonth == m, onClick = { selectedMonth = m }) {
Text(if (mResId != 0) stringResource(mResId) else "", modifier = Modifier.padding(8.dp))
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(stringResource(R.string.year), style = MaterialTheme.typography.labelMedium)
SecondaryScrollableTabRow(
selectedTabIndex = if (selectedYear == null) 0 else years.indexOf(selectedYear!!) + 1,
edgePadding = 0.dp,
divider = {},
containerColor = Color.Transparent
) {
Tab(selected = selectedYear == null, onClick = { selectedYear = null }) {
Text(stringResource(R.string.all_years), modifier = Modifier.padding(8.dp))
}
years.forEach { y ->
Tab(selected = selectedYear == y, onClick = { selectedYear = y }) {
Text(y.toString(), modifier = Modifier.padding(8.dp))
}
}
}
}
},
confirmButton = {
Button(onClick = { onConfirm(selectedMonth, selectedYear) }) { Text("Filtrar") }
},
dismissButton = {
TextButton(onClick = { onConfirm(null, null) }) { Text("Limpiar") }
}
)
} }
@Composable @Composable
@@ -499,12 +896,17 @@ fun FuelLogDialog(
initialLiters: String = "", initialLiters: String = "",
initialTotal: String = "", initialTotal: String = "",
initialType: String = "Diésel A", initialType: String = "Diésel A",
initialDate: Long = System.currentTimeMillis(),
initialOdometer: String = "",
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (Double, Double, String, Double) -> Unit onConfirm: (Double, Double, String, Double, Long, Double?) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var liters by remember { mutableStateOf(initialLiters) } var liters by remember { mutableStateOf(initialLiters) }
var total by remember { mutableStateOf(initialTotal) } var total by remember { mutableStateOf(initialTotal) }
var odometer by remember { mutableStateOf(initialOdometer) }
var selectedDate by remember { mutableStateOf(initialDate) }
var showDatePicker by remember { mutableStateOf(false) }
val dieselLabel = stringResource(R.string.diesel_a_label) val dieselLabel = stringResource(R.string.diesel_a_label)
val premiumLabel = stringResource(R.string.premium_label) val premiumLabel = stringResource(R.string.premium_label)
@@ -538,6 +940,24 @@ fun FuelLogDialog(
} }
} }
if (showDatePicker) {
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = selectedDate)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis?.let { selectedDate = it }
showDatePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text(stringResource(R.string.cancel)) }
}
) {
DatePicker(state = datePickerState)
}
}
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.log_fuel)) }, title = { Text(stringResource(R.string.log_fuel)) },
@@ -546,6 +966,18 @@ fun FuelLogDialog(
Text(stationName, style = MaterialTheme.typography.bodyLarge) Text(stationName, style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Date Selection
Text(stringResource(R.string.date_label), style = MaterialTheme.typography.labelMedium)
OutlinedButton(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.DateRange, null)
Spacer(modifier = Modifier.width(8.dp))
Text(android.text.format.DateFormat.format("dd/MM/yyyy", selectedDate).toString())
}
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.fuel_type), style = MaterialTheme.typography.labelMedium) Text(stringResource(R.string.fuel_type), style = MaterialTheme.typography.labelMedium)
FlowRow( FlowRow(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -646,13 +1078,20 @@ fun FuelLogDialog(
label = { Text(stringResource(R.string.total_price)) }, label = { Text(stringResource(R.string.total_price)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
OutlinedTextField(
value = odometer,
onValueChange = { odometer = it },
label = { Text(stringResource(R.string.odometer)) },
modifier = Modifier.fillMaxWidth()
)
} }
}, },
confirmButton = { confirmButton = {
Button(onClick = { Button(onClick = {
val l = liters.toDoubleOrNull() ?: 0.0 val l = liters.toDoubleOrNull() ?: 0.0
val t = total.toDoubleOrNull() ?: 0.0 val t = total.toDoubleOrNull() ?: 0.0
if (l > 0 && t > 0) onConfirm(l, t, selectedType, if (l > 0) t / l else currentPrice) val o = odometer.toDoubleOrNull()
if (l > 0 && t > 0) onConfirm(l, t, selectedType, if (l > 0) t / l else currentPrice, selectedDate, o)
}) { Text(stringResource(R.string.save)) } }) { Text(stringResource(R.string.save)) }
}, },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } } dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }
@@ -660,20 +1099,31 @@ fun FuelLogDialog(
} }
@Composable @Composable
fun GasStationItem(station: GasStationInfo, selectedFuelType: FuelType, onClick: () -> Unit, onLogClick: () -> Unit) { fun GasStationItem(
station: GasStationInfo,
selectedFuelType: FuelType,
isFavorite: Boolean,
onClick: () -> Unit,
onLogClick: () -> Unit,
onToggleFavorite: () -> Unit
) {
val priceColor = when (station.priceCategory) { val priceColor = when (station.priceCategory) {
PriceCategory.CHEAP -> Color(0xFF2E7D32) PriceCategory.CHEAP -> Color(0xFF2E7D32)
PriceCategory.NORMAL -> MaterialTheme.colorScheme.primary PriceCategory.NORMAL -> MaterialTheme.colorScheme.primary
PriceCategory.EXPENSIVE -> Color(0xFFC62828) PriceCategory.EXPENSIVE -> Color(0xFFC62828)
} }
val brandColor = when { val isRepsol = station.name.contains("REPSOL", ignoreCase = true)
station.name.contains("REPSOL", ignoreCase = true) -> Color(0xFFE30613) val (brandColor, brandInitial) = when {
station.name.contains("CEPSA", ignoreCase = true) -> Color(0xFFEC0000) isRepsol -> Color(0xFFE30613) to "R"
station.name.contains("BP", ignoreCase = true) -> Color(0xFF00A94F) station.name.contains("CEPSA", ignoreCase = true) -> Color(0xFFEC0000) to "C"
station.name.contains("GALP", ignoreCase = true) -> Color(0xFFFF6B00) station.name.contains("BP", ignoreCase = true) -> Color(0xFF00A94F) to "B"
station.name.contains("SHELL", ignoreCase = true) -> Color(0xFFFFD500) station.name.contains("GALP", ignoreCase = true) -> Color(0xFFFF6B00) to "G"
else -> MaterialTheme.colorScheme.surfaceVariant station.name.contains("SHELL", ignoreCase = true) -> Color(0xFFFFD500) to "S"
station.name.contains("MOEVE", ignoreCase = true) -> Color(0xFF555555) to "M"
station.name.contains("BALLENOIL", ignoreCase = true) -> Color(0xFF003399) to "B"
station.name.contains("PLENERGY", ignoreCase = true) -> Color(0xFF2E7D32) to "P"
else -> MaterialTheme.colorScheme.surfaceVariant to (station.name.firstOrNull()?.toString()?.uppercase() ?: "?")
} }
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp).clickable { onClick() }, elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)) { Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp).clickable { onClick() }, elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)) {
@@ -681,8 +1131,39 @@ fun GasStationItem(station: GasStationInfo, selectedFuelType: FuelType, onClick:
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Surface(color = brandColor, modifier = Modifier.size(12.dp).padding(end = 8.dp), shape = androidx.compose.foundation.shape.CircleShape) {} Surface(
color = brandColor,
modifier = Modifier.size(32.dp),
shape = CircleShape
) {
if (isRepsol) {
Image(
painter = painterResource(id = R.drawable.repsol_logo),
contentDescription = "Repsol",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(contentAlignment = Alignment.Center) {
Text(
text = brandInitial,
style = MaterialTheme.typography.titleSmall,
color = if (brandColor == MaterialTheme.colorScheme.surfaceVariant) MaterialTheme.colorScheme.onSurface else Color.White,
fontWeight = FontWeight.Bold
)
}
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(text = "${station.name} - ${station.municipality}", style = MaterialTheme.typography.titleMedium, maxLines = 1, modifier = Modifier.weight(1f)) Text(text = "${station.name} - ${station.municipality}", style = MaterialTheme.typography.titleMedium, maxLines = 1, modifier = Modifier.weight(1f))
IconButton(onClick = onToggleFavorite, modifier = Modifier.size(24.dp)) {
Icon(
imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = null,
tint = if (isFavorite) Color.Red else LocalContentColor.current,
modifier = Modifier.size(18.dp)
)
}
} }
Text(text = station.address, style = MaterialTheme.typography.bodySmall, maxLines = 1) Text(text = station.address, style = MaterialTheme.typography.bodySmall, maxLines = 1)
if (station.horario.isNotEmpty()) { if (station.horario.isNotEmpty()) {
@@ -18,7 +18,7 @@ class GasStationRepository {
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.addInterceptor { chain -> .addInterceptor { chain ->
val request = chain.request().newBuilder() val request = chain.request().newBuilder()
.header("User-Agent", "GasolinerasApp/1.0 (Android; Contact: support@example.com)") .header("User-Agent", "Mozilla/5.0 (Android) GasolinerasApp/1.0")
.build() .build()
chain.proceed(request) chain.proceed(request)
} }
@@ -138,8 +138,8 @@ class GasStationRepository {
} else null } else null
} ?: emptyList() } ?: emptyList()
// Radio máximo: 30km local / 400km viaje // Radio máximo: 30km local / 1000km viaje
val finalMaxDist = if (destinationLocation == null) 30.0 else 400.0 val finalMaxDist = if (destinationLocation == null) 30.0 else 1000.0
val filtered = allStations.filter { (it.distance / 1000.0) <= finalMaxDist } val filtered = allStations.filter { (it.distance / 1000.0) <= finalMaxDist }
if (filtered.isEmpty()) return@withContext emptyList<GasStationInfo>() to emptyList<Location>() if (filtered.isEmpty()) return@withContext emptyList<GasStationInfo>() to emptyList<Location>()
@@ -10,6 +10,7 @@ import com.example.prueba.R
import com.example.prueba.data.FuelType import com.example.prueba.data.FuelType
import com.example.prueba.data.GasStationInfo import com.example.prueba.data.GasStationInfo
import com.example.prueba.data.GasStationRepository import com.example.prueba.data.GasStationRepository
import com.example.prueba.utils.FavoriteStorage
import com.example.prueba.utils.FuelRecord import com.example.prueba.utils.FuelRecord
import com.example.prueba.utils.FuelStorage import com.example.prueba.utils.FuelStorage
import com.example.prueba.utils.LocationStorage import com.example.prueba.utils.LocationStorage
@@ -34,7 +35,12 @@ data class GasStationUiState(
val destinationQuery: String = "", val destinationQuery: String = "",
val isRouteActive: Boolean = false, val isRouteActive: Boolean = false,
val destinationLocation: Location? = null, val destinationLocation: Location? = null,
val routePoints: List<Location> = emptyList() val routePoints: List<Location> = emptyList(),
val mapCenterLocation: Location? = null,
val favoriteIds: Set<String> = emptySet(),
val showOnlyFavorites: Boolean = false,
val filterMonth: Int? = null, // null for all
val filterYear: Int? = null // null for all
) )
class GasStationViewModel( class GasStationViewModel(
@@ -56,6 +62,28 @@ class GasStationViewModel(
fun selectTab(index: Int) { fun selectTab(index: Int) {
_uiState.update { it.copy(selectedTab = index, errorMessage = null, visibleStationsCount = 15) } _uiState.update { it.copy(selectedTab = index, errorMessage = null, visibleStationsCount = 15) }
if (index == 0 || index == 2) {
loadFavorites()
}
}
fun toggleFavorite(stationId: String) {
FavoriteStorage.toggleFavorite(context, stationId)
loadFavorites()
}
fun toggleFavoritesFilter() {
_uiState.update { it.copy(showOnlyFavorites = !it.showOnlyFavorites) }
refreshStations()
}
fun setHistoryFilters(month: Int?, year: Int?) {
_uiState.update { it.copy(filterMonth = month, filterYear = year) }
}
private fun loadFavorites() {
val favs = FavoriteStorage.getFavorites(context)
_uiState.update { it.copy(favoriteIds = favs) }
} }
fun selectFuelType(type: FuelType) { fun selectFuelType(type: FuelType) {
@@ -177,18 +205,39 @@ class GasStationViewModel(
_uiState.update { it.copy(visibleStationsCount = 15) } _uiState.update { it.copy(visibleStationsCount = 15) }
} }
fun updateMapCenter(lat: Double, lon: Double) {
val loc = Location("").apply {
latitude = lat
longitude = lon
}
_uiState.update { it.copy(mapCenterLocation = loc) }
}
fun searchAtMapCenter() {
val center = _uiState.value.mapCenterLocation ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
val (stations, _) = repository.getCheapestAndNearest(
userLocation = center,
fuelType = _uiState.value.selectedFuelType
)
_uiState.update { it.copy(stations = stations, isLoading = false) }
}
}
fun refreshStations(forceRefresh: Boolean = false) { fun refreshStations(forceRefresh: Boolean = false) {
val state = _uiState.value val state = _uiState.value
val loc = state.lastLocation ?: return val loc = state.lastLocation ?: return
if (state.selectedTab != 0) return // Refrescar solo en pestañas de datos
if (state.selectedTab != 0 && state.selectedTab != 2) return
viewModelScope.launch { viewModelScope.launch {
try { try {
if (forceRefresh) _uiState.update { it.copy(isRefreshing = true, errorMessage = null) } if (forceRefresh) _uiState.update { it.copy(isRefreshing = true, errorMessage = null) }
else if (state.stations.isEmpty()) _uiState.update { it.copy(isLoading = true, errorMessage = null) } else if (state.stations.isEmpty()) _uiState.update { it.copy(isLoading = true, errorMessage = null) }
// 1. Fast load // 1. Carga Rápida (Línea recta)
val (fastStations, points) = repository.getCheapestAndNearest( val (fastStations, points) = repository.getCheapestAndNearest(
userLocation = loc, userLocation = loc,
getRealDistances = false, getRealDistances = false,
@@ -197,23 +246,27 @@ class GasStationViewModel(
destinationLocation = if (state.isRouteActive) state.destinationLocation else null destinationLocation = if (state.isRouteActive) state.destinationLocation else null
) )
if (fastStations.isEmpty() && forceRefresh) { // Aplicar el filtro de favoritos ANTES de actualizar la UI para evitar parpadeos
_uiState.update { it.copy(errorMessage = context.getString(R.string.no_results)) } val finalFast = if (_uiState.value.showOnlyFavorites) {
} fastStations.filter { _uiState.value.favoriteIds.contains(it.id) }
} else fastStations
_uiState.update { it.copy(stations = fastStations, routePoints = points, isLoading = false) } _uiState.update { it.copy(stations = finalFast, routePoints = points, isLoading = false) }
// 2. Precision load // 2. Carga de Precisión (Carretera)
val (realStations, _) = repository.getCheapestAndNearest( val (realStations, _) = repository.getCheapestAndNearest(
userLocation = loc, userLocation = loc,
getRealDistances = true, getRealDistances = true,
forceRefresh = false, // don't force twice forceRefresh = false,
fuelType = state.selectedFuelType, fuelType = state.selectedFuelType,
destinationLocation = if (state.isRouteActive) state.destinationLocation else null destinationLocation = if (state.isRouteActive) state.destinationLocation else null
) )
if (realStations.isNotEmpty()) { if (realStations.isNotEmpty()) {
_uiState.update { it.copy(stations = realStations, isRefreshing = false) } val finalReal = if (_uiState.value.showOnlyFavorites) {
realStations.filter { _uiState.value.favoriteIds.contains(it.id) }
} else realStations
_uiState.update { it.copy(stations = finalReal, isRefreshing = false) }
} else { } else {
_uiState.update { it.copy(isRefreshing = false) } _uiState.update { it.copy(isRefreshing = false) }
} }
@@ -230,15 +283,20 @@ class GasStationViewModel(
} }
init { init {
loadFavorites()
viewModelScope.launch { viewModelScope.launch {
combine( combine(
_uiState.map { it.lastLocation }.distinctUntilChanged(), _uiState.map { it.lastLocation }.distinctUntilChanged(),
_uiState.map { it.selectedTab }.distinctUntilChanged(), _uiState.map { it.selectedTab }.distinctUntilChanged(),
_uiState.map { it.selectedFuelType }.distinctUntilChanged(), _uiState.map { it.selectedFuelType }.distinctUntilChanged(),
_uiState.map { it.isRouteActive }.distinctUntilChanged(), _uiState.map { it.isRouteActive }.distinctUntilChanged(),
_uiState.map { it.destinationLocation }.distinctUntilChanged() _uiState.map { it.destinationLocation }.distinctUntilChanged(),
) { lastLoc, tab, _, _, _ -> _uiState.map { it.showOnlyFavorites }.distinctUntilChanged()
if (lastLoc != null && tab == 0) { ) { args ->
val lastLoc = args[0] as? Location
val tab = args[1] as Int
// Ahora refresca tanto en pestaña 0 (Lista) como en pestaña 2 (Mapa)
if (lastLoc != null && (tab == 0 || tab == 2)) {
refreshStations() refreshStations()
} }
}.collect() }.collect()
@@ -0,0 +1,30 @@
package com.example.prueba.utils
import android.content.Context
object FavoriteStorage {
private const val PREFS_NAME = "favorite_prefs"
private const val KEY_FAVORITES = "favorite_ids"
fun toggleFavorite(context: Context, stationId: String) {
val favorites = getFavorites(context).toMutableSet()
if (favorites.contains(stationId)) {
favorites.remove(stationId)
} else {
favorites.add(stationId)
}
saveFavorites(context, favorites)
}
fun getFavorites(context: Context): Set<String> {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getStringSet(KEY_FAVORITES, emptySet()) ?: emptySet()
}
private fun saveFavorites(context: Context, favorites: Set<String>) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putStringSet(KEY_FAVORITES, favorites)
.apply()
}
}
@@ -12,7 +12,8 @@ data class FuelRecord(
val liters: Double, val liters: Double,
val totalPrice: Double, val totalPrice: Double,
val pricePerLiter: Double, val pricePerLiter: Double,
val fuelType: String = "Diésel A" val fuelType: String = "Diésel A",
val odometer: Double? = null
) )
object FuelStorage { object FuelStorage {
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+30
View File
@@ -36,4 +36,34 @@
<string name="gnl_label">GNL</string> <string name="gnl_label">GNL</string>
<string name="diesel_b_label">Diésel B</string> <string name="diesel_b_label">Diésel B</string>
<string name="biodiesel_label">Biodiesel</string> <string name="biodiesel_label">Biodiesel</string>
<string name="search_this_area">Buscar en esta zona</string>
<string name="go_to_waze">Ir en Waze</string>
<string name="favorites">Favoritos</string>
<string name="add_favorite">Añadir a favoritos</string>
<string name="remove_favorite">Quitar de favoritos</string>
<string name="show_favorites">Mostrar solo favoritos</string>
<string name="date_label">Fecha</string>
<string name="select_date">Seleccionar fecha</string>
<string name="monthly_spend">Gasto este mes</string>
<string name="yearly_spend">Gasto este año</string>
<string name="filter_history">Filtrar historial</string>
<string name="all_months">Todos los meses</string>
<string name="all_years">Todos los años</string>
<string name="month">Mes</string>
<string name="year">Año</string>
<string name="month_0">Enero</string>
<string name="month_1">Febrero</string>
<string name="month_2">Marzo</string>
<string name="month_3">Abril</string>
<string name="month_4">Mayo</string>
<string name="month_5">Junio</string>
<string name="month_6">Julio</string>
<string name="month_7">Agosto</string>
<string name="month_8">Septiembre</string>
<string name="month_9">Octubre</string>
<string name="month_10">Noviembre</string>
<string name="month_11">Diciembre</string>
<string name="odometer">Kilómetros totales (Opcional)</string>
<string name="consumption">Consumo</string>
<string name="l_per_100km">%1$s L/100km</string>
</resources> </resources>