diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..bcf4711
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 7505d8d..cdbc250 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -5,6 +5,12 @@
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index c2b3ddc..a7fab1c 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,7 +1,7 @@
-
+
diff --git a/.idea/planningMode.xml b/.idea/planningMode.xml
new file mode 100644
index 0000000..d5006ec
--- /dev/null
+++ b/.idea/planningMode.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 61ad8f7..df968e7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -39,11 +39,16 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.material.icons)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.car.app)
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.gson)
+ implementation(libs.play.services.location)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8f54c8b..d61cb0f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/prueba/MainActivity.kt b/app/src/main/java/com/example/prueba/MainActivity.kt
index c9aad70..9ded489 100644
--- a/app/src/main/java/com/example/prueba/MainActivity.kt
+++ b/app/src/main/java/com/example/prueba/MainActivity.kt
@@ -1,29 +1,382 @@
package com.example.prueba
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Geocoder
+import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
+import androidx.activity.result.contract.ActivityResultContracts
+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.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import com.example.prueba.data.GasStationInfo
+import com.example.prueba.data.GasStationRepository
import com.example.prueba.ui.theme.PruebaTheme
+import com.example.prueba.utils.FavoriteStorage
+import com.example.prueba.utils.FuelRecord
+import com.example.prueba.utils.FuelStorage
+import com.example.prueba.utils.LocationStorage
+import com.google.android.gms.location.*
+import com.google.android.gms.tasks.CancellationTokenSource
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.Locale
class MainActivity : ComponentActivity() {
+ private val repository = GasStationRepository()
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PruebaTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- Greeting(
- name = "Android",
- modifier = Modifier.padding(innerPadding)
- )
+ GasStationApp(repository)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GasStationApp(repository: GasStationRepository) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ var stations by remember { mutableStateOf>(emptyList()) }
+ var lastLocation by remember { mutableStateOf(null) }
+ var fuelRecords by remember { mutableStateOf(FuelStorage.getRecords(context)) }
+ var showFuelDialog by remember { mutableStateOf(null) }
+ var editRecordDialog by remember { mutableStateOf(null) }
+ var currentAddress by remember { mutableStateOf("Buscando ubicación...") }
+ var isLoading by remember { mutableStateOf(false) }
+ var isRefreshing by remember { mutableStateOf(false) }
+ var selectedTab by remember { mutableIntStateOf(0) }
+
+ var hasPermission by remember {
+ mutableStateOf(
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.ACCESS_FINE_LOCATION
+ ) == PackageManager.PERMISSION_GRANTED
+ )
+ }
+
+ val launcher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ hasPermission = isGranted
+ }
+
+ suspend fun updateAddress(location: android.location.Location) {
+ withContext(Dispatchers.IO) {
+ try {
+ val geocoder = Geocoder(context, Locale.getDefault())
+ val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1)
+ if (!addresses.isNullOrEmpty()) {
+ val address = addresses[0]
+ val street = address.thoroughfare
+ val number = address.subThoroughfare
+
+ currentAddress = if (street != null && number != null) {
+ "$street, $number"
+ } else {
+ street ?: address.getAddressLine(0) ?: "Ubicación desconocida"
+ }
+ }
+ } catch (ignore: Exception) {}
+ }
+ }
+
+ LaunchedEffect(hasPermission) {
+ if (hasPermission) {
+ val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
+ LocationStorage.getLastLocation(context)?.let { lastLoc ->
+ scope.launch {
+ updateAddress(lastLoc)
+ stations = repository.getCheapestAndNearest(context, lastLoc)
+ }
+ }
+
+ isLoading = true
+ val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 10000)
+ .setMinUpdateDistanceMeters(500f)
+ .build()
+
+ val locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ lastLocation = location
+ LocationStorage.saveLocation(context, location)
+ scope.launch {
+ updateAddress(location)
+ stations = repository.getCheapestAndNearest(context, location)
+ isLoading = false
+ }
+ }
+ }
+
+ try {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
+ fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, android.os.Looper.getMainLooper())
+ }
+ } catch (e: Exception) {
+ isLoading = false
+ }
+ }
+ }
+
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("Gasolineras Baratas", style = MaterialTheme.typography.titleSmall)
+ Text(
+ text = currentAddress,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1
+ )
+ }
+ }
+ )
+ },
+ bottomBar = {
+ NavigationBar {
+ NavigationBarItem(
+ icon = { Icon(Icons.Default.Place, "Gasolineras") },
+ label = { Text("Gasolineras") },
+ selected = selectedTab == 0,
+ onClick = { selectedTab = 0 }
+ )
+ NavigationBarItem(
+ icon = { Icon(Icons.Default.Favorite, "Favoritas") },
+ label = { Text("Favoritas") },
+ selected = selectedTab == 1,
+ onClick = { selectedTab = 1 }
+ )
+ NavigationBarItem(
+ icon = { Icon(Icons.Default.DateRange, "Historial") },
+ label = { Text("Historial") },
+ selected = selectedTab == 2,
+ onClick = { selectedTab = 2 }
+ )
+ }
+ }
+ ) { innerPadding ->
+ Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
+ if (!hasPermission) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text("Se necesita permiso de ubicación")
+ Button(onClick = { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }) {
+ Text("Dar permiso")
+ }
+ }
+ } else {
+ when (selectedTab) {
+ 0, 1 -> {
+ PullToRefreshBox(
+ isRefreshing = isRefreshing,
+ onRefresh = {
+ lastLocation?.let { loc ->
+ isRefreshing = true
+ scope.launch {
+ stations = repository.getCheapestAndNearest(context, loc, forceRefresh = true)
+ isRefreshing = false
+ }
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ ) {
+ if (selectedTab == 0) {
+ if (isLoading && stations.isEmpty()) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ } else if (stations.isEmpty()) {
+ Text("No se encontraron gasolineras", modifier = Modifier.align(Alignment.Center))
+ } else {
+ GasStationList(
+ stations = stations,
+ context = context,
+ scope = scope,
+ repository = repository,
+ lastLocation = lastLocation,
+ onLogClick = { showFuelDialog = it },
+ onUpdateStations = { stations = it }
+ )
+ }
+ } else {
+ val favoriteStations = stations.filter { it.isFavorite }
+ if (favoriteStations.isEmpty()) {
+ Text("No tienes gasolineras favoritas marcadas", modifier = Modifier.align(Alignment.Center))
+ } else {
+ GasStationList(
+ stations = favoriteStations,
+ context = context,
+ scope = scope,
+ repository = repository,
+ lastLocation = lastLocation,
+ onLogClick = { showFuelDialog = it },
+ onUpdateStations = { stations = it }
+ )
+ }
+ }
+ }
+ }
+ 2 -> {
+ FuelHistoryScreen(
+ records = fuelRecords,
+ onDelete = { record ->
+ FuelStorage.deleteRecord(context, record)
+ fuelRecords = FuelStorage.getRecords(context)
+ },
+ onEdit = { record ->
+ editRecordDialog = record
+ }
+ )
+ }
+ }
+ }
+
+ showFuelDialog?.let { station ->
+ FuelLogDialog(
+ stationName = station.name,
+ pricePerLiter = station.price,
+ onDismiss = { showFuelDialog = null },
+ onConfirm = { liters, total ->
+ val record = FuelRecord(
+ date = System.currentTimeMillis(),
+ stationName = station.name,
+ address = station.address,
+ municipality = station.municipality,
+ liters = liters,
+ totalPrice = total,
+ pricePerLiter = station.price
+ )
+ FuelStorage.saveRecord(context, record)
+ fuelRecords = FuelStorage.getRecords(context)
+ showFuelDialog = null
+ }
+ )
+ }
+
+ editRecordDialog?.let { record ->
+ FuelLogDialog(
+ stationName = record.stationName,
+ pricePerLiter = record.pricePerLiter,
+ initialLiters = record.liters.toString(),
+ initialTotal = record.totalPrice.toString(),
+ onDismiss = { editRecordDialog = null },
+ onConfirm = { liters, total ->
+ val newRecord = record.copy(
+ liters = liters,
+ totalPrice = total
+ )
+ FuelStorage.updateRecord(context, record, newRecord)
+ fuelRecords = FuelStorage.getRecords(context)
+ editRecordDialog = null
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun GasStationList(
+ stations: List,
+ context: android.content.Context,
+ scope: kotlinx.coroutines.CoroutineScope,
+ repository: GasStationRepository,
+ lastLocation: android.location.Location?,
+ onLogClick: (GasStationInfo) -> Unit,
+ onUpdateStations: (List) -> Unit
+) {
+ LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
+ items(stations) { station ->
+ GasStationItem(
+ station = station,
+ onClick = {
+ 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)
+ },
+ onLogClick = { onLogClick(station) },
+ onFavoriteClick = {
+ FavoriteStorage.toggleFavorite(context, station.id)
+ lastLocation?.let { loc ->
+ scope.launch {
+ onUpdateStations(repository.getCheapestAndNearest(context, loc))
+ }
+ }
+ }
+ )
+ }
+ }
+}
+
+@Composable
+fun FuelHistoryScreen(records: List, onDelete: (FuelRecord) -> Unit, onEdit: (FuelRecord) -> Unit) {
+ if (records.isEmpty()) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Aún no tienes repostajes registrados")
+ }
+ } else {
+ LazyColumn(contentPadding = PaddingValues(16.dp)) {
+ items(records) { record ->
+ 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)
+ Text("${record.municipality} - ${record.address}", style = MaterialTheme.typography.bodySmall)
+ }
+ Row {
+ IconButton(onClick = { onEdit(record) }, modifier = Modifier.size(24.dp)) {
+ Icon(Icons.Default.Edit, contentDescription = "Editar", modifier = Modifier.size(18.dp))
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ IconButton(onClick = { onDelete(record) }, modifier = Modifier.size(24.dp)) {
+ 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
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text("${record.liters} L x ${record.pricePerLiter} €/L", style = MaterialTheme.typography.bodyMedium)
+ Text(
+ "Total: ${record.totalPrice} €",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
}
}
}
@@ -31,17 +384,122 @@ class MainActivity : ComponentActivity() {
}
@Composable
-fun Greeting(name: String, modifier: Modifier = Modifier) {
- Text(
- text = "Hello $name!",
- modifier = modifier
+fun FuelLogDialog(
+ stationName: String,
+ pricePerLiter: Double,
+ initialLiters: String = "",
+ initialTotal: String = "",
+ onDismiss: () -> Unit,
+ onConfirm: (Double, Double) -> Unit
+) {
+ var liters by remember { mutableStateOf(initialLiters) }
+ var total by remember { mutableStateOf(initialTotal) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Registrar Repostaje") },
+ text = {
+ Column {
+ Text(stationName, style = MaterialTheme.typography.bodyLarge)
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = liters,
+ onValueChange = { liters = it },
+ label = { Text("Litros") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ OutlinedTextField(
+ value = total,
+ onValueChange = { total = it },
+ label = { Text("Precio Total (€)") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ },
+ confirmButton = {
+ Button(onClick = {
+ val l = liters.toDoubleOrNull() ?: 0.0
+ val t = total.toDoubleOrNull() ?: 0.0
+ if (l > 0 && t > 0) onConfirm(l, t)
+ }) { Text("Guardar") }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text("Cancelar") }
+ }
)
}
-@Preview(showBackground = true)
@Composable
-fun GreetingPreview() {
- PruebaTheme {
- Greeting("Android")
+fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: () -> Unit, onFavoriteClick: () -> Unit) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 6.dp)
+ .clickable { onClick() },
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ 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
+ ) {
+ Text(
+ text = "${String.format(Locale.getDefault(), "%.1f", station.distance / 1000)} km",
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "${station.price} €/L",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
+ )
+ Button(
+ onClick = onLogClick,
+ 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))
+ Text("Registrar")
+ }
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/example/prueba/MainScreen.kt b/app/src/main/java/com/example/prueba/MainScreen.kt
new file mode 100644
index 0000000..f755684
--- /dev/null
+++ b/app/src/main/java/com/example/prueba/MainScreen.kt
@@ -0,0 +1,225 @@
+package com.example.prueba
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.location.Geocoder
+import android.location.Location
+import android.net.Uri
+import androidx.car.app.CarContext
+import androidx.car.app.Screen
+import androidx.car.app.model.*
+import androidx.lifecycle.lifecycleScope
+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
+import com.google.android.gms.location.*
+import com.google.android.gms.tasks.CancellationTokenSource
+import java.util.Locale
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class MainScreen(carContext: CarContext) : Screen(carContext) {
+ private var gasStations: List = emptyList()
+ private var currentAddress: String? = null
+ private var isLoading = true
+ private val repository = GasStationRepository()
+
+ init {
+ fetchLocationAndGasStations()
+ }
+
+ private suspend fun updateAddress(location: Location) {
+ withContext(Dispatchers.IO) {
+ try {
+ val geocoder = Geocoder(carContext, Locale.getDefault())
+ val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1)
+ if (!addresses.isNullOrEmpty()) {
+ val address = addresses[0]
+ val street = address.thoroughfare
+ val number = address.subThoroughfare
+
+ currentAddress = if (street != null && number != null) {
+ "$street, $number"
+ } else {
+ street ?: address.getAddressLine(0)
+ }
+ }
+ } catch (e: Exception) {
+ // Silencioso
+ }
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun fetchLocationAndGasStations(forceRefresh: Boolean = false) {
+ val fusedLocationClient = LocationServices.getFusedLocationProviderClient(carContext)
+
+ // Cargar última ubicación guardada inmediatamente si no es un refresh forzado
+ if (!forceRefresh) {
+ LocationStorage.getLastLocation(carContext)?.let { lastLoc ->
+ lifecycleScope.launch {
+ updateAddress(lastLoc)
+ gasStations = repository.getCheapestAndNearest(carContext, lastLoc)
+ invalidate()
+ }
+ }
+ }
+
+ val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 15000)
+ .setMinUpdateDistanceMeters(500f)
+ .build()
+
+ val locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ LocationStorage.saveLocation(carContext, location)
+ lifecycleScope.launch {
+ updateAddress(location)
+ gasStations = repository.getCheapestAndNearest(carContext, location, forceRefresh)
+ isLoading = false
+ 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(
+ locationRequest,
+ locationCallback,
+ carContext.mainLooper
+ )
+ }
+
+ override fun onGetTemplate(): Template {
+ val listBuilder = ItemList.Builder()
+
+ val refreshAction = Action.Builder()
+ .setTitle("Refrescar")
+ .setOnClickListener {
+ isLoading = true
+ invalidate()
+ fetchLocationAndGasStations(forceRefresh = true)
+ }
+ .build()
+
+ if (isLoading && gasStations.isEmpty()) {
+ return MessageTemplate.Builder("Buscando gasolineras...")
+ .setLoading(true)
+ .setHeader(
+ Header.Builder()
+ .setStartHeaderAction(Action.APP_ICON)
+ .setTitle("Gasolineras")
+ .build()
+ )
+ .build()
+ }
+
+ if (gasStations.isEmpty()) {
+ return MessageTemplate.Builder("No se encontraron gasolineras o no hay permiso de ubicación.")
+ .setHeader(
+ Header.Builder()
+ .setStartHeaderAction(Action.APP_ICON)
+ .setTitle("Gasolineras")
+ .addEndHeaderAction(refreshAction)
+ .build()
+ )
+ .addAction(
+ Action.Builder()
+ .setTitle("Reintentar")
+ .setOnClickListener {
+ isLoading = true
+ invalidate()
+ fetchLocationAndGasStations()
+ }
+ .build()
+ )
+ .build()
+ }
+
+ for (station in gasStations) {
+ val distanceStr = String.format(Locale.getDefault(), "%.1f km", station.distance / 1000)
+ listBuilder.addItem(
+ Row.Builder()
+ .setTitle("${station.name} - ${station.municipality}")
+ .addText("${station.price} €/L • $distanceStr")
+ .addText(station.address)
+ .setOnClickListener {
+ val pane = Pane.Builder()
+ .addRow(Row.Builder().setTitle("Precio").addText("${station.price} €/L").build())
+ .addRow(Row.Builder().setTitle("Distancia").addText(distanceStr).build())
+ .addAction(
+ 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 intent = Intent(CarContext.ACTION_NAVIGATE, uri)
+ carContext.startCarApp(intent)
+ }
+ .build()
+ )
+ .addAction(
+ Action.Builder()
+ .setTitle("Registrar Repostaje")
+ .setOnClickListener {
+ // Guardar record simplificado desde el coche
+ val record = FuelRecord(
+ date = System.currentTimeMillis(),
+ stationName = station.name,
+ address = station.address,
+ municipality = station.municipality,
+ liters = 0.0, // Se editará en el móvil después
+ totalPrice = 0.0,
+ pricePerLiter = station.price
+ )
+ FuelStorage.saveRecord(carContext, record)
+ screenManager.pop()
+ }
+ .build()
+ )
+ .build()
+
+ screenManager.push(
+ object : Screen(carContext) {
+ override fun onGetTemplate(): Template {
+ return PaneTemplate.Builder(pane)
+ .setHeader(Header.Builder().setTitle(station.name).setStartHeaderAction(Action.BACK).build())
+ .build()
+ }
+ }
+ )
+ }
+ .build()
+ )
+ }
+
+ return ListTemplate.Builder()
+ .setSingleList(listBuilder.build())
+ .setHeader(
+ Header.Builder()
+ .setStartHeaderAction(Action.APP_ICON)
+ .setTitle(currentAddress ?: "Gasolineras más baratas")
+ .addEndHeaderAction(refreshAction)
+ .build()
+ )
+ .build()
+ }
+}
diff --git a/app/src/main/java/com/example/prueba/MyCarAppService.kt b/app/src/main/java/com/example/prueba/MyCarAppService.kt
new file mode 100644
index 0000000..e6d2542
--- /dev/null
+++ b/app/src/main/java/com/example/prueba/MyCarAppService.kt
@@ -0,0 +1,21 @@
+package com.example.prueba
+
+import android.content.Intent
+import android.content.pm.ApplicationInfo
+import androidx.car.app.CarAppService
+import androidx.car.app.Session
+import androidx.car.app.validation.HostValidator
+
+class MyCarAppService : CarAppService() {
+ override fun createHostValidator(): HostValidator {
+ return HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
+ }
+
+ override fun onCreateSession(): Session {
+ return object : Session() {
+ override fun onCreateScreen(intent: Intent): androidx.car.app.Screen {
+ return MainScreen(carContext)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/prueba/data/GasStationRepository.kt b/app/src/main/java/com/example/prueba/data/GasStationRepository.kt
new file mode 100644
index 0000000..451d41a
--- /dev/null
+++ b/app/src/main/java/com/example/prueba/data/GasStationRepository.kt
@@ -0,0 +1,69 @@
+package com.example.prueba.data
+
+import android.content.Context
+import android.location.Location
+import com.example.prueba.utils.FavoriteStorage
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+
+class GasStationRepository {
+ private var cachedResponse: GasolineraResponse? = null
+
+ private val api: GasolineraApi = Retrofit.Builder()
+ .baseUrl("https://sedeaplicaciones.minetur.gob.es/ServiciosRESTCarburantes/PreciosCarburantes/")
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ .create(GasolineraApi::class.java)
+
+ suspend fun getCheapestAndNearest(context: Context, userLocation: Location, forceRefresh: Boolean = false): List {
+ return try {
+ if (cachedResponse == null || forceRefresh) {
+ cachedResponse = api.getGasolineras()
+ }
+
+ val favorites = FavoriteStorage.getFavoriteIds(context)
+
+ cachedResponse?.listaEESS?.mapNotNull {
+ val lat = it.latitud.replace(",", ".").toDoubleOrNull()
+ val lon = it.longitud.replace(",", ".").toDoubleOrNull()
+ val price = it.precioDiesel.replace(",", ".").toDoubleOrNull() ?: it.precioGasolina95.replace(",", ".").toDoubleOrNull()
+
+ if (lat != null && lon != null && price != null) {
+ val stationLoc = Location("").apply {
+ latitude = lat
+ longitude = lon
+ }
+ val distance = userLocation.distanceTo(stationLoc)
+ GasStationInfo(
+ id = it.id,
+ name = it.rotulo,
+ address = it.direccion,
+ municipality = it.municipio,
+ price = price,
+ distance = distance,
+ latitude = lat,
+ longitude = lon,
+ isFavorite = favorites.contains(it.id)
+ )
+ } else null
+ }?.sortedBy { it.distance }
+ ?.take(50)
+ ?.sortedWith(compareByDescending { it.isFavorite }.thenBy { it.price })
+ ?.take(10) ?: emptyList()
+ } catch (e: Exception) {
+ emptyList()
+ }
+ }
+}
+
+data class GasStationInfo(
+ val id: String,
+ val name: String,
+ val address: String,
+ val municipality: String,
+ val price: Double,
+ val distance: Float,
+ val latitude: Double,
+ val longitude: Double,
+ val isFavorite: Boolean = false
+)
diff --git a/app/src/main/java/com/example/prueba/data/GasolineraApi.kt b/app/src/main/java/com/example/prueba/data/GasolineraApi.kt
new file mode 100644
index 0000000..4f2d699
--- /dev/null
+++ b/app/src/main/java/com/example/prueba/data/GasolineraApi.kt
@@ -0,0 +1,33 @@
+package com.example.prueba.data
+
+import com.google.gson.annotations.SerializedName
+import retrofit2.http.GET
+
+data class GasolineraResponse(
+ @SerializedName("ListaEESSPrecio")
+ val listaEESS: List
+)
+
+data class Gasolinera(
+ @SerializedName("IDEESS")
+ val id: String,
+ @SerializedName("Rótulo")
+ val rotulo: String,
+ @SerializedName("Dirección")
+ val direccion: String,
+ @SerializedName("Municipio")
+ val municipio: String,
+ @SerializedName("Precio Gasoleo A")
+ val precioDiesel: String,
+ @SerializedName("Precio Gasolina 95 E5")
+ val precioGasolina95: String,
+ @SerializedName("Latitud")
+ val latitud: String,
+ @SerializedName("Longitud (WGS84)")
+ val longitud: String
+)
+
+interface GasolineraApi {
+ @GET("EstacionesTerrestres/")
+ suspend fun getGasolineras(): GasolineraResponse
+}
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..201ec55
--- /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 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 {
+ 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)
+ }
+}
diff --git a/app/src/main/java/com/example/prueba/utils/FuelStorage.kt b/app/src/main/java/com/example/prueba/utils/FuelStorage.kt
new file mode 100644
index 0000000..6572bfe
--- /dev/null
+++ b/app/src/main/java/com/example/prueba/utils/FuelStorage.kt
@@ -0,0 +1,57 @@
+package com.example.prueba.utils
+
+import android.content.Context
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+
+data class FuelRecord(
+ val date: Long,
+ val stationName: String,
+ val address: String,
+ val municipality: String,
+ val liters: Double,
+ val totalPrice: Double,
+ val pricePerLiter: Double
+)
+
+object FuelStorage {
+ private const val PREFS_NAME = "fuel_prefs"
+ private const val KEY_RECORDS = "fuel_records"
+ private val gson = Gson()
+
+ fun saveRecord(context: Context, record: FuelRecord) {
+ val records = getRecords(context).toMutableList()
+ records.add(0, record) // Añadir al principio
+ saveAllRecords(context, records)
+ }
+
+ fun getRecords(context: Context): List {
+ val json = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .getString(KEY_RECORDS, null) ?: return emptyList()
+ val type = object : TypeToken>() {}.type
+ return gson.fromJson(json, type)
+ }
+
+ fun deleteRecord(context: Context, record: FuelRecord) {
+ val records = getRecords(context).toMutableList()
+ records.remove(record)
+ saveAllRecords(context, records)
+ }
+
+ fun updateRecord(context: Context, oldRecord: FuelRecord, newRecord: FuelRecord) {
+ val records = getRecords(context).toMutableList()
+ val index = records.indexOf(oldRecord)
+ if (index != -1) {
+ records[index] = newRecord
+ saveAllRecords(context, records)
+ }
+ }
+
+ private fun saveAllRecords(context: Context, records: List) {
+ val json = gson.toJson(records)
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(KEY_RECORDS, json)
+ .apply()
+ }
+}
diff --git a/app/src/main/java/com/example/prueba/utils/LocationStorage.kt b/app/src/main/java/com/example/prueba/utils/LocationStorage.kt
new file mode 100644
index 0000000..f009adc
--- /dev/null
+++ b/app/src/main/java/com/example/prueba/utils/LocationStorage.kt
@@ -0,0 +1,31 @@
+package com.example.prueba.utils
+
+import android.content.Context
+import android.location.Location
+
+object LocationStorage {
+ private const val PREFS_NAME = "location_prefs"
+ private const val KEY_LAT = "last_lat"
+ private const val KEY_LON = "last_lon"
+
+ fun saveLocation(context: Context, location: Location) {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ prefs.edit()
+ .putFloat(KEY_LAT, location.latitude.toFloat())
+ .putFloat(KEY_LON, location.longitude.toFloat())
+ .apply()
+ }
+
+ fun getLastLocation(context: Context): Location? {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val lat = prefs.getFloat(KEY_LAT, 0f)
+ val lon = prefs.getFloat(KEY_LON, 0f)
+
+ if (lat == 0f && lon == 0f) return null
+
+ return Location("stored").apply {
+ latitude = lat.toDouble()
+ longitude = lon.toDouble()
+ }
+ }
+}
diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 0000000..0fb852c
--- /dev/null
+++ b/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..fa4ed51
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,12 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
+toolchainVersion=25
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index eaf63d0..9543fda 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,6 +8,9 @@ lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.13.0"
kotlin = "2.2.10"
composeBom = "2026.02.01"
+androidx-car-app = "1.7.0"
+retrofit = "2.9.0"
+playServicesLocation = "21.3.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -24,6 +27,11 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-car-app = { group = "androidx.car.app", name = "app", version.ref = "androidx-car-app" }
+retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
+play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }