diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4fcafda..fa94787 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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")
+ }
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2457cb8..1195a50 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
+
@@ -30,6 +34,9 @@
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/basurapp/AddAddressActivity.kt b/app/src/main/java/com/example/basurapp/AddAddressActivity.kt
new file mode 100644
index 0000000..8f6f982
--- /dev/null
+++ b/app/src/main/java/com/example/basurapp/AddAddressActivity.kt
@@ -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 = 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()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/basurapp/LoginActivity.kt b/app/src/main/java/com/example/basurapp/LoginActivity.kt
index 7e17837..3aa6ac7 100644
--- a/app/src/main/java/com/example/basurapp/LoginActivity.kt
+++ b/app/src/main/java/com/example/basurapp/LoginActivity.kt
@@ -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))
diff --git a/app/src/main/java/com/example/basurapp/MainActivity.kt b/app/src/main/java/com/example/basurapp/MainActivity.kt
index 965239c..20d3b3f 100644
--- a/app/src/main/java/com/example/basurapp/MainActivity.kt
+++ b/app/src/main/java/com/example/basurapp/MainActivity.kt
@@ -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() {
diff --git a/app/src/main/java/com/example/basurapp/adapters/AddressesAdapter.kt b/app/src/main/java/com/example/basurapp/adapters/AddressesAdapter.kt
new file mode 100644
index 0000000..4e2b2b4
--- /dev/null
+++ b/app/src/main/java/com/example/basurapp/adapters/AddressesAdapter.kt
@@ -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(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() {
+ override fun areItemsTheSame(oldItem: Address, newItem: Address) = oldItem.id == newItem.id
+ override fun areContentsTheSame(oldItem: Address, newItem: Address) = oldItem == newItem
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/basurapp/api/ApiClient.kt b/app/src/main/java/com/example/basurapp/api/ApiClient.kt
new file mode 100644
index 0000000..61cb91b
--- /dev/null
+++ b/app/src/main/java/com/example/basurapp/api/ApiClient.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/basurapp/api/BasurAppApi.kt b/app/src/main/java/com/example/basurapp/api/BasurAppApi.kt
new file mode 100644
index 0000000..dfa7071
--- /dev/null
+++ b/app/src/main/java/com/example/basurapp/api/BasurAppApi.kt
@@ -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
+
+ @GET("sistemasJSON/colonias-rutas.json")
+ suspend fun getColoniasRutas(): List
+
+ @GET("sistemasJSON/notificaciones.json")
+ suspend fun getNotificationRules(): List
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/basurapp/fragments/HomeFragment.kt b/app/src/main/java/com/example/basurapp/fragments/HomeFragment.kt
index aff757e..66a6464 100644
--- a/app/src/main/java/com/example/basurapp/fragments/HomeFragment.kt
+++ b/app/src/main/java/com/example/basurapp/fragments/HomeFragment.kt
@@ -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 = emptyList()
+ private var coloniasRutas: List = emptyList()
+ private var notificationRules: List = emptyList()
+
+ // Mapa: routeId -> último positionId conocido (para detectar cambios por ruta)
+ private val lastPositionByRoute = mutableMapOf()
+
+ 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>()
+ val seenRouteIds = mutableSetOf()
+
+ 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
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/basurapp/models/Adress.kt b/app/src/main/java/com/example/basurapp/models/Adress.kt
index d896474..e5cbff3 100644
--- a/app/src/main/java/com/example/basurapp/models/Adress.kt
+++ b/app/src/main/java/com/example/basurapp/models/Adress.kt
@@ -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 = ""
)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/basurapp/models/ApiRoute.kt b/app/src/main/java/com/example/basurapp/models/ApiRoute.kt
new file mode 100644
index 0000000..07944a8
--- /dev/null
+++ b/app/src/main/java/com/example/basurapp/models/ApiRoute.kt
@@ -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 = 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"
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/basurapp/models/ColoniaRuta.kt b/app/src/main/java/com/example/basurapp/models/ColoniaRuta.kt
new file mode 100644
index 0000000..aa31aea
--- /dev/null
+++ b/app/src/main/java/com/example/basurapp/models/ColoniaRuta.kt
@@ -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 = ""
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/basurapp/models/NotificacionRule.kt b/app/src/main/java/com/example/basurapp/models/NotificacionRule.kt
new file mode 100644
index 0000000..de384e9
--- /dev/null
+++ b/app/src/main/java/com/example/basurapp/models/NotificacionRule.kt
@@ -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 = ""
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/basurapp/utils/NotificationHelper.kt b/app/src/main/java/com/example/basurapp/utils/NotificationHelper.kt
new file mode 100644
index 0000000..193fdd8
--- /dev/null
+++ b/app/src/main/java/com/example/basurapp/utils/NotificationHelper.kt
@@ -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())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/basurapp/utils/RouteTracker.kt b/app/src/main/java/com/example/basurapp/utils/RouteTracker.kt
new file mode 100644
index 0000000..7301fa8
--- /dev/null
+++ b/app/src/main/java/com/example/basurapp/utils/RouteTracker.kt
@@ -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
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_add_address.xml b/app/src/main/res/layout/activity_add_address.xml
new file mode 100644
index 0000000..bb57329
--- /dev/null
+++ b/app/src/main/res/layout/activity_add_address.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml
new file mode 100644
index 0000000..8483098
--- /dev/null
+++ b/app/src/main/res/layout/fragment_home.xml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_address.xml b/app/src/main/res/layout/item_address.xml
new file mode 100644
index 0000000..c5c7110
--- /dev/null
+++ b/app/src/main/res/layout/item_address.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_route.xml b/app/src/main/res/layout/item_route.xml
index 6a32970..0fa7db8 100644
--- a/app/src/main/res/layout/item_route.xml
+++ b/app/src/main/res/layout/item_route.xml
@@ -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" />