From 4655f0008c621cef1c0e7c0fe66b7635de6cab7a Mon Sep 17 00:00:00 2001 From: Pedro Javier Date: Fri, 14 Aug 2026 13:58:02 +0200 Subject: [PATCH] Gasolinera app --- .idea/inspectionProfiles/Project_Default.xml | 50 ++ app/src/main/AndroidManifest.xml | 18 +- .../java/com/example/prueba/MainActivity.kt | 633 +++++++++++++++--- .../prueba/data/GasStationRepository.kt | 6 +- .../example/prueba/ui/GasStationViewModel.kt | 84 ++- .../example/prueba/utils/FavoriteStorage.kt | 30 + .../com/example/prueba/utils/FuelStorage.kt | 3 +- app/src/main/res/drawable/repsol_logo.jpeg | Bin 0 -> 13739 bytes app/src/main/res/values/strings.xml | 30 + 9 files changed, 753 insertions(+), 101 deletions(-) create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 app/src/main/java/com/example/prueba/utils/FavoriteStorage.kt create mode 100644 app/src/main/res/drawable/repsol_logo.jpeg diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f0c6ad0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d61cb0f..9d52d90 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,15 +3,13 @@ xmlns:tools="http://schemas.android.com/tools"> + - + android:required="true" /> - + - + + diff --git a/app/src/main/java/com/example/prueba/MainActivity.kt b/app/src/main/java/com/example/prueba/MainActivity.kt index 973b02b..f0ff678 100644 --- a/app/src/main/java/com/example/prueba/MainActivity.kt +++ b/app/src/main/java/com/example/prueba/MainActivity.kt @@ -1,6 +1,7 @@ package com.example.prueba import android.Manifest +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri @@ -10,10 +11,12 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.AltRoute import androidx.compose.material.icons.filled.* @@ -23,7 +26,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight 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.theme.PruebaTheme import com.example.prueba.utils.FuelRecord -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.io.File import java.util.Locale class MainActivity : ComponentActivity() { @@ -52,8 +56,9 @@ class MainActivity : ComponentActivity() { 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)" + val osmConfig = Configuration.getInstance() + osmConfig.userAgentValue = "Mozilla/5.0 (Android) GasolinerasApp/1.0" + osmConfig.load(this, getSharedPreferences("osmdroid_prefs", MODE_PRIVATE)) enableEdgeToEdge() setContent { @@ -73,6 +78,8 @@ fun GasStationApp(viewModel: GasStationViewModel = viewModel()) { var showFuelDialog by remember { mutableStateOf(null) } var editRecordDialog by remember { mutableStateOf(null) } + var selectedStationForMap by remember { mutableStateOf(null) } + var showHistoryFilter by remember { mutableStateOf(false) } val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() @@ -148,14 +155,36 @@ fun GasStationApp(viewModel: GasStationViewModel = viewModel()) { } }, 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) }) { Icon(Icons.AutoMirrored.Filled.AltRoute, stringResource(R.string.on_route)) } } } ) - if (uiState.selectedTab == 0) { + if (uiState.selectedTab == 0 || uiState.selectedTab == 2) { FuelTypeSelector( selectedType = uiState.selectedFuelType, onTypeSelected = { viewModel.selectFuelType(it) } @@ -205,31 +234,115 @@ fun GasStationApp(viewModel: GasStationViewModel = viewModel()) { } else if (uiState.stations.isEmpty()) { Text(stringResource(R.string.no_results), modifier = Modifier.align(Alignment.Center)) } else { - GasStationList( - stations = uiState.stations, - visibleCount = uiState.visibleStationsCount, - selectedFuelType = uiState.selectedFuelType, - onLogClick = { showFuelDialog = it }, - onShowMore = { viewModel.showMoreStations() }, - onShowLess = { viewModel.resetStationLimit() } - ) + GasStationList( + stations = uiState.stations, + visibleCount = uiState.visibleStationsCount, + selectedFuelType = uiState.selectedFuelType, + favoriteIds = uiState.favoriteIds, + onLogClick = { showFuelDialog = it }, + onShowMore = { viewModel.showMoreStations() }, + onShowLess = { viewModel.resetStationLimit() }, + onToggleFavorite = { viewModel.toggleFavorite(it) } + ) } } } 1 -> { FuelHistoryScreen( records = uiState.fuelRecords, + filterMonth = uiState.filterMonth, + filterYear = uiState.filterYear, onDelete = { record -> viewModel.deleteFuelRecord(record) }, onEdit = { record -> editRecordDialog = record } ) } 2 -> { - GasStationMap( - stations = uiState.stations, - routePoints = uiState.routePoints, - userLocation = uiState.lastLocation, - selectedFuelType = uiState.selectedFuelType - ) + Box(modifier = Modifier.fillMaxSize()) { + GasStationMap( + stations = uiState.stations, + routePoints = uiState.routePoints, + userLocation = uiState.lastLocation, + 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) }, onDismiss = { showFuelDialog = null }, - onConfirm = { liters, total, type, pricePerLiter -> + onConfirm = { liters, total, type, pricePerLiter, date, odo -> val record = FuelRecord( - date = System.currentTimeMillis(), + date = date, stationName = station.name, address = station.address, municipality = station.municipality, liters = liters, totalPrice = total, pricePerLiter = pricePerLiter, - fuelType = type + fuelType = type, + odometer = odo ) viewModel.saveFuelRecord(record) showFuelDialog = null @@ -275,22 +389,41 @@ fun GasStationApp(viewModel: GasStationViewModel = viewModel()) { ) } - editRecordDialog?.let { record -> - FuelLogDialog( - stationName = record.stationName, - initialLiters = record.liters.toString(), - initialTotal = record.totalPrice.toString(), - initialType = record.fuelType, - onDismiss = { editRecordDialog = null }, - onConfirm = { liters, total, type, pricePerLiter -> - val newRecord = record.copy( - liters = liters, - totalPrice = total, - fuelType = type, - pricePerLiter = pricePerLiter - ) - viewModel.updateFuelRecord(record, newRecord) - editRecordDialog = null + if (editRecordDialog != null) { + editRecordDialog?.let { record -> + FuelLogDialog( + stationName = record.stationName, + initialLiters = record.liters.toString(), + initialTotal = record.totalPrice.toString(), + initialType = record.fuelType, + initialDate = record.date, + initialOdometer = record.odometer?.toString() ?: "", + onDismiss = { editRecordDialog = null }, + onConfirm = { liters, total, type, pricePerLiter, date, odo -> + val newRecord = record.copy( + date = date, + liters = liters, + totalPrice = total, + fuelType = type, + pricePerLiter = pricePerLiter, + odometer = odo + ) + viewModel.updateFuelRecord(record, newRecord) + editRecordDialog = null + } + ) + } + } + + 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, routePoints: List, userLocation: android.location.Location?, - selectedFuelType: FuelType + selectedFuelType: FuelType, + favoriteIds: Set, + onMapMoved: (Double, Double) -> Unit, + onStationSelected: (GasStationInfo) -> Unit ) { AndroidView( factory = { context -> 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) + setLayerType(android.view.View.LAYER_TYPE_SOFTWARE, null) + controller.setZoom(15.0) userLocation?.let { 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 -> 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()) { val line = Polyline(mapView) line.setPoints(routePoints.map { GeoPoint(it.latitude, it.longitude) }) @@ -327,33 +489,60 @@ fun GasStationMap( line.outlinePaint.strokeWidth = 10f mapView.overlays.add(line) - // Zoom para ajustar a la ruta la primera vez - if (mapView.tag == null) { + // Zoom para ajustar a la ruta si es nueva + if (mapView.tag != "route_zoomed") { val points = routePoints.map { GeoPoint(it.latitude, it.longitude) } - mapView.zoomToBoundingBox(org.osmdroid.util.BoundingBox.fromGeoPoints(points), true) - mapView.tag = "zoomed" + if (points.isNotEmpty()) { + mapView.zoomToBoundingBox(org.osmdroid.util.BoundingBox.fromGeoPoints(points), true) + 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 { val userMarker = Marker(mapView) userMarker.position = GeoPoint(it.latitude, it.longitude) userMarker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) userMarker.title = "Tu ubicación" + userMarker.icon = ContextCompat.getDrawable(mapView.context, org.osmdroid.library.R.drawable.person) 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 + // 4. Marcadores de gasolineras con colores + 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 + 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) } @@ -405,9 +594,11 @@ fun GasStationList( stations: List, visibleCount: Int, selectedFuelType: FuelType, + favoriteIds: Set, onLogClick: (GasStationInfo) -> Unit, onShowMore: () -> Unit, - onShowLess: () -> Unit + onShowLess: () -> Unit, + onToggleFavorite: (String) -> Unit ) { val context = LocalContext.current val visibleStations = stations.take(visibleCount) @@ -417,11 +608,13 @@ fun GasStationList( GasStationItem( station = station, selectedFuelType = selectedFuelType, + isFavorite = favoriteIds.contains(station.id), 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)) }, - onLogClick = { onLogClick(station) } + onLogClick = { onLogClick(station) }, + onToggleFavorite = { onToggleFavorite(station.id) } ) } @@ -454,17 +647,133 @@ fun GasStationList( } @Composable -fun FuelHistoryScreen(records: List, onDelete: (FuelRecord) -> Unit, onEdit: (FuelRecord) -> Unit) { - if (records.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.no_records)) } - } else { - LazyColumn(contentPadding = PaddingValues(16.dp)) { - items(records) { record -> +fun FuelHistoryScreen( + records: List, + filterMonth: Int?, + 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)) { + 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)) { Column(modifier = Modifier.padding(16.dp)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Column(modifier = Modifier.weight(1f)) { - Text(record.stationName, style = MaterialTheme.typography.titleMedium) + 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.municipality} - ${record.address}", style = MaterialTheme.typography.bodySmall) } Row { @@ -474,9 +783,29 @@ fun FuelHistoryScreen(records: List, onDelete: (FuelRecord) -> Unit, } } Text(android.text.format.DateFormat.format("dd/MM/yyyy", record.date).toString(), style = MaterialTheme.typography.labelSmall) + Spacer(modifier = Modifier.height(4.dp)) - Text("${record.fuelType}: ${record.liters} L x ${record.pricePerLiter} €/L", style = MaterialTheme.typography.bodyMedium) - Text("Total: ${record.totalPrice} €", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column { + 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) + } + 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) + } } } } @@ -484,6 +813,74 @@ fun FuelHistoryScreen(records: List, onDelete: (FuelRecord) -> Unit, } } +@Composable +fun HistoryFilterDialog( + records: List, + 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 fun FuelLogDialog( stationName: String, @@ -499,12 +896,17 @@ fun FuelLogDialog( initialLiters: String = "", initialTotal: String = "", initialType: String = "Diésel A", + initialDate: Long = System.currentTimeMillis(), + initialOdometer: String = "", onDismiss: () -> Unit, - onConfirm: (Double, Double, String, Double) -> Unit + onConfirm: (Double, Double, String, Double, Long, Double?) -> Unit ) { val context = LocalContext.current var liters by remember { mutableStateOf(initialLiters) } 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 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( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.log_fuel)) }, @@ -546,6 +966,18 @@ fun FuelLogDialog( Text(stationName, style = MaterialTheme.typography.bodyLarge) 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) FlowRow( modifier = Modifier.fillMaxWidth(), @@ -646,13 +1078,20 @@ fun FuelLogDialog( label = { Text(stringResource(R.string.total_price)) }, modifier = Modifier.fillMaxWidth() ) + OutlinedTextField( + value = odometer, + onValueChange = { odometer = it }, + label = { Text(stringResource(R.string.odometer)) }, + 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, 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)) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } } @@ -660,20 +1099,31 @@ fun FuelLogDialog( } @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) { PriceCategory.CHEAP -> Color(0xFF2E7D32) PriceCategory.NORMAL -> MaterialTheme.colorScheme.primary PriceCategory.EXPENSIVE -> Color(0xFFC62828) } - val brandColor = when { - station.name.contains("REPSOL", ignoreCase = true) -> Color(0xFFE30613) - station.name.contains("CEPSA", ignoreCase = true) -> Color(0xFFEC0000) - station.name.contains("BP", ignoreCase = true) -> Color(0xFF00A94F) - station.name.contains("GALP", ignoreCase = true) -> Color(0xFFFF6B00) - station.name.contains("SHELL", ignoreCase = true) -> Color(0xFFFFD500) - else -> MaterialTheme.colorScheme.surfaceVariant + val isRepsol = station.name.contains("REPSOL", ignoreCase = true) + val (brandColor, brandInitial) = when { + isRepsol -> Color(0xFFE30613) to "R" + station.name.contains("CEPSA", ignoreCase = true) -> Color(0xFFEC0000) to "C" + station.name.contains("BP", ignoreCase = true) -> Color(0xFF00A94F) to "B" + station.name.contains("GALP", ignoreCase = true) -> Color(0xFFFF6B00) to "G" + 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)) { @@ -681,8 +1131,39 @@ fun GasStationItem(station: GasStationInfo, selectedFuelType: FuelType, onClick: Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { Column(modifier = Modifier.weight(1f)) { 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)) + 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) if (station.horario.isNotEmpty()) { diff --git a/app/src/main/java/com/example/prueba/data/GasStationRepository.kt b/app/src/main/java/com/example/prueba/data/GasStationRepository.kt index 3579af3..a0c5e97 100644 --- a/app/src/main/java/com/example/prueba/data/GasStationRepository.kt +++ b/app/src/main/java/com/example/prueba/data/GasStationRepository.kt @@ -18,7 +18,7 @@ class GasStationRepository { private val client = OkHttpClient.Builder() .addInterceptor { chain -> 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() chain.proceed(request) } @@ -138,8 +138,8 @@ class GasStationRepository { } else null } ?: emptyList() - // Radio máximo: 30km local / 400km viaje - val finalMaxDist = if (destinationLocation == null) 30.0 else 400.0 + // Radio máximo: 30km local / 1000km viaje + val finalMaxDist = if (destinationLocation == null) 30.0 else 1000.0 val filtered = allStations.filter { (it.distance / 1000.0) <= finalMaxDist } if (filtered.isEmpty()) return@withContext emptyList() to emptyList() diff --git a/app/src/main/java/com/example/prueba/ui/GasStationViewModel.kt b/app/src/main/java/com/example/prueba/ui/GasStationViewModel.kt index 525183b..ec6a7e6 100644 --- a/app/src/main/java/com/example/prueba/ui/GasStationViewModel.kt +++ b/app/src/main/java/com/example/prueba/ui/GasStationViewModel.kt @@ -10,6 +10,7 @@ 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.FavoriteStorage import com.example.prueba.utils.FuelRecord import com.example.prueba.utils.FuelStorage import com.example.prueba.utils.LocationStorage @@ -34,7 +35,12 @@ data class GasStationUiState( val destinationQuery: String = "", val isRouteActive: Boolean = false, val destinationLocation: Location? = null, - val routePoints: List = emptyList() + val routePoints: List = emptyList(), + val mapCenterLocation: Location? = null, + val favoriteIds: Set = emptySet(), + val showOnlyFavorites: Boolean = false, + val filterMonth: Int? = null, // null for all + val filterYear: Int? = null // null for all ) class GasStationViewModel( @@ -56,6 +62,28 @@ class GasStationViewModel( fun selectTab(index: Int) { _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) { @@ -177,18 +205,39 @@ class GasStationViewModel( _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) { val state = _uiState.value 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 { 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 + // 1. Carga Rápida (Línea recta) val (fastStations, points) = repository.getCheapestAndNearest( userLocation = loc, getRealDistances = false, @@ -197,23 +246,27 @@ class GasStationViewModel( destinationLocation = if (state.isRouteActive) state.destinationLocation else null ) - if (fastStations.isEmpty() && forceRefresh) { - _uiState.update { it.copy(errorMessage = context.getString(R.string.no_results)) } - } + // Aplicar el filtro de favoritos ANTES de actualizar la UI para evitar parpadeos + 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( userLocation = loc, getRealDistances = true, - forceRefresh = false, // don't force twice + forceRefresh = false, fuelType = state.selectedFuelType, destinationLocation = if (state.isRouteActive) state.destinationLocation else null ) 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 { _uiState.update { it.copy(isRefreshing = false) } } @@ -230,15 +283,20 @@ class GasStationViewModel( } init { + loadFavorites() 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) { + _uiState.map { it.destinationLocation }.distinctUntilChanged(), + _uiState.map { it.showOnlyFavorites }.distinctUntilChanged() + ) { 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() } }.collect() diff --git a/app/src/main/java/com/example/prueba/utils/FavoriteStorage.kt b/app/src/main/java/com/example/prueba/utils/FavoriteStorage.kt new file mode 100644 index 0000000..5665fd2 --- /dev/null +++ b/app/src/main/java/com/example/prueba/utils/FavoriteStorage.kt @@ -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 { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getStringSet(KEY_FAVORITES, emptySet()) ?: emptySet() + } + + private fun saveFavorites(context: Context, favorites: Set) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putStringSet(KEY_FAVORITES, favorites) + .apply() + } +} diff --git a/app/src/main/java/com/example/prueba/utils/FuelStorage.kt b/app/src/main/java/com/example/prueba/utils/FuelStorage.kt index 8fdf339..4767eff 100644 --- a/app/src/main/java/com/example/prueba/utils/FuelStorage.kt +++ b/app/src/main/java/com/example/prueba/utils/FuelStorage.kt @@ -12,7 +12,8 @@ data class FuelRecord( val liters: Double, val totalPrice: Double, val pricePerLiter: Double, - val fuelType: String = "Diésel A" + val fuelType: String = "Diésel A", + val odometer: Double? = null ) object FuelStorage { diff --git a/app/src/main/res/drawable/repsol_logo.jpeg b/app/src/main/res/drawable/repsol_logo.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..6ad28c1e0169b2e9b6b31307f30c59128c41cb1c GIT binary patch literal 13739 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 Diésel B Biodiesel + Buscar en esta zona + Ir en Waze + Favoritos + Añadir a favoritos + Quitar de favoritos + Mostrar solo favoritos + Fecha + Seleccionar fecha + Gasto este mes + Gasto este año + Filtrar historial + Todos los meses + Todos los años + Mes + Año + Enero + Febrero + Marzo + Abril + Mayo + Junio + Julio + Agosto + Septiembre + Octubre + Noviembre + Diciembre + Kilómetros totales (Opcional) + Consumo + %1$s L/100km \ No newline at end of file