Gasolinera app

This commit is contained in:
2026-08-10 13:42:04 +02:00
parent b6857bc422
commit 1e1fa640e7
9 changed files with 307 additions and 408 deletions
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="jbr-25" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="jbr-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">
@@ -12,12 +12,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.animation.AnimatedVisibility
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.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.AltRoute
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -28,12 +28,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
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
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
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.data.PriceCategory import com.example.prueba.data.PriceCategory
import com.example.prueba.ui.theme.PruebaTheme import com.example.prueba.ui.theme.PruebaTheme
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
@@ -71,8 +71,11 @@ fun GasStationApp(repository: GasStationRepository) {
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
var selectedTab by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableIntStateOf(0) }
var searchQuery by remember { mutableStateOf("") }
var isSearchActive by remember { mutableStateOf(false) } var destinationQuery by remember { mutableStateOf("") }
var isRouteActive by remember { mutableStateOf(false) }
var isReserveFilterActive by remember { mutableStateOf(false) }
var destinationLocation by remember { mutableStateOf<android.location.Location?>(null) }
var hasPermission by remember { var hasPermission by remember {
mutableStateOf( mutableStateOf(
@@ -98,33 +101,36 @@ fun GasStationApp(repository: GasStationRepository) {
val address = addresses[0] val address = addresses[0]
val street = address.thoroughfare val street = address.thoroughfare
val number = address.subThoroughfare val number = address.subThoroughfare
currentAddress = if (street != null && number != null) "$street, $number" else street ?: address.getAddressLine(0) ?: "Ubicación desconocida"
currentAddress = if (street != null && number != null) {
"$street, $number"
} else {
street ?: address.getAddressLine(0) ?: "Ubicación desconocida"
}
} }
} catch (ignore: Exception) {} } catch (ignore: Exception) {}
} }
} }
LaunchedEffect(hasPermission, searchQuery, selectedTab) { LaunchedEffect(hasPermission, destinationLocation, selectedTab, isReserveFilterActive) {
if (hasPermission) { if (hasPermission) {
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
val loc = lastLocation ?: LocationStorage.getLastLocation(context) val loc = lastLocation ?: LocationStorage.getLastLocation(context)
loc?.let { loc?.let {
// Primero recta para rapidez
stations = repository.getCheapestAndNearest( stations = repository.getCheapestAndNearest(
context = context,
userLocation = it, userLocation = it,
municipalityQuery = if (selectedTab == 0) searchQuery.ifBlank { null } else null, maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
onlyFavorites = (selectedTab == 1) destinationLocation = if (isRouteActive) destinationLocation else null,
getRealDistances = false
)
// Luego real para precisión
scope.launch {
stations = repository.getCheapestAndNearest(
userLocation = it,
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
destinationLocation = if (isRouteActive) destinationLocation else null,
getRealDistances = true
) )
} }
}
if (selectedTab == 0) isLoading = true
if (selectedTab != 2) isLoading = true val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 15000)
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 10000)
.setMinUpdateDistanceMeters(500f) .setMinUpdateDistanceMeters(500f)
.build() .build()
@@ -133,10 +139,15 @@ fun GasStationApp(repository: GasStationRepository) {
val location = result.lastLocation ?: return val location = result.lastLocation ?: return
lastLocation = location lastLocation = location
LocationStorage.saveLocation(context, location) LocationStorage.saveLocation(context, location)
if (searchQuery.isBlank()) { if (selectedTab == 0) {
scope.launch { scope.launch {
updateAddress(location) updateAddress(location)
stations = repository.getCheapestAndNearest(context, location) stations = repository.getCheapestAndNearest(
userLocation = location,
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
destinationLocation = if (isRouteActive) destinationLocation else null,
getRealDistances = true
)
isLoading = false isLoading = false
} }
} else { } else {
@@ -145,6 +156,7 @@ fun GasStationApp(repository: GasStationRepository) {
} }
} }
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
try { try {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, android.os.Looper.getMainLooper()) fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, android.os.Looper.getMainLooper())
@@ -161,42 +173,50 @@ fun GasStationApp(repository: GasStationRepository) {
Column { Column {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { title = {
if (isSearchActive) { if (isRouteActive) {
TextField( TextField(
value = searchQuery, value = destinationQuery,
onValueChange = { searchQuery = it }, onValueChange = { destinationQuery = it },
placeholder = { Text("Buscar municipio...") }, placeholder = { Text("¿A dónde vas?") },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent
),
trailingIcon = { trailingIcon = {
Row {
IconButton(onClick = { IconButton(onClick = {
searchQuery = "" scope.launch {
isSearchActive = false withContext(Dispatchers.IO) {
}) { try {
Icon(Icons.Default.Close, contentDescription = "Cerrar") val geocoder = Geocoder(context, Locale.getDefault())
val results = geocoder.getFromLocationName(destinationQuery, 1)
if (!results.isNullOrEmpty()) {
destinationLocation = android.location.Location("").apply {
latitude = results[0].latitude
longitude = results[0].longitude
}
}
} catch (e: Exception) {}
}
}
}) { Icon(Icons.Default.DirectionsCar, null) }
IconButton(onClick = {
isRouteActive = false
destinationLocation = null
destinationQuery = ""
}) { Icon(Icons.Default.Close, null) }
} }
} }
) )
} else { } else {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Gasolineras Diésel", style = MaterialTheme.typography.titleSmall) Text("Gasolineras Diésel", style = MaterialTheme.typography.titleSmall)
Text( Text(text = currentAddress, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, maxLines = 1)
text = currentAddress,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1
)
} }
} }
}, },
actions = { actions = {
if (!isSearchActive && selectedTab != 2) { if (!isRouteActive && selectedTab == 0) {
IconButton(onClick = { isSearchActive = true }) { IconButton(onClick = { isRouteActive = true }) {
Icon(Icons.Default.Search, contentDescription = "Buscar") Icon(Icons.AutoMirrored.Filled.AltRoute, "En ruta")
} }
} }
} }
@@ -206,99 +226,63 @@ fun GasStationApp(repository: GasStationRepository) {
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
NavigationBarItem( NavigationBarItem(
icon = { Icon(Icons.Default.Place, "Gasolineras") }, icon = { Icon(Icons.Default.LocalGasStation, null) },
label = { Text("Diésel") }, label = { Text("Gasolineras") },
selected = selectedTab == 0, selected = selectedTab == 0,
onClick = { selectedTab = 0 } onClick = { selectedTab = 0 }
) )
NavigationBarItem( NavigationBarItem(
icon = { Icon(Icons.Default.Favorite, "Favoritas") }, icon = { Icon(Icons.Default.History, null) },
label = { Text("Favoritas") }, label = { Text("Historial") },
selected = selectedTab == 1, selected = selectedTab == 1,
onClick = { selectedTab = 1 } onClick = { selectedTab = 1 }
) )
NavigationBarItem(
icon = { Icon(Icons.Default.DateRange, "Historial") },
label = { Text("Historial") },
selected = selectedTab == 2,
onClick = { selectedTab = 2 }
)
} }
} }
) { innerPadding -> ) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) { Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
if (!hasPermission) { if (!hasPermission) {
Column( Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Se necesita permiso de ubicación") Text("Se necesita permiso de ubicación")
Button(onClick = { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }) { Button(onClick = { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }) { Text("Dar permiso") }
Text("Dar permiso")
}
} }
} else { } else {
when (selectedTab) { when (selectedTab) {
0, 1 -> { 0 -> {
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
onRefresh = { onRefresh = {
lastLocation?.let { loc -> lastLocation?.let { loc ->
isRefreshing = true isRefreshing = true
scope.launch { scope.launch {
stations = repository.getCheapestAndNearest(context, loc, forceRefresh = true, municipalityQuery = if(searchQuery.isNotBlank()) searchQuery else null) stations = repository.getCheapestAndNearest(
userLocation = loc, forceRefresh = true,
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
destinationLocation = if (isRouteActive) destinationLocation else null
)
isRefreshing = false isRefreshing = false
} }
} }
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
if (selectedTab == 0) {
if (isLoading && stations.isEmpty()) { if (isLoading && stations.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (stations.isEmpty()) { } else if (stations.isEmpty()) {
Text("No se encontraron gasolineras", modifier = Modifier.align(Alignment.Center)) Text(if (isReserveFilterActive) "No hay nada a menos de 40km" else "Sin resultados", modifier = Modifier.align(Alignment.Center))
} else { } else {
GasStationList( GasStationList(stations = stations, onLogClick = { showFuelDialog = it })
stations = stations,
context = context,
scope = scope,
repository = repository,
lastLocation = lastLocation,
onLogClick = { showFuelDialog = it },
onUpdateStations = { stations = it },
searchQuery = searchQuery
)
}
} else {
if (stations.isEmpty()) {
Text("No tienes gasolineras favoritas marcadas", modifier = Modifier.align(Alignment.Center))
} else {
GasStationList(
stations = stations,
context = context,
scope = scope,
repository = repository,
lastLocation = lastLocation,
onLogClick = { showFuelDialog = it },
onUpdateStations = { stations = it },
searchQuery = ""
)
} }
} }
} }
} 1 -> {
2 -> {
FuelHistoryScreen( FuelHistoryScreen(
records = fuelRecords, records = fuelRecords,
onDelete = { record -> onDelete = { record ->
FuelStorage.deleteRecord(context, record) FuelStorage.deleteRecord(context, record)
fuelRecords = FuelStorage.getRecords(context) fuelRecords = FuelStorage.getRecords(context)
}, },
onEdit = { record -> onEdit = { record -> editRecordDialog = record }
editRecordDialog = record
}
) )
} }
} }
@@ -332,10 +316,7 @@ fun GasStationApp(repository: GasStationRepository) {
initialTotal = record.totalPrice.toString(), initialTotal = record.totalPrice.toString(),
onDismiss = { editRecordDialog = null }, onDismiss = { editRecordDialog = null },
onConfirm = { liters, total -> onConfirm = { liters, total ->
val newRecord = record.copy( val newRecord = record.copy(liters = liters, totalPrice = total)
liters = liters,
totalPrice = total
)
FuelStorage.updateRecord(context, record, newRecord) FuelStorage.updateRecord(context, record, newRecord)
fuelRecords = FuelStorage.getRecords(context) fuelRecords = FuelStorage.getRecords(context)
editRecordDialog = null editRecordDialog = null
@@ -347,35 +328,17 @@ fun GasStationApp(repository: GasStationRepository) {
} }
@Composable @Composable
fun GasStationList( fun GasStationList(stations: List<GasStationInfo>, onLogClick: (GasStationInfo) -> Unit) {
stations: List<GasStationInfo>, val context = LocalContext.current
context: android.content.Context,
scope: kotlinx.coroutines.CoroutineScope,
repository: GasStationRepository,
lastLocation: android.location.Location?,
onLogClick: (GasStationInfo) -> Unit,
onUpdateStations: (List<GasStationInfo>) -> Unit,
searchQuery: String
) {
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) { LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(stations) { station -> items(stations) { station ->
GasStationItem( GasStationItem(
station = station, station = station,
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)})")
val intent = Intent(Intent.ACTION_VIEW, uri) context.startActivity(Intent(Intent.ACTION_VIEW, uri))
context.startActivity(intent)
}, },
onLogClick = { onLogClick(station) }, onLogClick = { onLogClick(station) }
onFavoriteClick = {
FavoriteStorage.toggleFavorite(context, station.id)
val loc = lastLocation ?: LocationStorage.getLastLocation(context)
loc?.let {
scope.launch {
onUpdateStations(repository.getCheapestAndNearest(context, it, municipalityQuery = if(searchQuery.isNotBlank()) searchQuery else null))
}
}
}
) )
} }
} }
@@ -384,16 +347,11 @@ fun GasStationList(
@Composable @Composable
fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit, onEdit: (FuelRecord) -> Unit) { fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit, onEdit: (FuelRecord) -> Unit) {
if (records.isEmpty()) { if (records.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Sin registros") }
Text("Aún no tienes repostajes registrados")
}
} else { } else {
LazyColumn(contentPadding = PaddingValues(16.dp)) { LazyColumn(contentPadding = PaddingValues(16.dp)) {
items(records) { record -> items(records) { record ->
Card( Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
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)) {
@@ -401,26 +359,15 @@ fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit,
Text("${record.municipality} - ${record.address}", style = MaterialTheme.typography.bodySmall) Text("${record.municipality} - ${record.address}", style = MaterialTheme.typography.bodySmall)
} }
Row { Row {
IconButton(onClick = { onEdit(record) }, modifier = Modifier.size(24.dp)) { IconButton(onClick = { onEdit(record) }, modifier = Modifier.size(24.dp)) { Icon(Icons.Default.Edit, null, modifier = Modifier.size(18.dp)) }
Icon(Icons.Default.Edit, contentDescription = "Editar", modifier = Modifier.size(18.dp))
}
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = { onDelete(record) }, modifier = Modifier.size(24.dp)) { IconButton(onClick = { onDelete(record) }, modifier = Modifier.size(24.dp)) { Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.error) }
Icon(Icons.Default.Delete, contentDescription = "Eliminar", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.error)
} }
} }
} 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))
Text("${record.liters} L x ${record.pricePerLiter} €/L", style = MaterialTheme.typography.bodyMedium) Text("${record.liters} L x ${record.pricePerLiter} €/L", style = MaterialTheme.typography.bodyMedium)
Text( Text("Total: ${record.totalPrice}", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary)
"Total: ${record.totalPrice}",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
} }
} }
} }
@@ -429,13 +376,7 @@ fun FuelHistoryScreen(records: List<FuelRecord>, onDelete: (FuelRecord) -> Unit,
} }
@Composable @Composable
fun FuelLogDialog( fun FuelLogDialog(stationName: String, initialLiters: String = "", initialTotal: String = "", onDismiss: () -> Unit, onConfirm: (Double, Double) -> Unit) {
stationName: String,
initialLiters: String = "",
initialTotal: String = "",
onDismiss: () -> Unit,
onConfirm: (Double, Double) -> Unit
) {
var liters by remember { mutableStateOf(initialLiters) } var liters by remember { mutableStateOf(initialLiters) }
var total by remember { mutableStateOf(initialTotal) } var total by remember { mutableStateOf(initialTotal) }
@@ -446,18 +387,8 @@ fun FuelLogDialog(
Column { Column {
Text(stationName, style = MaterialTheme.typography.bodyLarge) Text(stationName, style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(value = liters, onValueChange = { liters = it }, label = { Text("Litros") }, modifier = Modifier.fillMaxWidth())
value = liters, OutlinedTextField(value = total, onValueChange = { total = it }, label = { Text("Precio Total (€)") }, modifier = Modifier.fillMaxWidth())
onValueChange = { liters = it },
label = { Text("Litros") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = total,
onValueChange = { total = it },
label = { Text("Precio Total (€)") },
modifier = Modifier.fillMaxWidth()
)
} }
}, },
confirmButton = { confirmButton = {
@@ -467,14 +398,12 @@ fun FuelLogDialog(
if (l > 0 && t > 0) onConfirm(l, t) if (l > 0 && t > 0) onConfirm(l, t)
}) { Text("Guardar") } }) { Text("Guardar") }
}, },
dismissButton = { dismissButton = { TextButton(onClick = onDismiss) { Text("Cancelar") } }
TextButton(onClick = onDismiss) { Text("Cancelar") }
}
) )
} }
@Composable @Composable
fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: () -> Unit, onFavoriteClick: () -> Unit) { fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: () -> 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
@@ -490,86 +419,43 @@ fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: ()
else -> MaterialTheme.colorScheme.surfaceVariant else -> MaterialTheme.colorScheme.surfaceVariant
} }
Card( Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp).clickable { onClick() }, elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)) {
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.clickable { onClick() },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row( Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
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( Surface(color = brandColor, modifier = Modifier.size(12.dp).padding(end = 8.dp), shape = androidx.compose.foundation.shape.CircleShape) {}
color = brandColor, Text(text = "${station.name} - ${station.municipality}", style = MaterialTheme.typography.titleMedium, maxLines = 1, modifier = Modifier.weight(1f))
modifier = Modifier.size(12.dp).padding(end = 8.dp),
shape = androidx.compose.foundation.shape.CircleShape
) {}
Text(
text = "${station.name} - ${station.municipality}",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onFavoriteClick) {
Icon(
imageVector = if (station.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = "Favorito",
tint = if (station.isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
} }
Text(text = station.address, style = MaterialTheme.typography.bodySmall, maxLines = 1)
} }
Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.medium) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
text = station.address, text = "${String.format(Locale.getDefault(), "%.1f", station.distance / 1000)} km",
style = MaterialTheme.typography.bodySmall,
maxLines = 1
)
}
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium
) {
Text(
text = "${String.format(Locale.getDefault(), "%.1f", station.distance / 1000)} km (recta)",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
) )
station.durationMinutes?.let { mins ->
Text(
text = "$mins min",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.secondary
)
}
}
} }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Column { Column {
Text( Text(text = "Diésel A: ${station.price} €/L", style = MaterialTheme.typography.titleLarge, color = priceColor, fontWeight = FontWeight.Bold)
text = "Diésel A: ${station.price} €/L",
style = MaterialTheme.typography.titleLarge,
color = priceColor,
fontWeight = FontWeight.Bold
)
station.pricePremium?.let { premium -> station.pricePremium?.let { premium ->
Text( Text(text = "Premium: $premium €/L", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, fontWeight = FontWeight.Medium)
text = "Premium: $premium €/L",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
fontWeight = FontWeight.Medium
)
} }
} }
Button( Button(onClick = onLogClick, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), shape = MaterialTheme.shapes.small) {
onClick = onLogClick, Icon(Icons.Default.Add, null, modifier = Modifier.size(16.dp))
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
shape = MaterialTheme.shapes.small
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text("Registrar") Text("Registrar")
} }
@@ -11,12 +11,10 @@ import androidx.car.app.model.*
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
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
import com.google.android.gms.location.* import com.google.android.gms.location.*
import com.google.android.gms.tasks.CancellationTokenSource
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -26,6 +24,7 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
private var gasStations: List<GasStationInfo> = emptyList() private var gasStations: List<GasStationInfo> = emptyList()
private var currentAddress: String? = null private var currentAddress: String? = null
private var isLoading = true private var isLoading = true
private var isReserveFilterActive = false
private val repository = GasStationRepository() private val repository = GasStationRepository()
init { init {
@@ -41,16 +40,9 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
val address = addresses[0] val address = addresses[0]
val street = address.thoroughfare val street = address.thoroughfare
val number = address.subThoroughfare val number = address.subThoroughfare
currentAddress = if (street != null && number != null) "$street, $number" else street ?: address.getAddressLine(0)
currentAddress = if (street != null && number != null) {
"$street, $number"
} else {
street ?: address.getAddressLine(0)
}
}
} catch (e: Exception) {
// Silencioso
} }
} catch (e: Exception) {}
} }
} }
@@ -58,12 +50,23 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
private fun fetchLocationAndGasStations(forceRefresh: Boolean = false) { private fun fetchLocationAndGasStations(forceRefresh: Boolean = false) {
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(carContext) val fusedLocationClient = LocationServices.getFusedLocationProviderClient(carContext)
// Cargar última ubicación guardada inmediatamente si no es un refresh forzado
if (!forceRefresh) { if (!forceRefresh) {
LocationStorage.getLastLocation(carContext)?.let { lastLoc -> LocationStorage.getLastLocation(carContext)?.let { lastLoc ->
lifecycleScope.launch { lifecycleScope.launch {
updateAddress(lastLoc) updateAddress(lastLoc)
gasStations = repository.getCheapestAndNearest(carContext, lastLoc) // Primero recta
gasStations = repository.getCheapestAndNearest(
userLocation = lastLoc,
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
getRealDistances = false
)
invalidate()
// Luego real
gasStations = repository.getCheapestAndNearest(
userLocation = lastLoc,
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
getRealDistances = true
)
invalidate() invalidate()
} }
} }
@@ -79,79 +82,60 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
LocationStorage.saveLocation(carContext, location) LocationStorage.saveLocation(carContext, location)
lifecycleScope.launch { lifecycleScope.launch {
updateAddress(location) updateAddress(location)
gasStations = repository.getCheapestAndNearest(carContext, location, forceRefresh) gasStations = repository.getCheapestAndNearest(
userLocation = location,
forceRefresh = forceRefresh,
maxDistanceKm = if (isReserveFilterActive) 40.0 else null,
getRealDistances = true
)
isLoading = false isLoading = false
invalidate() invalidate()
} }
// Si era un forceRefresh, después de la primera actualización volvemos a false
// para que los updates automáticos del GPS no sigan forzando red innecesariamente
if (forceRefresh) {
fusedLocationClient.removeLocationUpdates(this)
fetchLocationAndGasStations(false)
}
} }
} }
fusedLocationClient.requestLocationUpdates( fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, carContext.mainLooper)
locationRequest,
locationCallback,
carContext.mainLooper
)
} }
override fun onGetTemplate(): Template { override fun onGetTemplate(): Template {
val listBuilder = ItemList.Builder() val listBuilder = ItemList.Builder()
val refreshAction = Action.Builder() val reserveAction = Action.Builder()
.setTitle("Refrescar") .setTitle(if (isReserveFilterActive) "Todo" else "Reserva")
.setOnClickListener { .setOnClickListener {
isReserveFilterActive = !isReserveFilterActive
isLoading = true isLoading = true
invalidate() invalidate()
fetchLocationAndGasStations(forceRefresh = true) fetchLocationAndGasStations(forceRefresh = false)
} }
.build() .build()
if (isLoading && gasStations.isEmpty()) { if (isLoading && gasStations.isEmpty()) {
return MessageTemplate.Builder("Buscando gasolineras...") return MessageTemplate.Builder("Buscando...")
.setLoading(true) .setLoading(true)
.setHeader( .setHeader(Header.Builder().setTitle("Gasolineras").build())
Header.Builder()
.setStartHeaderAction(Action.APP_ICON)
.setTitle("Gasolineras")
.build()
)
.build() .build()
} }
if (gasStations.isEmpty()) { if (gasStations.isEmpty()) {
return MessageTemplate.Builder("No se encontraron gasolineras o no hay permiso de ubicación.") return MessageTemplate.Builder("Sin resultados")
.setHeader( .setHeader(Header.Builder().setTitle("Gasolineras").addEndHeaderAction(reserveAction).build())
Header.Builder() .addAction(Action.Builder().setTitle("Reintentar").setOnClickListener {
.setStartHeaderAction(Action.APP_ICON)
.setTitle("Gasolineras")
.addEndHeaderAction(refreshAction)
.build()
)
.addAction(
Action.Builder()
.setTitle("Reintentar")
.setOnClickListener {
isLoading = true isLoading = true
invalidate() invalidate()
fetchLocationAndGasStations() fetchLocationAndGasStations()
} }.build())
.build()
)
.build() .build()
} }
for (station in gasStations) { for (station in gasStations) {
val distanceStr = String.format(Locale.getDefault(), "%.1f km", station.distance / 1000) val distanceStr = String.format(Locale.getDefault(), "%.1f km", station.distance / 1000)
val durationStr = station.durationMinutes?.let { " ($it min)" } ?: ""
val rowBuilder = Row.Builder() val rowBuilder = Row.Builder()
.setTitle("${station.name} - ${station.municipality}") .setTitle("${station.name} - ${station.municipality}")
.addText("A: ${station.price} €/L" + (station.pricePremium?.let { " • P: $it €/L" } ?: "")) .addText("A: ${station.price} €/L" + (station.pricePremium?.let { " • P: $it €/L" } ?: ""))
.addText("$distanceStr (recta) ${station.address}") .addText("$distanceStr$durationStr${station.address}")
.setOnClickListener { .setOnClickListener {
val paneBuilder = Pane.Builder() val paneBuilder = Pane.Builder()
.addRow(Row.Builder().setTitle("Diésel A").addText("${station.price} €/L").build()) .addRow(Row.Builder().setTitle("Diésel A").addText("${station.price} €/L").build())
@@ -162,31 +146,12 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
val pane = paneBuilder val pane = paneBuilder
.addRow(Row.Builder().setTitle("Ubicación").addText("${station.municipality}\n${station.address}").build()) .addRow(Row.Builder().setTitle("Ubicación").addText("${station.municipality}\n${station.address}").build())
.addRow(Row.Builder().setTitle("Distancia").addText("$distanceStr (en línea recta)").build()) .addRow(Row.Builder().setTitle("Distancia").addText("$distanceStr$durationStr").build())
.addAction( .addAction(Action.Builder().setTitle("Ir ahora").setOnClickListener {
Action.Builder()
.setTitle(if (station.isFavorite) "Quitar Favorito" else "Añadir Favorito")
.setOnClickListener {
FavoriteStorage.toggleFavorite(carContext, station.id)
screenManager.pop()
fetchLocationAndGasStations(forceRefresh = false)
}
.build()
)
.addAction(
Action.Builder()
.setTitle("Ir ahora")
.setOnClickListener {
val uri = Uri.parse("geo:${station.latitude},${station.longitude}?q=${station.latitude},${station.longitude}") val uri = Uri.parse("geo:${station.latitude},${station.longitude}?q=${station.latitude},${station.longitude}")
val intent = Intent(CarContext.ACTION_NAVIGATE, uri) carContext.startCarApp(Intent(CarContext.ACTION_NAVIGATE, uri))
carContext.startCarApp(intent) }.build())
} .addAction(Action.Builder().setTitle("Registrar").setOnClickListener {
.build()
)
.addAction(
Action.Builder()
.setTitle("Registrar")
.setOnClickListener {
val record = FuelRecord( val record = FuelRecord(
date = System.currentTimeMillis(), date = System.currentTimeMillis(),
stationName = station.name, stationName = station.name,
@@ -198,22 +163,15 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
) )
FuelStorage.saveRecord(carContext, record) FuelStorage.saveRecord(carContext, record)
screenManager.pop() screenManager.pop()
} }.build())
.build()
)
.build() .build()
screenManager.push( screenManager.push(object : Screen(carContext) {
object : Screen(carContext) { override fun onGetTemplate(): Template = PaneTemplate.Builder(pane)
override fun onGetTemplate(): Template {
return PaneTemplate.Builder(pane)
.setHeader(Header.Builder().setTitle(station.name).setStartHeaderAction(Action.BACK).build()) .setHeader(Header.Builder().setTitle(station.name).setStartHeaderAction(Action.BACK).build())
.build() .build()
})
} }
}
)
}
listBuilder.addItem(rowBuilder.build()) listBuilder.addItem(rowBuilder.build())
} }
@@ -221,9 +179,8 @@ class MainScreen(carContext: CarContext) : Screen(carContext) {
.setSingleList(listBuilder.build()) .setSingleList(listBuilder.build())
.setHeader( .setHeader(
Header.Builder() Header.Builder()
.setStartHeaderAction(Action.APP_ICON) .setTitle(currentAddress ?: "Gasolineras Diésel")
.setTitle(currentAddress ?: "Gasolineras más baratas") .addEndHeaderAction(reserveAction)
.addEndHeaderAction(refreshAction)
.build() .build()
) )
.build() .build()
@@ -1,10 +1,11 @@
package com.example.prueba.data package com.example.prueba.data
import android.content.Context
import android.location.Location import android.location.Location
import com.example.prueba.utils.FavoriteStorage
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
class GasStationRepository { class GasStationRepository {
private var cachedResponse: GasolineraResponse? = null private var cachedResponse: GasolineraResponse? = null
@@ -15,20 +16,24 @@ class GasStationRepository {
.build() .build()
.create(GasolineraApi::class.java) .create(GasolineraApi::class.java)
private val routeApi: OsrmApi = Retrofit.Builder()
.baseUrl("https://router.project-osrm.org/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(OsrmApi::class.java)
suspend fun getCheapestAndNearest( suspend fun getCheapestAndNearest(
context: Context,
userLocation: Location, userLocation: Location,
forceRefresh: Boolean = false, forceRefresh: Boolean = false,
municipalityQuery: String? = null, maxDistanceKm: Double? = null,
onlyFavorites: Boolean = false destinationLocation: Location? = null,
getRealDistances: Boolean = false
): List<GasStationInfo> { ): List<GasStationInfo> {
return try { return try {
if (cachedResponse == null || forceRefresh) { if (cachedResponse == null || forceRefresh) {
cachedResponse = api.getGasolineras() cachedResponse = api.getGasolineras()
} }
val favorites = FavoriteStorage.getFavoriteIds(context)
val allStations = cachedResponse?.listaEESS?.mapNotNull { val allStations = cachedResponse?.listaEESS?.mapNotNull {
val lat = it.latitud.replace(",", ".").toDoubleOrNull() val lat = it.latitud.replace(",", ".").toDoubleOrNull()
val lon = it.longitud.replace(",", ".").toDoubleOrNull() val lon = it.longitud.replace(",", ".").toDoubleOrNull()
@@ -42,7 +47,17 @@ class GasStationRepository {
latitude = lat latitude = lat
longitude = lon longitude = lon
} }
val distance = userLocation.distanceTo(stationLoc) val distanceToUser = userLocation.distanceTo(stationLoc)
val validPrice = (dieselPrice ?: 0.0) > 0.1
var isOnRoute = true
if (destinationLocation != null) {
val distUserToDest = userLocation.distanceTo(destinationLocation)
val distStationToDest = stationLoc.distanceTo(destinationLocation)
isOnRoute = (distanceToUser + distStationToDest) <= (distUserToDest * 1.15)
}
if (isOnRoute && validPrice) {
GasStationInfo( GasStationInfo(
id = it.id, id = it.id,
name = it.rotulo, name = it.rotulo,
@@ -50,37 +65,59 @@ class GasStationRepository {
municipality = it.municipio, municipality = it.municipio,
price = dieselPrice ?: 0.0, price = dieselPrice ?: 0.0,
pricePremium = premiumPrice, pricePremium = premiumPrice,
distance = distance, distance = distanceToUser,
latitude = lat, latitude = lat,
longitude = lon, longitude = lon
isFavorite = favorites.contains(it.id)
) )
} else null } else null
} else null } else null
} else null
} ?: emptyList() } ?: emptyList()
val filteredStations = when { // Radio máximo: 30km si es local, 400km si hay destino (ruta larga)
onlyFavorites -> allStations.filter { it.isFavorite } val finalMaxDist = maxDistanceKm ?: if (destinationLocation == null) 30.0 else 400.0
.sortedBy { it.distance }
!municipalityQuery.isNullOrBlank() -> allStations.filter { it.municipality.contains(municipalityQuery, ignoreCase = true) }
.sortedBy { it.distance }
else -> allStations.sortedBy { it.distance }.take(50)
}
if (filteredStations.isEmpty()) return emptyList() val filtered = allStations.filter { (it.distance / 1000.0) <= finalMaxDist }
val minPrice = filteredStations.minOf { it.price } if (filtered.isEmpty()) return emptyList()
val avgPrice = filteredStations.map { it.price }.average()
filteredStations.map { station -> val minPrice = filtered.minOf { it.price }
val avgPrice = filtered.map { it.price }.average()
var results = filtered.map { station ->
val category = when { val category = when {
station.price <= minPrice * 1.01 -> PriceCategory.CHEAP station.price <= minPrice * 1.01 -> PriceCategory.CHEAP
station.price <= avgPrice -> PriceCategory.NORMAL station.price <= avgPrice -> PriceCategory.NORMAL
else -> PriceCategory.EXPENSIVE else -> PriceCategory.EXPENSIVE
} }
station.copy(priceCategory = category) station.copy(priceCategory = category)
}.sortedWith(compareByDescending<GasStationInfo> { it.isFavorite }.thenBy { it.price }) }.sortedBy { it.distance }
.take(if (onlyFavorites) 50 else 15) .take(if (destinationLocation != null) 30 else 15)
if (getRealDistances) {
coroutineScope {
val tasks = results.map { station ->
async {
try {
val coords = "${userLocation.longitude},${userLocation.latitude};${station.longitude},${station.latitude}"
val routeResponse = routeApi.getRoute(coords)
val route = routeResponse.routes.firstOrNull()
if (route != null) {
station.copy(
distance = route.distance.toFloat(),
durationMinutes = (route.duration / 60.0).toInt()
)
} else station
} catch (e: Exception) {
station
}
}
}
results = tasks.awaitAll().sortedBy { it.distance }
}
}
results
} catch (e: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }
@@ -97,8 +134,8 @@ data class GasStationInfo(
val price: Double, val price: Double,
val pricePremium: Double? = null, val pricePremium: Double? = null,
val distance: Float, val distance: Float,
val durationMinutes: Int? = null,
val latitude: Double, val latitude: Double,
val longitude: Double, val longitude: Double,
val isFavorite: Boolean = false,
val priceCategory: PriceCategory = PriceCategory.NORMAL val priceCategory: PriceCategory = PriceCategory.NORMAL
) )
@@ -0,0 +1,24 @@
package com.example.prueba.data
import com.google.gson.annotations.SerializedName
import retrofit2.http.GET
import retrofit2.http.Path
data class OsrmResponse(
@SerializedName("routes")
val routes: List<OsrmRoute>
)
data class OsrmRoute(
@SerializedName("distance")
val distance: Double, // En metros
@SerializedName("duration")
val duration: Double // En segundos
)
interface OsrmApi {
@GET("route/v1/driving/{coords}?overview=false")
suspend fun getRoute(
@Path("coords") coords: String
): OsrmResponse
}
@@ -1,30 +0,0 @@
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 prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val favorites = getFavoriteIds(context).toMutableSet()
if (favorites.contains(stationId)) {
favorites.remove(stationId)
} else {
favorites.add(stationId)
}
prefs.edit().putStringSet(KEY_FAVORITES, favorites).apply()
}
fun getFavoriteIds(context: Context): Set<String> {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getStringSet(KEY_FAVORITES, emptySet()) ?: emptySet()
}
fun isFavorite(context: Context, stationId: String): Boolean {
return getFavoriteIds(context).contains(stationId)
}
}