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 0000000..6ad28c1 Binary files /dev/null and b/app/src/main/res/drawable/repsol_logo.jpeg differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b05225..a808c61 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,4 +36,34 @@ GNL 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