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