Inicio de aplicacion

This commit is contained in:
2026-08-10 12:15:45 +02:00
parent ccb900c453
commit 3d839131bd
17 changed files with 1027 additions and 21 deletions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="25" />
</component>
</project>
+6
View File
@@ -5,6 +5,12 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7">
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="jbr-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PlanningModeManager">
<option name="approvalStates">
<map>
<entry key="f9d713bb-3de6-48b7-9013-a05b442ed5a5" value="false" />
</map>
</option>
</component>
</project>
+5
View File
@@ -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)
+31
View File
@@ -2,6 +2,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-feature
android:name="android.hardware.type.automotive"
android:required="false" />
<uses-feature
android:name="android.software.car.templates_host"
android:required="false" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -11,6 +22,14 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Prueba">
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -23,6 +42,18 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".MyCarAppService"
android:exported="true"
android:label="@string/app_name"
android:permission="androidx.car.app.PERMISSION_CAR_APP">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.POI" />
</intent-filter>
</service>
</application>
</manifest>
@@ -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<List<GasStationInfo>>(emptyList()) }
var lastLocation by remember { mutableStateOf<android.location.Location?>(null) }
var fuelRecords by remember { mutableStateOf(FuelStorage.getRecords(context)) }
var showFuelDialog by remember { mutableStateOf<GasStationInfo?>(null) }
var editRecordDialog by remember { mutableStateOf<FuelRecord?>(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<GasStationInfo>,
context: android.content.Context,
scope: kotlinx.coroutines.CoroutineScope,
repository: GasStationRepository,
lastLocation: android.location.Location?,
onLogClick: (GasStationInfo) -> Unit,
onUpdateStations: (List<GasStationInfo>) -> 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<FuelRecord>, 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")
}
}
}
}
}
}
@@ -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<GasStationInfo> = 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()
}
}
@@ -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)
}
}
}
}
@@ -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<GasStationInfo> {
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<GasStationInfo> { 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
)
@@ -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<Gasolinera>
)
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
}
@@ -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<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)
}
}
@@ -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<FuelRecord> {
val json = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_RECORDS, null) ?: return emptyList()
val type = object : TypeToken<List<FuelRecord>>() {}.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<FuelRecord>) {
val json = gson.toJson(records)
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_RECORDS, json)
.apply()
}
}
@@ -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()
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="template" />
</automotiveApp>
+12
View File
@@ -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
+8
View File
@@ -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" }