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" }