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> <GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" /> <option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>
+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_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" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <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(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons)
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.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) testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+31
View File
@@ -2,6 +2,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> 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 <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -11,6 +22,14 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Prueba"> 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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -23,6 +42,18 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </manifest>
@@ -1,29 +1,382 @@
package com.example.prueba 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 android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
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.compose.foundation.layout.fillMaxSize import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.clickable
import androidx.compose.material3.Scaffold import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable 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.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.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() { class MainActivity : ComponentActivity() {
private val repository = GasStationRepository()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
PruebaTheme { PruebaTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> GasStationApp(repository)
Greeting( }
name = "Android", }
modifier = Modifier.padding(innerPadding) }
) }
@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 @Composable
fun Greeting(name: String, modifier: Modifier = Modifier) { fun FuelLogDialog(
Text( stationName: String,
text = "Hello $name!", pricePerLiter: Double,
modifier = modifier 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 @Composable
fun GreetingPreview() { fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: () -> Unit, onFavoriteClick: () -> Unit) {
PruebaTheme { Card(
Greeting("Android") 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" activityCompose = "1.13.0"
kotlin = "2.2.10" kotlin = "2.2.10"
composeBom = "2026.02.01" composeBom = "2026.02.01"
androidx-car-app = "1.7.0"
retrofit = "2.9.0"
playServicesLocation = "21.3.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }