Ya casiii

This commit is contained in:
JaredAyala
2026-05-23 00:05:10 -06:00
parent ab11b001a4
commit ac0a312d58
20 changed files with 960 additions and 56 deletions

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

View File

@@ -37,21 +37,32 @@ android {
}
dependencies {
// AndroidX (versiones compatibles con SDK 34)
// Forzar versiones compatibles con SDK 34
implementation("androidx.core:core:1.12.0")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.activity:activity:1.8.2")
implementation("androidx.activity:activity-ktx:1.8.2")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.activity:activity-ktx:1.8.2")
implementation("androidx.fragment:fragment-ktx:1.6.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.drawerlayout:drawerlayout:1.2.0")
implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0")
// Firebase
// Firebase (BoM antigua compatible)
implementation(platform("com.google.firebase:firebase-bom:32.7.4"))
implementation("com.google.firebase:firebase-auth-ktx")
implementation("com.google.firebase:firebase-firestore-ktx")
// Retrofit + OkHttp (versiones estables)
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
@@ -59,4 +70,14 @@ dependencies {
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
// Forzar versiones específicas para evitar conflictos transitivos
configurations.all {
resolutionStrategy {
force("androidx.core:core:1.12.0")
force("androidx.core:core-ktx:1.12.0")
force("androidx.activity:activity:1.8.2")
force("androidx.activity:activity-ktx:1.8.2")
}
}

View File

@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
@@ -15,6 +16,9 @@
android:supportsRtl="true"
android:theme="@style/Theme.BasurApp"
tools:targetApi="31">
<activity
android:name=".AddAddressActivity"
android:exported="false" />
<activity
android:name=".RegisterActivity"
android:exported="false" />
@@ -30,6 +34,9 @@
<activity
android:name=".MainActivity"
android:exported="false" />
<activity
android:name=".AddAddressActivity"
android:exported="false" />
</application>
</manifest>

View File

@@ -0,0 +1,94 @@
package com.example.basurapp
import android.os.Bundle
import android.view.View
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.basurapp.api.ApiClient
import com.example.basurapp.databinding.ActivityAddAddressBinding
import com.example.basurapp.models.Address
import com.example.basurapp.models.ColoniaRuta
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.launch
class AddAddressActivity : AppCompatActivity() {
private lateinit var binding: ActivityAddAddressBinding
private val auth: FirebaseAuth by lazy { FirebaseAuth.getInstance() }
private val db: FirebaseFirestore by lazy { FirebaseFirestore.getInstance() }
private var coloniasList: List<ColoniaRuta> = emptyList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityAddAddressBinding.inflate(layoutInflater)
setContentView(binding.root)
loadColonias()
binding.btnSave.setOnClickListener {
saveAddress()
}
}
private fun loadColonias() {
binding.progressBar.visibility = View.VISIBLE
lifecycleScope.launch {
try {
coloniasList = ApiClient.api.getColoniasRutas()
val names = coloniasList.map { it.colonia }
val adapter = ArrayAdapter(this@AddAddressActivity, android.R.layout.simple_dropdown_item_1line, names)
binding.actvColonia.setAdapter(adapter)
binding.progressBar.visibility = View.GONE
} catch (e: Exception) {
binding.progressBar.visibility = View.GONE
Toast.makeText(this@AddAddressActivity, "Error cargando colonias: ${e.localizedMessage}", Toast.LENGTH_LONG).show()
}
}
}
private fun saveAddress() {
val street = binding.etStreet.text?.toString()?.trim().orEmpty()
val number = binding.etNumber.text?.toString()?.trim().orEmpty()
val coloniaSelected = binding.actvColonia.text?.toString()?.trim().orEmpty()
val reference = binding.etReference.text?.toString()?.trim().orEmpty()
if (street.isEmpty() || number.isEmpty() || coloniaSelected.isEmpty()) {
Toast.makeText(this, "Completa calle, número y colonia", Toast.LENGTH_SHORT).show()
return
}
// Validar que la colonia exista en el catálogo
val validColonia = coloniasList.any { it.colonia.equals(coloniaSelected, ignoreCase = true) }
if (!validColonia) {
Toast.makeText(this, "Selecciona una colonia válida de la lista", Toast.LENGTH_SHORT).show()
return
}
val uid = auth.currentUser?.uid ?: return
val addressId = db.collection("addresses").document().id
val address = Address(
id = addressId,
userId = uid,
street = street,
number = number,
neighborhood = coloniaSelected,
city = "Celaya",
reference = reference
)
binding.btnSave.isEnabled = false
db.collection("addresses").document(addressId).set(address)
.addOnSuccessListener {
Toast.makeText(this, "Dirección guardada", Toast.LENGTH_SHORT).show()
finish()
}
.addOnFailureListener { e ->
binding.btnSave.isEnabled = true
Toast.makeText(this, e.localizedMessage ?: "Error", Toast.LENGTH_SHORT).show()
}
}
}

View File

@@ -31,10 +31,10 @@ class LoginActivity : AppCompatActivity() {
}
// Si ya hay sesión activa, ir directo al Main
if (auth.currentUser != null) {
goToMain()
return
}
// if (auth.currentUser != null) {
// goToMain()
// return
// }
binding.tvCreateAccount.setOnClickListener {
startActivity(Intent(this, RegisterActivity::class.java))

View File

@@ -1,11 +1,15 @@
package com.example.basurapp
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import android.widget.TextView
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.fragment.app.Fragment
import com.example.basurapp.databinding.ActivityMainBinding
@@ -50,6 +54,20 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
binding.navigationView.setNavigationItemSelectedListener(this)
loadCurrentUser()
// Solicitar permiso de notificaciones en Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this, android.Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
100
)
}
}
}
private fun loadCurrentUser() {

View File

@@ -0,0 +1,48 @@
package com.example.basurapp.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.basurapp.databinding.ItemAddressBinding
import com.example.basurapp.models.Address
class AddressesAdapter(
private val onDelete: (Address) -> Unit
) : ListAdapter<Address, AddressesAdapter.AddressViewHolder>(DIFF) {
inner class AddressViewHolder(val binding: ItemAddressBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(address: Address) {
binding.tvStreet.text = "${address.street} ${address.number}"
binding.tvNeighborhood.text = address.neighborhood
if (address.reference.isNotEmpty()) {
binding.tvReference.text = address.reference
binding.tvReference.visibility = android.view.View.VISIBLE
} else {
binding.tvReference.visibility = android.view.View.GONE
}
binding.btnDelete.setOnClickListener { onDelete(address) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddressViewHolder {
val binding = ItemAddressBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return AddressViewHolder(binding)
}
override fun onBindViewHolder(holder: AddressViewHolder, position: Int) {
holder.bind(getItem(position))
}
companion object {
val DIFF = object : DiffUtil.ItemCallback<Address>() {
override fun areItemsTheSame(oldItem: Address, newItem: Address) = oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: Address, newItem: Address) = oldItem == newItem
}
}
}

View File

@@ -0,0 +1,35 @@
package com.example.basurapp.api
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object ApiClient {
private const val BASE_URL = "https://td.celaya.biz:8018/"
private val okHttp by lazy {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
}
OkHttpClient.Builder()
.addInterceptor(logging)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
}
private val retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttp)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val api: BasurAppApi by lazy {
retrofit.create(BasurAppApi::class.java)
}
}

View File

@@ -0,0 +1,18 @@
package com.example.basurapp.api
import com.example.basurapp.models.ApiRoute
import com.example.basurapp.models.ColoniaRuta
import com.example.basurapp.models.NotificationRule
import retrofit2.http.GET
interface BasurAppApi {
@GET("sistemasJSON/rutas.json")
suspend fun getRoutes(): List<ApiRoute>
@GET("sistemasJSON/colonias-rutas.json")
suspend fun getColoniasRutas(): List<ColoniaRuta>
@GET("sistemasJSON/notificaciones.json")
suspend fun getNotificationRules(): List<NotificationRule>
}

View File

@@ -1,34 +1,67 @@
package com.example.basurapp.fragments
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.basurapp.AddAddressActivity
import com.example.basurapp.adapters.AddressesAdapter
import com.example.basurapp.adapters.RoutesAdapter
import com.example.basurapp.databinding.HomeFragmentBinding
import com.example.basurapp.api.ApiClient
import com.example.basurapp.databinding.FragmentHomeBinding
import com.example.basurapp.dialogs.RouteInfoDialog
import com.example.basurapp.models.ApiRoute
import com.example.basurapp.models.Address
import com.example.basurapp.models.ColoniaRuta
import com.example.basurapp.models.NotificationRule
import com.example.basurapp.models.Route
import com.example.basurapp.models.RouteStop
import com.example.basurapp.utils.NotificationHelper
import com.example.basurapp.utils.RouteTracker
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.launch
class HomeFragment : Fragment() {
private var _binding: HomeFragmentBinding? = null
private var _binding: FragmentHomeBinding? = null
private val binding get() = _binding!!
private val auth: FirebaseAuth by lazy { FirebaseAuth.getInstance() }
private val db: FirebaseFirestore by lazy { FirebaseFirestore.getInstance() }
private lateinit var routesAdapter: RoutesAdapter
private lateinit var addressesAdapter: AddressesAdapter
private val handler = Handler(Looper.getMainLooper())
private val POLL_INTERVAL_MS = 2_000L // 2 segundos
private var userAddresses: List<Address> = emptyList()
private var coloniasRutas: List<ColoniaRuta> = emptyList()
private var notificationRules: List<NotificationRule> = emptyList()
// Mapa: routeId -> último positionId conocido (para detectar cambios por ruta)
private val lastPositionByRoute = mutableMapOf<String, Int>()
private val pollRunnable = object : Runnable {
override fun run() {
refreshRoutes()
handler.postDelayed(this, POLL_INTERVAL_MS)
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = HomeFragmentBinding.inflate(inflater, container, false)
_binding = FragmentHomeBinding.inflate(inflater, container, false)
return binding.root
}
@@ -42,24 +75,50 @@ class HomeFragment : Fragment() {
binding.rvRoutes.layoutManager = LinearLayoutManager(requireContext())
binding.rvRoutes.adapter = routesAdapter
binding.btnAddAddress.setOnClickListener {
Toast.makeText(requireContext(), "Función Agregar dirección (pendiente)", Toast.LENGTH_SHORT).show()
// TODO: Abrir Activity o dialog para agregar dirección
// Adapter de direcciones
addressesAdapter = AddressesAdapter { address ->
confirmDeleteAddress(address)
}
binding.rvAddresses.layoutManager = LinearLayoutManager(requireContext())
binding.rvAddresses.adapter = addressesAdapter
// Botones para agregar dirección
binding.btnAddAddressEmpty.setOnClickListener {
startActivity(Intent(requireContext(), AddAddressActivity::class.java))
}
binding.btnAddMoreAddress.setOnClickListener {
startActivity(Intent(requireContext(), AddAddressActivity::class.java))
}
checkUserAddress()
NotificationHelper.ensureChannel(requireContext())
loadNotificationRules()
}
private fun checkUserAddress() {
override fun onResume() {
super.onResume()
// Recargar direcciones cada vez que vuelve al fragment (después de agregar)
loadUserAddresses()
}
override fun onPause() {
super.onPause()
stopPolling()
}
private fun loadUserAddresses() {
val uid = auth.currentUser?.uid ?: return
db.collection("addresses")
.whereEqualTo("userId", uid)
.get()
.addOnSuccessListener { result ->
if (result.isEmpty) {
userAddresses = result.documents.mapNotNull { it.toObject(Address::class.java) }
if (userAddresses.isEmpty()) {
showNoAddressState()
stopPolling()
} else {
showRoutesState()
showWithAddressesState()
addressesAdapter.submitList(userAddresses)
startPolling()
}
}
.addOnFailureListener {
@@ -67,51 +126,177 @@ class HomeFragment : Fragment() {
}
}
private fun confirmDeleteAddress(address: Address) {
AlertDialog.Builder(requireContext())
.setTitle("Eliminar dirección")
.setMessage("¿Quieres eliminar ${address.street} ${address.number}?")
.setPositiveButton("Eliminar") { _, _ ->
deleteAddress(address)
}
.setNegativeButton("Cancelar", null)
.show()
}
private fun deleteAddress(address: Address) {
db.collection("addresses").document(address.id).delete()
.addOnSuccessListener {
Toast.makeText(requireContext(), "Dirección eliminada", Toast.LENGTH_SHORT).show()
loadUserAddresses()
}
.addOnFailureListener { e ->
Toast.makeText(requireContext(), e.localizedMessage ?: "Error", Toast.LENGTH_SHORT).show()
}
}
private fun showNoAddressState() {
if (_binding == null) return
binding.layoutNoAddress.visibility = View.VISIBLE
binding.rvRoutes.visibility = View.GONE
binding.layoutWithAddresses.visibility = View.GONE
}
private fun showRoutesState() {
private fun showWithAddressesState() {
if (_binding == null) return
binding.layoutNoAddress.visibility = View.GONE
binding.rvRoutes.visibility = View.VISIBLE
loadRoutes()
binding.layoutWithAddresses.visibility = View.VISIBLE
}
private fun loadRoutes() {
// Por ahora datos de ejemplo (después se conectan a Firestore)
val sampleRoutes = listOf(
Route(
id = "1",
name = "Dirección 1",
neighborhood = "Centro",
stops = listOf(
RouteStop("Inicio", "07:00"),
RouteStop("Calle 5 de Mayo", "07:30"),
RouteStop("Plaza Central", "08:00"),
RouteStop("Av. Juárez", "08:30"),
RouteStop("Final de ruta", "09:00")
),
truckPosition = 2
),
Route(
id = "2",
name = "Dirección 1",
neighborhood = "Zona Norte",
stops = listOf(
RouteStop("Inicio", "10:00"),
RouteStop("Mercado Norte", "10:30"),
RouteStop("Parque Hidalgo", "11:00"),
RouteStop("Final de ruta", "11:30")
),
truckPosition = 1
)
private fun startPolling() {
stopPolling()
handler.post(pollRunnable)
}
private fun stopPolling() {
handler.removeCallbacks(pollRunnable)
}
private fun loadNotificationRules() {
lifecycleScope.launch {
try {
notificationRules = ApiClient.api.getNotificationRules()
} catch (e: Exception) {
// silenciar
}
}
}
private fun refreshRoutes() {
if (userAddresses.isEmpty()) return
lifecycleScope.launch {
try {
if (coloniasRutas.isEmpty()) {
coloniasRutas = ApiClient.api.getColoniasRutas()
}
val allRoutes = ApiClient.api.getRoutes()
// Para cada dirección del usuario, encontrar su ruta correspondiente
val matchedRoutes = mutableListOf<Pair<ApiRoute, ColoniaRuta>>()
val seenRouteIds = mutableSetOf<String>()
for (address in userAddresses) {
val coloniaInfo = coloniasRutas.firstOrNull {
it.colonia.equals(address.neighborhood, ignoreCase = true)
} ?: continue
// Evitar mostrar la misma ruta dos veces si dos direcciones comparten ruta
if (seenRouteIds.contains(coloniaInfo.routeId)) continue
seenRouteIds.add(coloniaInfo.routeId)
val apiRoute = allRoutes.firstOrNull { it.routeId == coloniaInfo.routeId }
?: continue
matchedRoutes.add(apiRoute to coloniaInfo)
// Checar cambio de posición para notificaciones
checkPositionChange(apiRoute, address)
}
// Convertir a modelo visual
val visualRoutes = matchedRoutes.map { (apiRoute, colonia) ->
mapToVisualRoute(apiRoute, colonia)
}
routesAdapter.submitList(visualRoutes) {
// Después de actualizar, forzar redibujado para que se vea el movimiento del camión
routesAdapter.notifyDataSetChanged()
}
routesAdapter.submitList(visualRoutes)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun checkPositionChange(apiRoute: ApiRoute, address: Address) {
val currentId = RouteTracker.getCurrentPositionId(apiRoute)
val lastId = lastPositionByRoute[apiRoute.routeId]
if (lastId == null) {
lastPositionByRoute[apiRoute.routeId] = currentId
return
}
if (currentId == lastId) return
val totalPositions = apiRoute.positions.size
val addressLabel = "${address.street} ${address.number}"
when {
lastId == 1 && currentId >= 2 -> {
fireNotification(NotificationRule.EVENT_ROUTE_START, apiRoute.routeId, addressLabel)
}
lastId < 4 && currentId >= 4 -> {
fireNotification(NotificationRule.EVENT_TRUCK_PROXIMITY, apiRoute.routeId, addressLabel)
}
lastId < totalPositions && currentId >= totalPositions -> {
fireNotification(NotificationRule.EVENT_ROUTE_COMPLETED, apiRoute.routeId, addressLabel)
}
}
lastPositionByRoute[apiRoute.routeId] = currentId
}
private fun fireNotification(triggerEvent: String, routeId: String, addressLabel: String) {
val rule = notificationRules.firstOrNull { it.triggerEvent == triggerEvent } ?: return
val notificationId = (routeId.hashCode() + triggerEvent.hashCode())
NotificationHelper.show(
requireContext(),
notificationId,
rule.pushPayload.title,
"${rule.pushPayload.body}\nDirección: $addressLabel"
)
routesAdapter.submitList(sampleRoutes)
}
private fun mapToVisualRoute(apiRoute: ApiRoute, colonia: ColoniaRuta): Route {
val stops = apiRoute.positions.map { pos ->
RouteStop(
name = "Punto ${pos.positionId}",
time = extractTime(pos.timestamp)
)
}
val currentId = RouteTracker.getCurrentPositionId(apiRoute)
return Route(
id = apiRoute.routeId,
name = "${colonia.colonia} - ${apiRoute.name}",
neighborhood = colonia.colonia,
stops = stops,
truckPosition = (currentId - 1).coerceAtLeast(0)
)
}
private fun extractTime(timestamp: String): String {
return try {
timestamp.substringAfter("T").substring(0, 5)
} catch (e: Exception) {
""
}
}
override fun onDestroyView() {
super.onDestroyView()
stopPolling()
_binding = null
}
}

View File

@@ -1,14 +1,11 @@
package com.example.basurapp.models
/**
* Dirección de un usuario. Colección "addresses" en Firestore.
*/
data class Address(
val id: String = "",
val userId: String = "",
val street: String = "",
val number: String = "",
val neighborhood: String = "",
val city: String = "",
val neighborhood: String = "", // ← este es el nombre exacto de la colonia
val city: String = "Celaya",
val reference: String = ""
)

View File

@@ -0,0 +1,20 @@
package com.example.basurapp.models
/**
* Modelo de la API de rutas (rutas.json)
*/
data class ApiRoute(
val routeId: String = "",
val name: String = "",
val truckId: Int = 0,
val status: String = "",
val positions: List<ApiRoutePosition> = emptyList()
)
data class ApiRoutePosition(
val positionId: Int = 0,
val lat: Double = 0.0,
val lng: Double = 0.0,
val speed: Int = 0,
val timestamp: String = "" // formato ISO: "2026-05-22T06:00:00Z"
)

View File

@@ -0,0 +1,10 @@
package com.example.basurapp.models
/**
* Modelo de colonias-rutas.json
*/
data class ColoniaRuta(
val colonia: String = "",
val routeId: String = "",
val horarioEstimado: String = ""
)

View File

@@ -0,0 +1,21 @@
package com.example.basurapp.models
/**
* Modelo de notificaciones.json
*/
data class NotificationRule(
val triggerEvent: String = "",
val condition: String = "",
val pushPayload: PushPayload = PushPayload()
) {
companion object {
const val EVENT_ROUTE_START = "ROUTE_START"
const val EVENT_TRUCK_PROXIMITY = "TRUCK_PROXIMITY"
const val EVENT_ROUTE_COMPLETED = "ROUTE_COMPLETED"
}
}
data class PushPayload(
val title: String = "",
val body: String = ""
)

View File

@@ -0,0 +1,53 @@
package com.example.basurapp.utils
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.example.basurapp.MainActivity
import com.example.basurapp.R
object NotificationHelper {
private const val CHANNEL_ID = "basurapp_routes"
private const val CHANNEL_NAME = "Rutas de recolección"
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notificaciones sobre el camión recolector"
}
manager.createNotificationChannel(channel)
}
}
fun show(context: Context, notificationId: Int, title: String, body: String) {
ensureChannel(context)
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(notificationId, builder.build())
}
}

View File

@@ -0,0 +1,96 @@
package com.example.basurapp.utils
import android.util.Log
import com.example.basurapp.models.ApiRoute
import com.example.basurapp.models.ApiRoutePosition
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object RouteTracker {
private const val TAG = "RouteTracker"
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
// ======== MODO DEMO ========
// Cambia DEMO_ENABLED a false para usar la hora real del dispositivo
private const val DEMO_ENABLED = true
private const val DEMO_START_HOUR = 6
private const val DEMO_START_MINUTE = 10
private const val DEMO_SPEED = 60 // cada segundo real = X minutos simulados
private var demoStartRealTimeMs: Long = 0L
// ===========================
fun getCurrentPositionId(route: ApiRoute): Int {
if (route.positions.isEmpty()) return 0
val nowMinutes = currentMinutesOfDay()
var currentId = 0
for (pos in route.positions.sortedBy { it.positionId }) {
val posMinutes = timestampToMinutesOfDay(pos.timestamp) ?: continue
if (nowMinutes >= posMinutes) {
currentId = pos.positionId
} else {
break
}
}
Log.d(TAG, "Ruta ${route.routeId}: hora simulada = ${nowMinutes / 60}:${nowMinutes % 60}, positionId = $currentId")
return currentId
}
fun getCurrentPosition(route: ApiRoute): ApiRoutePosition? {
val currentId = getCurrentPositionId(route)
if (currentId == 0) return null
return route.positions.firstOrNull { it.positionId == currentId }
}
fun getProgress(route: ApiRoute): Float {
val currentId = getCurrentPositionId(route)
val total = route.positions.size
if (total == 0) return 0f
return currentId.toFloat() / total.toFloat()
}
fun getStatusText(route: ApiRoute): String {
val currentId = getCurrentPositionId(route)
val total = route.positions.size
return when {
currentId == 0 -> "Aún no inicia la ruta"
currentId == total -> "Ruta completada"
currentId == total - 1 -> "Regresando al depósito"
else -> "En ruta (parada $currentId de $total)"
}
}
private fun currentMinutesOfDay(): Int {
if (DEMO_ENABLED) {
// Inicializar el reloj demo la primera vez
if (demoStartRealTimeMs == 0L) {
demoStartRealTimeMs = System.currentTimeMillis()
Log.d(TAG, "Demo iniciado en ${DEMO_START_HOUR}:${DEMO_START_MINUTE}")
}
val elapsedSeconds = (System.currentTimeMillis() - demoStartRealTimeMs) / 1000
val simulatedMinutesElapsed = elapsedSeconds * DEMO_SPEED / 60
val startMinutes = DEMO_START_HOUR * 60 + DEMO_START_MINUTE
return (startMinutes + simulatedMinutesElapsed).toInt()
}
// Hora real del dispositivo
val cal = java.util.Calendar.getInstance()
return cal.get(java.util.Calendar.HOUR_OF_DAY) * 60 + cal.get(java.util.Calendar.MINUTE)
}
private fun timestampToMinutesOfDay(timestamp: String): Int? {
return try {
val date: Date = isoFormat.parse(timestamp) ?: return null
val cal = java.util.Calendar.getInstance().apply { time = date }
cal.get(java.util.Calendar.HOUR_OF_DAY) * 60 + cal.get(java.util.Calendar.MINUTE)
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:text="Agregar dirección"
android:textColor="@color/gray_900"
android:textSize="20sp"
android:textStyle="bold" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:hint="Calle">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etStreet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPostalAddress" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Número">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNumber"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Colonia"
app:endIconMode="dropdown_menu">
<com.google.android.material.textfield.MaterialAutoCompleteTextView
android:id="@+id/actvColonia"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Referencia (opcional)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etReference"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="32dp"
android:text="Guardar"
app:cornerRadius="28dp" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="16dp"
android:visibility="gone" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white">
<!-- Estado "sin dirección" -->
<LinearLayout
android:id="@+id/layoutNoAddress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/no_address_title"
android:textColor="@color/gray_900"
android:textSize="16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/no_routes"
android:textColor="@color/gray_900"
android:textSize="16sp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAddAddressEmpty"
android:layout_width="wrap_content"
android:layout_height="48dp"
android:layout_marginTop="24dp"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:text="@string/add_address"
app:cornerRadius="24dp" />
</LinearLayout>
<!-- Estado con direcciones -->
<LinearLayout
android:id="@+id/layoutWithAddresses"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:visibility="gone"
tools:visibility="visible">
<!-- Sección de mis direcciones -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Mis direcciones"
android:textColor="@color/gray_900"
android:textSize="16sp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAddMoreAddress"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="+ Agregar"
android:textColor="@color/green_primary" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvAddresses"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
tools:itemCount="2"
tools:listitem="@layout/item_address" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp"
android:background="@color/gray_200" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Rutas activas"
android:textColor="@color/gray_900"
android:textSize="16sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvRoutes"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:clipToPadding="false"
tools:listitem="@layout/item_route" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="1dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="12dp">
<TextView
android:id="@+id/tvStreet"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="@color/gray_900"
android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/btnDelete"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvNeighborhood"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/green_primary"
android:textSize="13sp"
app:layout_constraintEnd_toStartOf="@id/btnDelete"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvStreet" />
<TextView
android:id="@+id/tvReference"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/gray_500"
android:textSize="12sp"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/btnDelete"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvNeighborhood" />
<ImageButton
android:id="@+id/btnDelete"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Eliminar"
android:src="@android:drawable/ic_menu_delete"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -18,7 +18,7 @@
android:id="@+id/tvRouteName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/gray_900"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp"
android:textStyle="bold" />