Files
Prueba/app/src/main/java/com/example/prueba/MainActivity.kt
T
2026-08-10 12:42:28 +02:00

580 lines
25 KiB
Kotlin

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.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
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.data.PriceCategory
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 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 {
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 searchQuery by remember { mutableStateOf("") }
var isSearchActive by remember { mutableStateOf(false) }
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, searchQuery, selectedTab) {
if (hasPermission) {
val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
val loc = lastLocation ?: LocationStorage.getLastLocation(context)
loc?.let {
stations = repository.getCheapestAndNearest(
context = context,
userLocation = it,
municipalityQuery = if (selectedTab == 0) searchQuery.ifBlank { null } else null,
onlyFavorites = (selectedTab == 1)
)
}
if (selectedTab != 2) 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)
if (searchQuery.isBlank()) {
scope.launch {
updateAddress(location)
stations = repository.getCheapestAndNearest(context, location)
isLoading = false
}
} else {
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 = {
Column {
CenterAlignedTopAppBar(
title = {
if (isSearchActive) {
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text("Buscar municipio...") },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent
),
trailingIcon = {
IconButton(onClick = {
searchQuery = ""
isSearchActive = false
}) {
Icon(Icons.Default.Close, contentDescription = "Cerrar")
}
}
)
} else {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Gasolineras Diésel", style = MaterialTheme.typography.titleSmall)
Text(
text = currentAddress,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1
)
}
}
},
actions = {
if (!isSearchActive && selectedTab != 2) {
IconButton(onClick = { isSearchActive = true }) {
Icon(Icons.Default.Search, contentDescription = "Buscar")
}
}
}
)
}
},
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Place, "Gasolineras") },
label = { Text("Diésel") },
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, municipalityQuery = if(searchQuery.isNotBlank()) searchQuery else null)
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 },
searchQuery = searchQuery
)
}
} else {
if (stations.isEmpty()) {
Text("No tienes gasolineras favoritas marcadas", modifier = Modifier.align(Alignment.Center))
} else {
GasStationList(
stations = stations,
context = context,
scope = scope,
repository = repository,
lastLocation = lastLocation,
onLogClick = { showFuelDialog = it },
onUpdateStations = { stations = it },
searchQuery = ""
)
}
}
}
}
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,
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,
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,
searchQuery: String
) {
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)
val loc = lastLocation ?: LocationStorage.getLastLocation(context)
loc?.let {
scope.launch {
onUpdateStations(repository.getCheapestAndNearest(context, it, municipalityQuery = if(searchQuery.isNotBlank()) searchQuery else null))
}
}
}
)
}
}
}
@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
)
}
}
}
}
}
}
@Composable
fun FuelLogDialog(
stationName: String,
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") }
}
)
}
@Composable
fun GasStationItem(station: GasStationInfo, onClick: () -> Unit, onLogClick: () -> Unit, onFavoriteClick: () -> Unit) {
val priceColor = when (station.priceCategory) {
PriceCategory.CHEAP -> Color(0xFF2E7D32)
PriceCategory.NORMAL -> MaterialTheme.colorScheme.primary
PriceCategory.EXPENSIVE -> Color(0xFFC62828)
}
val brandColor = when {
station.name.contains("REPSOL", ignoreCase = true) -> Color(0xFFE30613)
station.name.contains("CEPSA", ignoreCase = true) -> Color(0xFFEC0000)
station.name.contains("BP", ignoreCase = true) -> Color(0xFF00A94F)
station.name.contains("GALP", ignoreCase = true) -> Color(0xFFFF6B00)
station.name.contains("SHELL", ignoreCase = true) -> Color(0xFFFFD500)
else -> MaterialTheme.colorScheme.surfaceVariant
}
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) {
Surface(
color = brandColor,
modifier = Modifier.size(12.dp).padding(end = 8.dp),
shape = androidx.compose.foundation.shape.CircleShape
) {}
Text(
text = "${station.name} - ${station.municipality}",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onFavoriteClick) {
Icon(
imageVector = if (station.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = "Favorito",
tint = if (station.isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
}
}
Text(
text = station.address,
style = MaterialTheme.typography.bodySmall,
maxLines = 1
)
}
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium
) {
Text(
text = "${String.format(Locale.getDefault(), "%.1f", station.distance / 1000)} km (recta)",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Column {
Text(
text = "Diésel A: ${station.price} €/L",
style = MaterialTheme.typography.titleLarge,
color = priceColor,
fontWeight = FontWeight.Bold
)
station.pricePremium?.let { premium ->
Text(
text = "Premium: $premium €/L",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
fontWeight = FontWeight.Medium
)
}
}
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")
}
}
}
}
}