580 lines
25 KiB
Kotlin
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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|